← 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 →