[object Object]

← back to Web Viewer 3877

add lib/safe-fetch.js — SSRF-hardened wrapper around axios

f15b9a4496c582b5a54c4efc92aeb311c626a1e6 · 2026-05-19 22:14:05 -0700 · Steve

Enforces scheme allowlist (http/https only), DNS-resolves the hostname
and rejects any private/internal/link-local/loopback/CGNAT/multicast/
IPv6-ULA target, pins the resolved IP into the axios request to defeat
DNS rebinding, follows redirects manually (max 5 hops, re-validates each
Location), and caps response size at 5 MB with a 10 s timeout. Errors
carry .statusCode and .isSsrfBlock for the API layer to translate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit f15b9a4496c582b5a54c4efc92aeb311c626a1e6
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 22:14:05 2026 -0700

    add lib/safe-fetch.js — SSRF-hardened wrapper around axios
    
    Enforces scheme allowlist (http/https only), DNS-resolves the hostname
    and rejects any private/internal/link-local/loopback/CGNAT/multicast/
    IPv6-ULA target, pins the resolved IP into the axios request to defeat
    DNS rebinding, follows redirects manually (max 5 hops, re-validates each
    Location), and caps response size at 5 MB with a 10 s timeout. Errors
    carry .statusCode and .isSsrfBlock for the API layer to translate.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 lib/safe-fetch.js | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 215 insertions(+)

