← back to Web Viewer 3877
lib/safe-fetch.js
216 lines
// 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 },
};