ssrf-guard.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. /**
  2. * SSRF (Server-Side Request Forgery) protection utilities.
  3. *
  4. * Validates URLs to prevent requests to internal/private network addresses.
  5. * Used by any API route that fetches a user-supplied URL server-side.
  6. */
  7. import { promises as dns } from 'node:dns';
  8. import { isIP } from 'node:net';
  9. function normalizeAddress(value: string): string {
  10. let normalized = value.trim().toLowerCase();
  11. if (normalized.startsWith('[') && normalized.endsWith(']')) {
  12. normalized = normalized.slice(1, -1);
  13. }
  14. return normalized.replace(/\.+$/, '');
  15. }
  16. function parseIPv4(ip: string): number[] | null {
  17. const parts = ip.split('.');
  18. if (parts.length !== 4) return null;
  19. const octets = parts.map((part) => {
  20. if (!/^\d+$/.test(part)) {
  21. return Number.NaN;
  22. }
  23. return Number.parseInt(part, 10);
  24. });
  25. if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) {
  26. return null;
  27. }
  28. return octets;
  29. }
  30. function extractMappedIPv4(ip: string): string | null {
  31. const normalized = normalizeAddress(ip);
  32. if (!normalized.startsWith('::ffff:')) {
  33. return null;
  34. }
  35. const suffix = normalized.slice('::ffff:'.length);
  36. const dottedIPv4 = parseIPv4(suffix);
  37. if (dottedIPv4) {
  38. return dottedIPv4.join('.');
  39. }
  40. const parts = suffix.split(':');
  41. if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) {
  42. return null;
  43. }
  44. const [high, low] = parts.map((part) => Number.parseInt(part, 16));
  45. return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.');
  46. }
  47. function getFirstIPv6Hextet(ip: string): number | null {
  48. const normalized = normalizeAddress(ip);
  49. if (!normalized.includes(':')) {
  50. return null;
  51. }
  52. if (normalized.startsWith('::')) {
  53. return 0;
  54. }
  55. const [firstHextet] = normalized.split(':');
  56. if (!firstHextet || !/^[0-9a-f]{1,4}$/.test(firstHextet)) {
  57. return null;
  58. }
  59. return Number.parseInt(firstHextet, 16);
  60. }
  61. /** Expand an IPv6 address into 8 numeric hextets. Returns null for invalid input. */
  62. function expandIPv6(ip: string): number[] | null {
  63. const normalized = normalizeAddress(ip);
  64. if (!normalized.includes(':')) return null;
  65. // Skip IPv4-suffix forms (handled separately by extractMappedIPv4)
  66. const lastPart = normalized.split(':').pop() || '';
  67. if (lastPart.includes('.')) return null;
  68. const sides = normalized.split('::');
  69. if (sides.length > 2) return null;
  70. let parts: string[];
  71. if (sides.length === 2) {
  72. const left = sides[0] ? sides[0].split(':') : [];
  73. const right = sides[1] ? sides[1].split(':') : [];
  74. const missing = 8 - left.length - right.length;
  75. if (missing < 0) return null;
  76. parts = [...left, ...Array(missing).fill('0'), ...right];
  77. } else {
  78. parts = normalized.split(':');
  79. }
  80. if (parts.length !== 8) return null;
  81. if (parts.some((p) => !/^[0-9a-f]{1,4}$/.test(p))) return null;
  82. return parts.map((p) => Number.parseInt(p, 16));
  83. }
  84. export function isPrivateIP(ip: string): boolean {
  85. const normalized = normalizeAddress(ip);
  86. const mappedIPv4 = extractMappedIPv4(normalized);
  87. if (mappedIPv4) {
  88. return isPrivateIP(mappedIPv4);
  89. }
  90. const ipv4 = parseIPv4(normalized);
  91. if (ipv4) {
  92. const [first, second, third, fourth] = ipv4;
  93. return (
  94. first === 0 ||
  95. first === 10 ||
  96. first === 127 ||
  97. (first === 169 && second === 254) ||
  98. (first === 172 && second >= 16 && second <= 31) ||
  99. (first === 192 && second === 168) ||
  100. (first === 0 && second === 0 && third === 0 && fourth === 0)
  101. );
  102. }
  103. const ipv6FirstHextet = getFirstIPv6Hextet(normalized);
  104. if (ipv6FirstHextet === null) {
  105. return false;
  106. }
  107. if (normalized === '::' || normalized === '::1') {
  108. return true;
  109. }
  110. if (
  111. (ipv6FirstHextet & 0xfe00) === 0xfc00 || // fc00::/7 unique local
  112. (ipv6FirstHextet & 0xffc0) === 0xfe80 || // fe80::/10 link-local
  113. (ipv6FirstHextet & 0xffc0) === 0xfec0 // fec0::/10 site-local (deprecated)
  114. ) {
  115. return true;
  116. }
  117. // 6to4 tunnel: 2002::/16 — embedded IPv4 sits in bits 16-47
  118. if (ipv6FirstHextet === 0x2002) {
  119. const hextets = expandIPv6(normalized);
  120. if (hextets) {
  121. const embedded = `${hextets[1] >> 8}.${hextets[1] & 0xff}.${hextets[2] >> 8}.${hextets[2] & 0xff}`;
  122. if (isPrivateIP(embedded)) return true;
  123. }
  124. }
  125. // Teredo tunnel: 2001:0000::/32 — client IPv4 in last 32 bits, XOR-inverted
  126. if (ipv6FirstHextet === 0x2001) {
  127. const hextets = expandIPv6(normalized);
  128. if (hextets && hextets[1] === 0x0000) {
  129. const high = hextets[6] ^ 0xffff;
  130. const low = hextets[7] ^ 0xffff;
  131. const embedded = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
  132. if (isPrivateIP(embedded)) return true;
  133. }
  134. }
  135. return false;
  136. }
  137. /**
  138. * Validate a URL against SSRF attacks.
  139. * Returns null if the URL is safe, or an error message string if blocked.
  140. */
  141. export async function validateUrlForSSRF(url: string): Promise<string | null> {
  142. let parsed: URL;
  143. try {
  144. parsed = new URL(url);
  145. } catch {
  146. return 'Invalid URL';
  147. }
  148. if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
  149. return 'Only HTTP(S) URLs are allowed';
  150. }
  151. // Self-hosted deployments can set ALLOW_LOCAL_NETWORKS=true to skip private-IP checks
  152. const allowLocal = process.env.ALLOW_LOCAL_NETWORKS;
  153. if (allowLocal === 'true' || allowLocal === '1') {
  154. return null;
  155. }
  156. const hostname = normalizeAddress(parsed.hostname);
  157. if (
  158. hostname === 'localhost' ||
  159. hostname.endsWith('.local') ||
  160. hostname === '0.0.0.0' ||
  161. hostname === '::1' ||
  162. isPrivateIP(hostname)
  163. ) {
  164. return 'Local/private network URLs are not allowed';
  165. }
  166. if (isIP(hostname)) {
  167. return null;
  168. }
  169. let resolvedAddresses: Array<{ address: string; family: number }>;
  170. try {
  171. resolvedAddresses = await dns.lookup(hostname, { all: true, verbatim: true });
  172. } catch {
  173. return 'Unable to verify hostname safety';
  174. }
  175. if (resolvedAddresses.length === 0) {
  176. return 'Unable to verify hostname safety';
  177. }
  178. if (resolvedAddresses.some(({ address }) => isPrivateIP(address))) {
  179. return 'Local/private network URLs are not allowed';
  180. }
  181. return null;
  182. }