← 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 },
};