ssrf-guard.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { beforeEach, describe, expect, it, vi } from 'vitest';
  2. const { lookupMock } = vi.hoisted(() => ({
  3. lookupMock: vi.fn(),
  4. }));
  5. vi.mock('node:dns', () => ({
  6. promises: {
  7. lookup: lookupMock,
  8. },
  9. }));
  10. describe('validateUrlForSSRF', () => {
  11. beforeEach(() => {
  12. vi.resetModules();
  13. lookupMock.mockReset();
  14. });
  15. it('allows a public hostname when DNS resolves to a public IP', async () => {
  16. lookupMock.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
  17. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  18. await expect(validateUrlForSSRF('https://api.openai.com')).resolves.toBeNull();
  19. expect(lookupMock).toHaveBeenCalledWith('api.openai.com', { all: true, verbatim: true });
  20. });
  21. it('allows a public IP literal without DNS lookup', async () => {
  22. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  23. await expect(validateUrlForSSRF('https://8.8.8.8')).resolves.toBeNull();
  24. expect(lookupMock).not.toHaveBeenCalled();
  25. });
  26. it('allows a public IPv6 literal without DNS lookup', async () => {
  27. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  28. await expect(validateUrlForSSRF('https://[2606:4700:4700::1111]')).resolves.toBeNull();
  29. expect(lookupMock).not.toHaveBeenCalled();
  30. });
  31. it('rejects invalid URLs and non-http protocols', async () => {
  32. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  33. await expect(validateUrlForSSRF('not-a-url')).resolves.toBe('Invalid URL');
  34. await expect(validateUrlForSSRF('ftp://example.com')).resolves.toBe(
  35. 'Only HTTP(S) URLs are allowed',
  36. );
  37. await expect(validateUrlForSSRF('file:///etc/passwd')).resolves.toBe(
  38. 'Only HTTP(S) URLs are allowed',
  39. );
  40. await expect(validateUrlForSSRF('javascript:alert(1)')).resolves.toBe(
  41. 'Only HTTP(S) URLs are allowed',
  42. );
  43. });
  44. it('rejects blocked hostnames immediately', async () => {
  45. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  46. await expect(validateUrlForSSRF('http://localhost')).resolves.toBe(
  47. 'Local/private network URLs are not allowed',
  48. );
  49. await expect(validateUrlForSSRF('http://printer.local')).resolves.toBe(
  50. 'Local/private network URLs are not allowed',
  51. );
  52. expect(lookupMock).not.toHaveBeenCalled();
  53. });
  54. it('rejects private IPv4 literals', async () => {
  55. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  56. const urls = [
  57. 'http://127.0.0.1',
  58. 'http://10.0.0.42',
  59. 'http://172.16.5.4',
  60. 'http://172.31.255.255',
  61. 'http://192.168.1.10',
  62. 'http://169.254.169.254',
  63. 'http://0.0.0.0',
  64. ];
  65. for (const url of urls) {
  66. await expect(validateUrlForSSRF(url)).resolves.toBe(
  67. 'Local/private network URLs are not allowed',
  68. );
  69. }
  70. expect(lookupMock).not.toHaveBeenCalled();
  71. });
  72. it('rejects private IPv6 literals and mapped loopback addresses', async () => {
  73. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  74. const urls = [
  75. 'http://[::1]',
  76. 'http://[fd00::1234]',
  77. 'http://[fe80::1]',
  78. 'http://[fec0::1]',
  79. 'http://[::ffff:127.0.0.1]',
  80. ];
  81. for (const url of urls) {
  82. await expect(validateUrlForSSRF(url)).resolves.toBe(
  83. 'Local/private network URLs are not allowed',
  84. );
  85. }
  86. expect(lookupMock).not.toHaveBeenCalled();
  87. });
  88. it('rejects 6to4 tunnel addresses embedding private IPv4', async () => {
  89. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  90. // 2002:7f00:0001:: embeds 127.0.0.1
  91. await expect(validateUrlForSSRF('http://[2002:7f00:0001::]')).resolves.toBe(
  92. 'Local/private network URLs are not allowed',
  93. );
  94. // 2002:0a00:0001:: embeds 10.0.0.1
  95. await expect(validateUrlForSSRF('http://[2002:0a00:0001::]')).resolves.toBe(
  96. 'Local/private network URLs are not allowed',
  97. );
  98. expect(lookupMock).not.toHaveBeenCalled();
  99. });
  100. it('allows 6to4 tunnel addresses embedding public IPv4', async () => {
  101. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  102. // 2002:0808:0808:: embeds 8.8.8.8
  103. await expect(validateUrlForSSRF('http://[2002:0808:0808::]')).resolves.toBeNull();
  104. expect(lookupMock).not.toHaveBeenCalled();
  105. });
  106. it('rejects Teredo tunnel addresses embedding private IPv4', async () => {
  107. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  108. // Client IPv4 127.0.0.1 XOR 0xFFFFFFFF = 0x80FFFFFE → hextets 80ff:fffe
  109. await expect(
  110. validateUrlForSSRF('http://[2001:0000:4136:e378:8000:63bf:80ff:fffe]'),
  111. ).resolves.toBe('Local/private network URLs are not allowed');
  112. expect(lookupMock).not.toHaveBeenCalled();
  113. });
  114. it('allows Teredo tunnel addresses embedding public IPv4', async () => {
  115. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  116. // Client IPv4 8.8.8.8 XOR 0xFFFFFFFF = 0xF7F7F7F7 → hextets f7f7:f7f7
  117. await expect(
  118. validateUrlForSSRF('http://[2001:0000:4136:e378:8000:63bf:f7f7:f7f7]'),
  119. ).resolves.toBeNull();
  120. expect(lookupMock).not.toHaveBeenCalled();
  121. });
  122. it('rejects hostnames that resolve to a private IP', async () => {
  123. lookupMock.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
  124. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  125. await expect(validateUrlForSSRF('https://attacker.com')).resolves.toBe(
  126. 'Local/private network URLs are not allowed',
  127. );
  128. });
  129. it('rejects hostnames when any DNS answer is private', async () => {
  130. lookupMock.mockResolvedValue([
  131. { address: '93.184.216.34', family: 4 },
  132. { address: '192.168.1.10', family: 4 },
  133. ]);
  134. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  135. await expect(validateUrlForSSRF('https://mixed.example')).resolves.toBe(
  136. 'Local/private network URLs are not allowed',
  137. );
  138. });
  139. it('fails closed when DNS lookup errors', async () => {
  140. lookupMock.mockRejectedValue(new Error('ENOTFOUND'));
  141. const { validateUrlForSSRF } = await import('@/lib/server/ssrf-guard');
  142. await expect(validateUrlForSSRF('https://missing.example')).resolves.toBe(
  143. 'Unable to verify hostname safety',
  144. );
  145. });
  146. });