Merge pull request #1732 from manjunathbhaskar/fix/dns-rebinding-toctou-safeproxyfetch
fix: prevent DNS-rebinding TOCTOU in safeProxyFetch by pinning resolved IPs
This commit is contained in:
commit
cc416f0f93
10 changed files with 597 additions and 87 deletions
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
|
|
@ -46,6 +46,10 @@ jobs:
|
|||
working-directory: ./client
|
||||
run: npm test
|
||||
|
||||
- name: Run server tests
|
||||
working-directory: ./server
|
||||
run: npm test
|
||||
|
||||
- run: npm run build
|
||||
|
||||
publish:
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -13273,10 +13273,12 @@
|
|||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ws": "^8.5.12",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.2"
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"start-client": "cd client && npm run preview",
|
||||
"test": "npm run prettier-check && cd client && npm test",
|
||||
"test-cli": "cd cli && npm run test",
|
||||
"test-server": "cd server && npm run test",
|
||||
"test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client",
|
||||
"prettier-fix": "prettier --write .",
|
||||
"prettier-check": "prettier --check .",
|
||||
|
|
|
|||
|
|
@ -24,15 +24,19 @@
|
|||
"build": "tsc && shx cp -R static build",
|
||||
"start": "node build/index.js",
|
||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL"
|
||||
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL",
|
||||
"test": "tsc -p tsconfig.test.json && vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ws": "^8.5.12",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.2"
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
|
|
|
|||
359
server/src/__tests__/proxy-security.test.ts
Normal file
359
server/src/__tests__/proxy-security.test.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
/**
|
||||
* Unit tests for proxy-security.ts
|
||||
*
|
||||
* Tests cover:
|
||||
* - isBlockedProxyAddress: IPv4, IPv6, IPv4-mapped IPv6 (hex + dotted), edge cases
|
||||
* - assertSafeProxyTarget: safe IPs, blocked IPs, literal-IP hosts, DNS errors
|
||||
* - createPinnedAgent: correct agent type, lookup always returns pinned IP
|
||||
* - TOCTOU guarantee: the pinned agent never invokes the OS resolver
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import type dnsTypes from "node:dns";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import nodeFetch from "node-fetch";
|
||||
import { vi, describe, it, expect, afterEach } from "vitest";
|
||||
|
||||
// Mock node:dns/promises before importing the module under test so that
|
||||
// assertSafeProxyTarget's dnsLookup is replaceable in each test.
|
||||
vi.mock("node:dns/promises", () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as dns from "node:dns/promises";
|
||||
import {
|
||||
isBlockedProxyAddress,
|
||||
assertSafeProxyTarget,
|
||||
createPinnedAgent,
|
||||
ProxyTargetError,
|
||||
} from "../proxy-security.js";
|
||||
|
||||
// Convenience cast — vitest doesn't know the mock shape yet.
|
||||
const mockLookup = dns.lookup as ReturnType<typeof vi.fn>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isBlockedProxyAddress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isBlockedProxyAddress", () => {
|
||||
describe("IPv4 link-local (169.254.0.0/16)", () => {
|
||||
it("blocks 169.254.169.254 (AWS metadata)", () => {
|
||||
expect(isBlockedProxyAddress("169.254.169.254")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks 169.254.0.1 (first address in range)", () => {
|
||||
expect(isBlockedProxyAddress("169.254.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks 169.254.255.255 (last address in range)", () => {
|
||||
expect(isBlockedProxyAddress("169.254.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows 169.253.0.1 (just outside the range)", () => {
|
||||
expect(isBlockedProxyAddress("169.253.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows 170.254.0.1 (just outside the range)", () => {
|
||||
expect(isBlockedProxyAddress("170.254.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows loopback 127.0.0.1", () => {
|
||||
expect(isBlockedProxyAddress("127.0.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a public IP", () => {
|
||||
expect(isBlockedProxyAddress("93.184.216.34")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv6 link-local (fe80::/10)", () => {
|
||||
it("blocks fe80::1", () => {
|
||||
expect(isBlockedProxyAddress("fe80::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks fe80::aabb:ccdd (arbitrary link-local)", () => {
|
||||
expect(isBlockedProxyAddress("fe80::aabb:ccdd")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows ::1 (loopback)", () => {
|
||||
expect(isBlockedProxyAddress("::1")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows 2001:db8::1 (documentation range)", () => {
|
||||
expect(isBlockedProxyAddress("2001:db8::1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AWS IPv6 IMDS (fd00:ec2::254)", () => {
|
||||
it("blocks fd00:ec2::254 exactly", () => {
|
||||
expect(isBlockedProxyAddress("fd00:ec2::254")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows fd00:ec2::255 (adjacent address)", () => {
|
||||
expect(isBlockedProxyAddress("fd00:ec2::255")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv4-mapped IPv6 variants of 169.254.169.254", () => {
|
||||
it("blocks dotted form ::ffff:169.254.169.254", () => {
|
||||
expect(isBlockedProxyAddress("::ffff:169.254.169.254")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks hex form ::ffff:a9fe:a9fe (WHATWG URL serialization)", () => {
|
||||
expect(isBlockedProxyAddress("::ffff:a9fe:a9fe")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows IPv4-mapped loopback ::ffff:127.0.0.1", () => {
|
||||
expect(isBlockedProxyAddress("::ffff:127.0.0.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-IP strings", () => {
|
||||
it("allows empty string (not an IP)", () => {
|
||||
expect(isBlockedProxyAddress("")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows hostname string (not an IP)", () => {
|
||||
expect(isBlockedProxyAddress("example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// assertSafeProxyTarget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("assertSafeProxyTarget", () => {
|
||||
it("resolves and allows a safe hostname", async () => {
|
||||
mockLookup.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]);
|
||||
|
||||
const addrs = await assertSafeProxyTarget(new URL("http://example.com/"));
|
||||
expect(addrs).toEqual(["93.184.216.34"]);
|
||||
expect(mockLookup).toHaveBeenCalledWith("example.com", { all: true });
|
||||
});
|
||||
|
||||
it("throws ProxyTargetError when host resolves to blocked IP", async () => {
|
||||
mockLookup.mockResolvedValueOnce([
|
||||
{ address: "169.254.169.254", family: 4 },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
assertSafeProxyTarget(new URL("http://evil.example.com/")),
|
||||
).rejects.toThrow(ProxyTargetError);
|
||||
});
|
||||
|
||||
it("throws ProxyTargetError when any resolved IP is blocked (mixed results)", async () => {
|
||||
mockLookup.mockResolvedValueOnce([
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "169.254.169.254", family: 4 },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
assertSafeProxyTarget(new URL("http://dual.example.com/")),
|
||||
).rejects.toThrow(ProxyTargetError);
|
||||
});
|
||||
|
||||
it("throws ProxyTargetError when DNS lookup fails", async () => {
|
||||
mockLookup.mockRejectedValueOnce(new Error("ENOTFOUND"));
|
||||
|
||||
await expect(
|
||||
assertSafeProxyTarget(new URL("http://nonexistent.invalid/")),
|
||||
).rejects.toThrow(ProxyTargetError);
|
||||
});
|
||||
|
||||
it("skips DNS lookup for literal IPv4 hosts", async () => {
|
||||
const addrs = await assertSafeProxyTarget(new URL("http://127.0.0.1/path"));
|
||||
expect(addrs).toEqual(["127.0.0.1"]);
|
||||
expect(mockLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws ProxyTargetError for literal blocked IPv4", async () => {
|
||||
await expect(
|
||||
assertSafeProxyTarget(new URL("http://169.254.169.254/")),
|
||||
).rejects.toThrow(ProxyTargetError);
|
||||
expect(mockLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips DNS lookup for literal IPv6 hosts", async () => {
|
||||
const addrs = await assertSafeProxyTarget(new URL("http://[::1]/"));
|
||||
expect(addrs).toEqual(["::1"]);
|
||||
expect(mockLookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns all validated addresses so caller can pick one for pinning", async () => {
|
||||
mockLookup.mockResolvedValueOnce([
|
||||
{ address: "192.0.2.1", family: 4 },
|
||||
{ address: "192.0.2.2", family: 4 },
|
||||
]);
|
||||
|
||||
const addrs = await assertSafeProxyTarget(new URL("http://multi.example/"));
|
||||
expect(addrs).toHaveLength(2);
|
||||
expect(addrs).toContain("192.0.2.1");
|
||||
expect(addrs).toContain("192.0.2.2");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createPinnedAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The lookup hook `createPinnedAgent` installed on the agent. */
|
||||
type PinnedLookup = (
|
||||
hostname: string,
|
||||
options: dnsTypes.LookupOptions,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string | dnsTypes.LookupAddress[],
|
||||
family?: number,
|
||||
) => void,
|
||||
) => void;
|
||||
|
||||
function pinnedLookup(agent: http.Agent | https.Agent): PinnedLookup {
|
||||
const { lookup } = (agent as http.Agent & { options: http.AgentOptions })
|
||||
.options;
|
||||
if (typeof lookup !== "function") {
|
||||
throw new Error("expected createPinnedAgent to install a lookup hook");
|
||||
}
|
||||
return lookup as PinnedLookup;
|
||||
}
|
||||
|
||||
describe("createPinnedAgent", () => {
|
||||
it("returns an http.Agent for http: protocol", () => {
|
||||
const agent = createPinnedAgent("http:", ["127.0.0.1"]);
|
||||
expect(agent).toBeInstanceOf(http.Agent);
|
||||
expect(agent).not.toBeInstanceOf(https.Agent);
|
||||
});
|
||||
|
||||
it("returns an https.Agent for https: protocol", () => {
|
||||
const agent = createPinnedAgent("https:", ["127.0.0.1"]);
|
||||
expect(agent).toBeInstanceOf(https.Agent);
|
||||
});
|
||||
|
||||
it("pinned lookup returns the IPv4 address regardless of queried hostname", () => {
|
||||
const lookup = pinnedLookup(createPinnedAgent("http:", ["192.0.2.99"]));
|
||||
|
||||
const callback = vi.fn();
|
||||
lookup("example.com", {}, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null, "192.0.2.99", 4);
|
||||
});
|
||||
|
||||
it("pinned lookup returns the IPv6 address and family 6", () => {
|
||||
const lookup = pinnedLookup(createPinnedAgent("http:", ["2001:db8::1"]));
|
||||
|
||||
const callback = vi.fn();
|
||||
lookup("example.com", {}, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null, "2001:db8::1", 6);
|
||||
});
|
||||
|
||||
// Node's net.connect runs with autoSelectFamily on (Node >= 20), so it calls
|
||||
// the hook with { all: true } and requires the ARRAY callback shape. Handing
|
||||
// it a scalar there fails every hostname request with ERR_INVALID_IP_ADDRESS.
|
||||
it("pinned lookup returns the array shape when called with { all: true }", () => {
|
||||
const lookup = pinnedLookup(createPinnedAgent("http:", ["192.0.2.99"]));
|
||||
|
||||
const callback = vi.fn();
|
||||
lookup("example.com", { all: true }, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null, [
|
||||
{ address: "192.0.2.99", family: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pinned lookup returns the array shape for IPv6 too", () => {
|
||||
const lookup = pinnedLookup(createPinnedAgent("http:", ["2001:db8::1"]));
|
||||
|
||||
const callback = vi.fn();
|
||||
lookup("example.com", { all: true }, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null, [
|
||||
{ address: "2001:db8::1", family: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
// Every validated address is handed to the hook, so Node keeps its normal
|
||||
// address-family fallback for a dual-stack host whose first address is
|
||||
// unreachable — while still only ever seeing prevalidated addresses.
|
||||
it("passes every validated address through for dual-stack fallback", () => {
|
||||
const lookup = pinnedLookup(
|
||||
createPinnedAgent("http:", ["2001:db8::1", "192.0.2.7"]),
|
||||
);
|
||||
|
||||
const callback = vi.fn();
|
||||
lookup("dual.example", { all: true }, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null, [
|
||||
{ address: "2001:db8::1", family: 6 },
|
||||
{ address: "192.0.2.7", family: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when given no validated address", () => {
|
||||
expect(() => createPinnedAgent("http:", [])).toThrow(ProxyTargetError);
|
||||
});
|
||||
|
||||
// A dual-stack host whose first (IPv6) address is dead: the connection must
|
||||
// still complete via the second, validated address.
|
||||
it("falls back to a later validated address when the first is unreachable", async () => {
|
||||
const server = http.createServer((_req, res) => res.end("fallback-ok"));
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", () => resolve()),
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
try {
|
||||
// 2001:db8::1 is the documentation range — nothing answers there.
|
||||
const agent = createPinnedAgent("http:", ["2001:db8::1", "127.0.0.1"]);
|
||||
const response = await nodeFetch(`http://dual.invalid:${port}/`, {
|
||||
agent,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("fallback-ok");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("TOCTOU guarantee: lookup never invokes the OS resolver", () => {
|
||||
const lookup = pinnedLookup(createPinnedAgent("http:", ["10.0.0.1"]));
|
||||
|
||||
const callback = vi.fn();
|
||||
lookup("any-hostname.example", {}, callback);
|
||||
|
||||
// The callback is invoked synchronously with the fixed IP — no resolver.
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(null, "10.0.0.1", 4);
|
||||
});
|
||||
|
||||
// End-to-end over the production call path: node-fetch → http.Agent →
|
||||
// net.connect. The hostname is deliberately unresolvable, so the request can
|
||||
// only succeed if the connection went to the pinned IP, and it exercises the
|
||||
// real { all: true } callback shape rather than a hand-rolled invocation.
|
||||
it("routes a real request to the pinned IP for an unresolvable hostname", async () => {
|
||||
const server = http.createServer((_req, res) => res.end("pinned-ok"));
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", () => resolve()),
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
try {
|
||||
const agent = createPinnedAgent("http:", ["127.0.0.1"]);
|
||||
const response = await nodeFetch(
|
||||
`http://pinned-target.invalid:${port}/`,
|
||||
{ agent },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("pinned-ok");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,11 @@ import cors from "cors";
|
|||
import { parseArgs } from "node:util";
|
||||
import { parse as shellParseArgs } from "shell-quote";
|
||||
import nodeFetch, { Headers as NodeHeaders } from "node-fetch";
|
||||
import {
|
||||
assertSafeProxyTarget,
|
||||
createPinnedAgent,
|
||||
ProxyTargetError,
|
||||
} from "./proxy-security.js";
|
||||
|
||||
// Type-compatible wrappers for node-fetch to work with browser-style types
|
||||
const fetch = nodeFetch;
|
||||
|
|
@ -29,8 +34,6 @@ import rateLimit from "express-rate-limit";
|
|||
import { findActualExecutable } from "spawn-rx";
|
||||
import mcpProxy, { type ProxyHeaderHolder } from "./mcpProxy.js";
|
||||
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
|
|
@ -863,91 +866,17 @@ app.get("/health", (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
// Raised when the /fetch proxy is asked to reach a disallowed target, so the
|
||||
// route can distinguish an SSRF rejection (403) from an upstream failure (500).
|
||||
class ProxyTargetError extends Error {}
|
||||
|
||||
const MAX_PROXY_REDIRECTS = 5;
|
||||
|
||||
// The /fetch proxy is used by the client to reach OAuth discovery/token
|
||||
// endpoints on the connected MCP server (bypassing browser CORS). Connecting
|
||||
// to loopback and RFC1918/private LAN hosts is a core use case (testing local
|
||||
// or self-hosted MCP servers), so those stay reachable. We only block
|
||||
// link-local addresses — 169.254.0.0/16 (the AWS/GCP/Azure/etc. cloud metadata
|
||||
// endpoint 169.254.169.254 lives here) and IPv6 link-local / the AWS IPv6 IMDS
|
||||
// address — which are never legitimate proxy targets and are the real SSRF
|
||||
// exfiltration vector.
|
||||
function isBlockedProxyAddress(ip: string): boolean {
|
||||
const kind = isIP(ip);
|
||||
if (kind === 4) {
|
||||
const parts = ip.split(".").map(Number);
|
||||
// 169.254.0.0/16 — IPv4 link-local (incl. cloud metadata 169.254.169.254)
|
||||
return parts[0] === 169 && parts[1] === 254;
|
||||
}
|
||||
if (kind === 6) {
|
||||
const addr = ip.toLowerCase();
|
||||
// IPv4-mapped IPv6 (::ffff:x). The WHATWG URL parser serializes the embedded
|
||||
// IPv4 in hex (::ffff:a9fe:a9fe for 169.254.169.254), while DNS and other
|
||||
// sources may use the dotted form (::ffff:169.254.169.254) — handle both by
|
||||
// extracting the IPv4 and re-checking it.
|
||||
const mapped = addr.match(/^::ffff:(.+)$/);
|
||||
if (mapped) {
|
||||
const tail = mapped[1];
|
||||
if (isIP(tail) === 4) {
|
||||
return isBlockedProxyAddress(tail);
|
||||
}
|
||||
const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (hex) {
|
||||
const high = parseInt(hex[1], 16);
|
||||
const low = parseInt(hex[2], 16);
|
||||
return isBlockedProxyAddress(
|
||||
`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// fe80::/10 — IPv6 link-local
|
||||
if (/^fe[89ab]/.test(addr)) {
|
||||
return true;
|
||||
}
|
||||
// fd00:ec2::254 — AWS IPv6 instance metadata endpoint
|
||||
return addr === "fd00:ec2::254";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves the target host and rejects it if any resolved address is a blocked
|
||||
// (link-local/metadata) address, mitigating SSRF including the DNS case where a
|
||||
// hostname resolves to the cloud metadata IP.
|
||||
async function assertSafeProxyTarget(parsedUrl: URL): Promise<void> {
|
||||
const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
||||
|
||||
let addresses: string[];
|
||||
if (isIP(host)) {
|
||||
addresses = [host];
|
||||
} else {
|
||||
try {
|
||||
addresses = (await dnsLookup(host, { all: true })).map((r) => r.address);
|
||||
} catch {
|
||||
throw new ProxyTargetError(`Could not resolve host: ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (addresses.length === 0) {
|
||||
throw new ProxyTargetError(`Could not resolve host: ${host}`);
|
||||
}
|
||||
|
||||
for (const address of addresses) {
|
||||
if (isBlockedProxyAddress(address)) {
|
||||
throw new ProxyTargetError(
|
||||
`Refusing to proxy request to blocked address (${address})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch that follows redirects manually so every hop's target is re-validated
|
||||
// against assertSafeProxyTarget — otherwise an allowed host could redirect the
|
||||
// proxy into a blocked internal/metadata address.
|
||||
//
|
||||
// On each hop we call assertSafeProxyTarget (which resolves DNS and validates
|
||||
// every returned IP) and then pass a createPinnedAgent to node-fetch so it
|
||||
// connects to the IP we just validated instead of re-resolving the hostname.
|
||||
// This eliminates the TOCTOU window between the block-list check and the
|
||||
// actual TCP connection that a DNS-rebinding attack would exploit.
|
||||
async function safeProxyFetch(initialUrl: string, init?: RequestInit) {
|
||||
let currentUrl = new URL(initialUrl);
|
||||
let method = init?.method ?? "GET";
|
||||
|
|
@ -958,13 +887,20 @@ async function safeProxyFetch(initialUrl: string, init?: RequestInit) {
|
|||
if (!["http:", "https:"].includes(currentUrl.protocol)) {
|
||||
throw new ProxyTargetError("Only http/https URLs are allowed");
|
||||
}
|
||||
await assertSafeProxyTarget(currentUrl);
|
||||
|
||||
// Resolve DNS once, validate every address, and pin the connection to that
|
||||
// validated set so node-fetch never does a second DNS lookup. All of them
|
||||
// are passed through, so Node's address-family fallback still works while
|
||||
// the reachable set stays limited to what was just validated.
|
||||
const validatedAddresses = await assertSafeProxyTarget(currentUrl);
|
||||
const agent = createPinnedAgent(currentUrl.protocol, validatedAddresses);
|
||||
|
||||
const response = await fetch(currentUrl.toString(), {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
redirect: "manual",
|
||||
agent,
|
||||
});
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
|
|
|
|||
183
server/src/proxy-security.ts
Normal file
183
server/src/proxy-security.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* SSRF-prevention helpers for the /fetch reverse proxy.
|
||||
*
|
||||
* Extracted from index.ts so they can be unit-tested independently.
|
||||
*
|
||||
* ## DNS-rebinding / TOCTOU threat model
|
||||
*
|
||||
* `assertSafeProxyTarget` resolves the target hostname and validates every
|
||||
* returned IP against the block-list. The resolved IPs are returned to the
|
||||
* caller so they can be passed to `createPinnedAgent`, which builds an
|
||||
* http/https.Agent whose `lookup` callback unconditionally returns the
|
||||
* pre-validated IP. The agent is then passed to `node-fetch`, which calls the
|
||||
* lookup hook instead of doing its own DNS query.
|
||||
*
|
||||
* Without this pinning there is a TOCTOU race: an attacker who controls the
|
||||
* target domain can flip its DNS record to 169.254.169.254 (AWS/GCP/Azure
|
||||
* instance-metadata) in the window between `assertSafeProxyTarget` returning
|
||||
* and `fetch` performing its own resolution. Pinning eliminates that window.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { isIP } from "node:net";
|
||||
import type dns from "node:dns";
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block-list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if `ip` is a link-local or cloud-metadata address that must
|
||||
* never be reachable through the proxy.
|
||||
*
|
||||
* Blocked ranges:
|
||||
* - 169.254.0.0/16 (IPv4 link-local; includes 169.254.169.254 IMDS)
|
||||
* - ::ffff:169.254.x.x (IPv4-mapped IPv6 variants of the above)
|
||||
* - fe80::/10 (IPv6 link-local)
|
||||
* - fd00:ec2::254 (AWS IPv6 instance-metadata)
|
||||
*/
|
||||
export function isBlockedProxyAddress(ip: string): boolean {
|
||||
const kind = isIP(ip);
|
||||
if (kind === 4) {
|
||||
const parts = ip.split(".").map(Number);
|
||||
return parts[0] === 169 && parts[1] === 254;
|
||||
}
|
||||
if (kind === 6) {
|
||||
const addr = ip.toLowerCase();
|
||||
|
||||
// IPv4-mapped IPv6 — two serialization forms:
|
||||
// dotted ::ffff:169.254.169.254
|
||||
// hex ::ffff:a9fe:a9fe
|
||||
const mapped = addr.match(/^::ffff:(.+)$/);
|
||||
if (mapped) {
|
||||
const tail = mapped[1];
|
||||
if (isIP(tail) === 4) {
|
||||
return isBlockedProxyAddress(tail);
|
||||
}
|
||||
const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (hex) {
|
||||
const high = parseInt(hex[1], 16);
|
||||
const low = parseInt(hex[2], 16);
|
||||
return isBlockedProxyAddress(
|
||||
`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (/^fe[89ab]/.test(addr)) return true; // fe80::/10
|
||||
return addr === "fd00:ec2::254"; // AWS IPv6 IMDS
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Raised when the /fetch proxy is asked to reach a disallowed target. */
|
||||
export class ProxyTargetError extends Error {}
|
||||
|
||||
/**
|
||||
* Resolves `parsedUrl`'s hostname and asserts that every returned IP is
|
||||
* outside the block-list.
|
||||
*
|
||||
* Returns the list of validated IP strings so the caller can pin one of them
|
||||
* into an HTTP agent (see `createPinnedAgent`), eliminating the TOCTOU window
|
||||
* between this check and the actual `fetch()` call.
|
||||
*
|
||||
* @throws {ProxyTargetError} if the host cannot be resolved or any resolved
|
||||
* address falls inside the block-list.
|
||||
*/
|
||||
export async function assertSafeProxyTarget(parsedUrl: URL): Promise<string[]> {
|
||||
const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
||||
|
||||
let addresses: string[];
|
||||
if (isIP(host)) {
|
||||
addresses = [host];
|
||||
} else {
|
||||
try {
|
||||
addresses = (await dnsLookup(host, { all: true })).map((r) => r.address);
|
||||
} catch {
|
||||
throw new ProxyTargetError(`Could not resolve host: ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (addresses.length === 0) {
|
||||
throw new ProxyTargetError(`Could not resolve host: ${host}`);
|
||||
}
|
||||
|
||||
for (const address of addresses) {
|
||||
if (isBlockedProxyAddress(address)) {
|
||||
throw new ProxyTargetError(
|
||||
`Refusing to proxy request to blocked address (${address})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IP-pinned agent factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates an `http.Agent` or `https.Agent` whose internal `lookup` hook always
|
||||
* returns `pinnedIps`, bypassing the OS resolver entirely.
|
||||
*
|
||||
* Pass this agent to `node-fetch` on every hop of `safeProxyFetch` so the TCP
|
||||
* connection always goes to an IP that was validated by `assertSafeProxyTarget`
|
||||
* — even if the domain's DNS record changes mid-flight.
|
||||
*
|
||||
* Every address `assertSafeProxyTarget` returned is handed to the hook, not
|
||||
* just the first, so Node keeps its normal address-family fallback: a
|
||||
* dual-stack host whose first address is unreachable (IPv6 on an IPv4-only
|
||||
* network, say) still connects via a later one. The set it may choose from is
|
||||
* exactly the prevalidated set, so the TOCTOU guarantee is unchanged.
|
||||
*
|
||||
* @param protocol - `"http:"` or `"https:"` (from `URL.protocol`)
|
||||
* @param pinnedIps - Validated IPv4/IPv6 address strings, in preference order
|
||||
*/
|
||||
export function createPinnedAgent(
|
||||
protocol: string,
|
||||
pinnedIps: string[],
|
||||
): http.Agent | https.Agent {
|
||||
if (pinnedIps.length === 0) {
|
||||
throw new ProxyTargetError("No validated address to connect to");
|
||||
}
|
||||
|
||||
const resolved: dns.LookupAddress[] = pinnedIps.map((address) => ({
|
||||
address,
|
||||
family: isIP(address) === 6 ? 6 : 4,
|
||||
}));
|
||||
|
||||
// Node.js lookup signature: (hostname, options, callback).
|
||||
//
|
||||
// The callback shape depends on `options.all`. Node's `net.connect` runs with
|
||||
// `autoSelectFamily` enabled by default (Node >= 20), so it calls the hook
|
||||
// with `{ all: true }` and expects an ARRAY of `{ address, family }` — handing
|
||||
// it the scalar form there fails the connection with ERR_INVALID_IP_ADDRESS.
|
||||
// Both shapes are supported so the agent works on either call path.
|
||||
const lookup = (
|
||||
_hostname: string,
|
||||
opts: dns.LookupOptions | undefined,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string | dns.LookupAddress[],
|
||||
family?: number,
|
||||
) => void,
|
||||
): void => {
|
||||
if (opts?.all) {
|
||||
callback(null, resolved);
|
||||
return;
|
||||
}
|
||||
callback(null, resolved[0].address, resolved[0].family);
|
||||
};
|
||||
|
||||
if (protocol === "https:") {
|
||||
return new https.Agent({ lookup } as https.AgentOptions);
|
||||
}
|
||||
return new http.Agent({ lookup } as http.AgentOptions);
|
||||
}
|
||||
|
|
@ -12,5 +12,5 @@
|
|||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "packages", "**/*.spec.ts"]
|
||||
"exclude": ["node_modules", "packages", "**/*.spec.ts", "src/**/__tests__/**"]
|
||||
}
|
||||
|
|
|
|||
12
server/tsconfig.test.json
Normal file
12
server/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// The build config excludes __tests__ so the suite never lands in the
|
||||
// published `build/` output. This config exists only so the tests are still
|
||||
// type-checked; it emits nothing and is run by `npm test`.
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "packages"]
|
||||
}
|
||||
9
server/vitest.config.ts
Normal file
9
server/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue