aboutsummaryrefslogtreecommitdiffstats
path: root/lib/safe-fetch.ts
blob: 1f9ba2fe413d255e41ce3e75156c861232dcd750 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// SSRF-resistant fetch for user-supplied URLs (lnurl / lightning-address
// resolution in routes/lnurl.ts). Rejects any host that resolves to a
// loopback, private, link-local, CGNAT, or cloud-metadata address.
//
// Unlike a resolve-then-fetch guard (which re-resolves DNS when it actually
// connects, leaving a DNS-rebinding TOCTOU window), this pins the socket to the
// exact IP it validated: the connection's DNS lookup IS the validation, in one
// callback, so a low-TTL rebind cannot swap in an internal IP after the check.
// Redirects are followed manually and each hop is re-validated the same way.
// Scheme is restricted to http(s); 8s timeout; 2MB streamed-body cap.
import http from "node:http";
import https from "node:https";
import dns from "node:dns";
import net from "node:net";

const MAX_BYTES = 2_000_000;
const MAX_REDIRECTS = 4;
const TIMEOUT_MS = 8000;

const blockedV4 = (ip: string): boolean => {
  const p = ip.split(".").map(Number);
  if (p.length !== 4 || p.some((n) => Number.isNaN(n))) return true;
  const [a, b] = p;
  if (a === 0) return true; // 0.0.0.0/8
  if (a === 10) return true; // private
  if (a === 127) return true; // loopback
  if (a === 169 && b === 254) return true; // link-local + cloud metadata (169.254.169.254)
  if (a === 172 && b >= 16 && b <= 31) return true; // private
  if (a === 192 && b === 168) return true; // private
  if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10 (incl. tailscale)
  if (a >= 224) return true; // multicast / reserved
  return false;
};

const isBlockedIp = (ip: string): boolean => {
  if (net.isIPv4(ip)) return blockedV4(ip);
  if (net.isIPv6(ip)) {
    const low = ip.toLowerCase();
    if (low === "::1" || low === "::") return true; // loopback / unspecified
    const mapped = low.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
    if (mapped) return blockedV4(mapped[1]); // IPv4-mapped
    if (/^fe[89ab]/.test(low)) return true; // fe80::/10 link-local
    const head = low.split(":")[0];
    if (head.startsWith("fc") || head.startsWith("fd")) return true; // fc00::/7 ULA
    return false;
  }
  return true; // not a recognizable IP -> block
};

// A drop-in for the Node dns.lookup used by http(s).request: resolves the host,
// rejects the whole connection if ANY resolved address is blocked (so an
// attacker can't return [public, private] and hope the stack picks the private
// one), and otherwise hands the socket the first allowed address. Because the
// address returned here is the one the socket connects to, there is no
// resolve/connect gap for a rebind to slip through.
const pinnedLookup = (
  hostname: string,
  options: any,
  cb: (err: Error | null, address?: any, family?: number) => void,
): void => {
  // hostname may be an IP literal (redirect to a raw IP) — validate directly.
  if (net.isIP(hostname)) {
    if (isBlockedIp(hostname)) return cb(new Error(`blocked host ${hostname}`));
    const fam = net.isIPv6(hostname) ? 6 : 4;
    return cb(null, options?.all ? [{ address: hostname, family: fam }] : hostname, fam);
  }
  dns.lookup(hostname, { all: true }, (err, addrs: any[]) => {
    if (err) return cb(err);
    if (!addrs?.length) return cb(new Error(`cannot resolve ${hostname}`));
    const bad = addrs.find((a) => isBlockedIp(a.address));
    if (bad) return cb(new Error(`blocked host ${hostname} -> ${bad.address}`));
    const a = addrs[0];
    if (options?.all) cb(null, [{ address: a.address, family: a.family }]);
    else cb(null, a.address, a.family);
  });
};

const fetchOnce = (url: string, redirectsLeft: number): Promise<any> =>
  new Promise((resolve, reject) => {
    let u: URL;
    try { u = new URL(url); } catch { return reject(new Error("invalid url")); }
    if (u.protocol !== "http:" && u.protocol !== "https:")
      return reject(new Error("unsupported scheme"));

    // CRITICAL: Node skips the custom `lookup` when the URL host is already an
    // IP literal, so pinnedLookup alone would not see a raw-IP SSRF target.
    // Validate any literal host up front (strip IPv6 brackets first).
    const literal = u.hostname.replace(/^\[|\]$/g, "");
    if (net.isIP(literal) && isBlockedIp(literal))
      return reject(new Error(`blocked host ${literal}`));

    const mod = u.protocol === "https:" ? https : http;
    const req = mod.request(
      url,
      { method: "GET", lookup: pinnedLookup as any, headers: { accept: "application/json" } },
      (res) => {
        const status = res.statusCode || 0;

        // manual redirect handling — re-validate each hop via pinnedLookup
        if (status >= 300 && status < 400 && res.headers.location) {
          res.resume(); // drain
          if (redirectsLeft <= 0) return reject(new Error("too many redirects"));
          let next: string;
          try { next = new URL(res.headers.location, url).toString(); }
          catch { return reject(new Error("bad redirect target")); }
          return resolve(fetchOnce(next, redirectsLeft - 1));
        }

        let len = 0;
        const chunks: Buffer[] = [];
        res.on("data", (c: Buffer) => {
          len += c.length;
          if (len > MAX_BYTES) { req.destroy(); reject(new Error("response too large")); return; }
          chunks.push(c);
        });
        res.on("end", () => {
          try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
          catch { reject(new Error("invalid json response")); }
        });
      },
    );
    req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error("request timed out")));
    req.on("error", reject);
    req.end();
  });

// Fetch a user-supplied URL with SSRF protection and return the parsed JSON
// body. Throws on a bad scheme/URL or a host that resolves into a blocked range.
export const safeGot = async (url: string): Promise<any> => fetchOnce(url, MAX_REDIRECTS);