diff --git a/lib/safe-fetch.js b/lib/safe-fetch.js
new file mode 100644
index 0000000..6da6969
--- /dev/null
+++ b/lib/safe-fetch.js
@@ -0,0 +1,215 @@
+// safe-fetch.js — SSRF-hardened wrapper around axios for web-viewer-3877.
+//
+// All user-supplied URLs go through this helper. Both POST /api/fetch and the
+// Socket.IO load-url handler use it — no bypass path.
+//
+// Hardening (per Steve's 2026-05-19 best-practices Option B):
+//   1. URL must parse cleanly.
+//   2. Scheme allowlist: http:, https: only.
+//   3. DNS-resolve hostname (all addresses). Reject if ANY resolves into a
+//      private/internal/link-local/loopback/multicast/CGNAT/IPv6-ULA range.
+//   4. Pin resolved IP into the axios request (dns rebinding defense).
+//   5. Follow redirects manually (maxRedirects:0), cap at 5 hops, re-run
+//      validation on every Location.
+//   6. Cap body size (5 MB) + timeout (10s).
+//
+// Errors carry .statusCode so the caller can translate to HTTP 400/403/etc.
+
+const dns = require('dns').promises;
+const net = require('net');
+const axios = require('axios');
+
+const MAX_REDIRECTS = 5;
+const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
+const TIMEOUT_MS = 10_000;
+
+// IPv4 CIDR blocks that must NEVER be reachable through this proxy.
+// Format: [networkInt, maskBits]
+const BLOCKED_V4 = [
+  ['0.0.0.0', 8],       // "this network"
+  ['10.0.0.0', 8],      // RFC1918
+  ['100.64.0.0', 10],   // CGNAT
+  ['127.0.0.0', 8],     // loopback
+  ['169.254.0.0', 16],  // link-local + AWS/GCP/Azure metadata (169.254.169.254)
+  ['172.16.0.0', 12],   // RFC1918
+  ['192.168.0.0', 16],  // RFC1918
+  ['224.0.0.0', 4],     // multicast
+  ['240.0.0.0', 4],     // reserved (includes 255.255.255.255 broadcast)
+];
+
+function v4ToInt(ip) {
+  const parts = ip.split('.').map(Number);
+  if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) {
+    return null;
+  }
+  return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
+}
+
+function isBlockedV4(ip) {
+  const ipInt = v4ToInt(ip);
+  if (ipInt === null) return true; // unparseable v4 = treat as blocked
+  for (const [net4, bits] of BLOCKED_V4) {
+    const netInt = v4ToInt(net4);
+    if (netInt === null) continue;
+    const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
+    if ((ipInt & mask) === (netInt & mask)) return true;
+  }
+  return false;
+}
+
+function isBlockedV6(ip) {
+  // Normalize: lowercase, strip zone id (%eth0)
+  const addr = ip.toLowerCase().split('%')[0];
+  if (addr === '::' || addr === '::1') return true; // unspecified + loopback
+  // IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded v4.
+  const v4mapped = addr.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
+  if (v4mapped) return isBlockedV4(v4mapped[1]);
+  // Also IPv4-mapped in hex form: ::ffff:xxxx:xxxx
+  const v4mappedHex = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
+  if (v4mappedHex) {
+    const hi = parseInt(v4mappedHex[1], 16);
+    const lo = parseInt(v4mappedHex[2], 16);
+    const v4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
+    return isBlockedV4(v4);
+  }
+  // fc00::/7 — Unique Local Addresses
+  if (/^f[cd][0-9a-f]{2}:/.test(addr)) return true;
+  // fe80::/10 — link-local
+  if (/^fe[89ab][0-9a-f]:/.test(addr)) return true;
+  // ff00::/8 — multicast
+  if (/^ff[0-9a-f]{2}:/.test(addr)) return true;
+  // ::/128 already caught above
+  return false;
+}
+
+function ssrfError(message, statusCode) {
+  const err = new Error(message);
+  err.statusCode = statusCode;
+  err.isSsrfBlock = true;
+  return err;
+}
+
+// Validate a single URL string: parse, scheme-check, DNS-resolve, IP-range-check.
+// Returns { parsed: URL, resolvedIp: string, family: 4|6 } on success, throws on failure.
+async function validateUrl(rawUrl) {
+  let parsed;
+  try {
+    parsed = new URL(rawUrl);
+  } catch (_e) {
+    throw ssrfError('invalid URL', 400);
+  }
+  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+    throw ssrfError(`scheme not allowed: ${parsed.protocol}`, 400);
+  }
+  const hostname = parsed.hostname;
+  if (!hostname) throw ssrfError('invalid URL: empty hostname', 400);
+
+  // Strip IPv6 brackets if hostname is a literal v6
+  const bareHost = hostname.replace(/^\[|\]$/g, '');
+
+  // If hostname is already an IP literal, validate directly (no DNS lookup).
+  if (net.isIP(bareHost)) {
+    const family = net.isIP(bareHost);
+    const blocked = family === 4 ? isBlockedV4(bareHost) : isBlockedV6(bareHost);
+    if (blocked) throw ssrfError('private/internal address blocked', 403);
+    return { parsed, resolvedIp: bareHost, family };
+  }
+
+  // Otherwise resolve all addresses and reject if ANY are blocked.
+  let addrs;
+  try {
+    addrs = await dns.lookup(bareHost, { all: true });
+  } catch (_e) {
+    throw ssrfError('DNS lookup failed', 400);
+  }
+  if (!addrs || addrs.length === 0) throw ssrfError('DNS lookup returned no addresses', 400);
+
+  for (const a of addrs) {
+    const blocked = a.family === 4 ? isBlockedV4(a.address) : isBlockedV6(a.address);
+    if (blocked) throw ssrfError('private/internal address blocked', 403);
+  }
+
+  // Pin the first resolved address (DNS-rebinding defense).
+  const first = addrs[0];
+  return { parsed, resolvedIp: first.address, family: first.family };
+}
+
+// Perform the actual fetch with redirect-following done by US, not axios.
+// Returns the final axios response object on success.
+async function safeFetch(rawUrl, opts = {}) {
+  let currentUrl = rawUrl;
+  let hop = 0;
+
+  while (true) {
+    if (hop > MAX_REDIRECTS) {
+      throw ssrfError(`too many redirects (>${MAX_REDIRECTS})`, 502);
+    }
+
+    const { parsed, resolvedIp, family } = await validateUrl(currentUrl);
+
+    // Build a pinned-IP lookup so axios/node-http can't re-resolve.
+    const pinnedLookup = (_hostname, _options, cb) => {
+      // Signature handles both (hostname, options, cb) and (hostname, cb).
+      const callback = typeof _options === 'function' ? _options : cb;
+      callback(null, resolvedIp, family);
+    };
+
+    let response;
+    try {
+      response = await axios.get(parsed.toString(), {
+        headers: {
+          'User-Agent':
+            opts.userAgent ||
+            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+          // Preserve Host header so virtual hosts still work after IP pinning.
+          Host: parsed.host,
+        },
+        timeout: TIMEOUT_MS,
+        maxRedirects: 0,
+        maxContentLength: MAX_BYTES,
+        maxBodyLength: MAX_BYTES,
+        // Don't throw on 3xx — we handle them manually.
+        validateStatus: (s) => s >= 200 && s < 400,
+        lookup: pinnedLookup,
+        // Disable axios's own decompression-side size check bypass by capping
+        // responseType to text/buffer (cheerio expects string anyway).
+        responseType: 'text',
+      });
+    } catch (err) {
+      // axios wraps redirect responses (status 3xx) when validateStatus rejects
+      // them. With validateStatus above we accept 3xx into response.
+      if (err.isSsrfBlock) throw err;
+      // Map axios-specific failures to a generic 502.
+      const upstreamStatus = err.response && err.response.status;
+      const msg = err.message || 'upstream fetch failed';
+      throw ssrfError(`upstream fetch failed: ${msg}`, upstreamStatus ? 502 : 502);
+    }
+
+    // 3xx? follow it ourselves after re-validating.
+    if (response.status >= 300 && response.status < 400) {
+      const loc = response.headers && response.headers.location;
+      if (!loc) {
+        throw ssrfError(`redirect with no Location header (status ${response.status})`, 502);
+      }
+      // Resolve relative redirects against the current URL.
+      let nextUrl;
+      try {
+        nextUrl = new URL(loc, parsed).toString();
+      } catch (_e) {
+        throw ssrfError('redirect Location is not a valid URL', 502);
+      }
+      currentUrl = nextUrl;
+      hop += 1;
+      continue;
+    }
+
+    return response;
+  }
+}
+
+module.exports = {
+  safeFetch,
+  validateUrl,
+  // exported for tests
+  _internals: { isBlockedV4, isBlockedV6, v4ToInt },
+};

← 83bda2d fix .gitignore: expand broken literal "\n" line + dedupe + a  ·  back to Web Viewer 3877  ·  route /api/fetch + Socket.IO load-url through safeFetch() 21bf7c6 →