Connect the image proxy to the address it checked

The proxy resolved a hostname, refused it if any answer pointed somewhere
private, and then handed the *hostname* to fetch — which resolved it again when
the socket opened. An attacker who controls the zone answers with a public
address the first time and 127.0.0.1 the second, and the check has been walked
straight past. It is the standard way an SSRF guard gets bypassed.

Resolve once and connect to that address: a `lookup` that returns what we
already approved, on `node:http`/`node:https` rather than fetch, since fetch
gives no say in how the socket is opened. Every redirect hop is re-checked and
re-pinned. TLS is unaffected — the certificate is still validated against the
hostname, which `servername` and the Host header carry.

Pooling had to go with it: sockets are keyed by host and port, not by the
address we pinned, so a connection opened earlier would be reused and the pin
never consulted. Found by a test, not by reading it back.

Also refuse two ranges the old check let through: IPv6 multicast, and the NAT64
prefix, which is a route into IPv4 space.
This commit is contained in:
2026-08-24 11:27:14 -07:00
parent e21944a031
commit 9d06dce473
2 changed files with 238 additions and 49 deletions
+118 -49
View File
@@ -1,11 +1,15 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { request as httpRequest, type IncomingMessage } from "node:http";
import { request as httpsRequest } from "node:https";
import { Readable } from "node:stream";
import type { Context } from "hono";
import { config } from "./config.js";
const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
const UA = "Mozilla/5.0 (compatible; ihasmail-image-proxy)";
function isPrivateAddress(addr: string): boolean {
export function isPrivateAddress(addr: string): boolean {
const v = isIP(addr);
if (v === 4) {
const [a, b] = addr.split(".").map(Number) as [number, number];
@@ -21,12 +25,75 @@ function isPrivateAddress(addr: string): boolean {
const lower = addr.toLowerCase();
if (lower === "::1" || lower === "::") return true;
if (lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd")) return true;
if (lower.startsWith("ff")) return true; // multicast
if (lower.startsWith("::ffff:")) return isPrivateAddress(lower.slice(7));
if (lower.startsWith("64:ff9b:")) return true; // NAT64, reaches IPv4 space
return false;
}
return true;
}
export class BlockedTarget extends Error {}
/**
* Settle on one address for `hostname` and refuse it if it is somewhere we
* should not be reaching.
*/
async function resolveAllowed(hostname: string): Promise<string> {
const host = hostname.replace(/^\[|\]$/g, "");
if (isIP(host)) {
if (isPrivateAddress(host)) throw new BlockedTarget(host);
return host;
}
const addrs = await lookup(host, { all: true });
if (!addrs.length) throw new BlockedTarget(host);
// Every answer has to be acceptable: one bad record is enough to mean the
// name is not something we should be fetching at all.
for (const a of addrs) if (isPrivateAddress(a.address)) throw new BlockedTarget(a.address);
return addrs[0]!.address;
}
/**
* Fetch, connecting to `addr` rather than whatever DNS says at the moment the
* socket opens.
*
* Checking a name and then handing the name to a fetching library leaves a gap:
* the library resolves again, and an attacker who controls the zone can answer
* differently the second time — the first answer passes the check, the second
* points at localhost. Pinning the address closes the gap. TLS is unaffected:
* the certificate is still validated against the hostname, which is what
* `servername` and the Host header carry.
*/
export function fetchPinned(url: URL, addr: string, signal?: AbortSignal): Promise<IncomingMessage> {
const family = isIP(addr) === 6 ? 6 : 4;
const send = url.protocol === "https:" ? httpsRequest : httpRequest;
return new Promise((resolve, reject) => {
const req = send(
url,
{
/*
* Called instead of a real resolution, so the socket goes exactly where
* we decided it should. Node asks for every address at once when it is
* picking a family itself (autoSelectFamily), and for a single one
* otherwise; answer in whichever shape was asked for.
*/
lookup: (_hostname: string, opts: { all?: boolean }, cb: (err: Error | null, address: string | { address: string; family: number }[], family?: number) => void) =>
opts?.all ? cb(null, [{ address: addr, family }]) : cb(null, addr, family),
servername: isIP(url.hostname) ? undefined : url.hostname,
// A pooled socket is keyed by host and port, not by the address we
// pinned, so a connection opened earlier would be reused and the pin
// never consulted. Take a fresh socket every time.
agent: false,
headers: { accept: "image/avif,image/webp,image/*,*/*;q=0.8", "user-agent": UA, host: url.host },
signal,
},
resolve,
);
req.on("error", reject);
req.end();
});
}
/**
* Gmail-style remote content proxy: hides the reader's IP address and
* user-agent from tracking pixels, and blocks SSRF to internal networks.
@@ -43,72 +110,73 @@ export async function imageProxyHandler(c: Context) {
if (url.protocol !== "http:" && url.protocol !== "https:") return c.json({ error: "bad_scheme" }, 400);
if (url.username || url.password) return c.json({ error: "bad_url" }, 400);
// Resolve and refuse private targets.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
let res: IncomingMessage;
try {
const host = url.hostname.replace(/^\[|\]$/g, "");
if (isIP(host)) {
if (isPrivateAddress(host)) return c.json({ error: "forbidden_target" }, 403);
} else {
const addrs = await lookup(host, { all: true });
if (!addrs.length || addrs.some((a) => isPrivateAddress(a.address))) {
return c.json({ error: "forbidden_target" }, 403);
}
let addr: string;
try {
addr = await resolveAllowed(url.hostname);
} catch (err) {
clearTimeout(timer);
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
}
} catch {
return c.json({ error: "dns_failure" }, 502);
}
res = await fetchPinned(url, addr, controller.signal);
let res: Response;
try {
res = await fetch(url, {
redirect: "manual",
headers: {
accept: "image/avif,image/webp,image/*,*/*;q=0.8",
"user-agent": "Mozilla/5.0 (compatible; ihasmail-image-proxy)",
},
signal: AbortSignal.timeout(15_000),
});
// Follow a limited number of redirects manually, re-validating each hop.
// Follow a limited number of redirects, re-checking and re-pinning each hop.
let hops = 0;
while ([301, 302, 303, 307, 308].includes(res.status) && hops < 3) {
const loc = res.headers.get("location");
while (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && hops < 3) {
const loc = res.headers.location;
if (!loc) break;
res.resume(); // discard the redirect body
const next = new URL(loc, url);
if (next.protocol !== "http:" && next.protocol !== "https:") return c.json({ error: "bad_redirect" }, 400);
const host = next.hostname.replace(/^\[|\]$/g, "");
if (isIP(host)) {
if (isPrivateAddress(host)) return c.json({ error: "forbidden_target" }, 403);
} else {
const addrs = await lookup(host, { all: true });
if (!addrs.length || addrs.some((a) => isPrivateAddress(a.address))) {
return c.json({ error: "forbidden_target" }, 403);
}
if (next.protocol !== "http:" && next.protocol !== "https:") {
clearTimeout(timer);
return c.json({ error: "bad_redirect" }, 400);
}
res = await fetch(next, {
redirect: "manual",
headers: { accept: "image/*", "user-agent": "Mozilla/5.0 (compatible; ihasmail-image-proxy)" },
signal: AbortSignal.timeout(15_000),
});
try {
addr = await resolveAllowed(next.hostname);
} catch (err) {
clearTimeout(timer);
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
}
url = next;
res = await fetchPinned(url, addr, controller.signal);
hops++;
}
} catch {
clearTimeout(timer);
return c.json({ error: "fetch_failed" }, 502);
}
if (!res.ok || !res.body) return c.json({ error: "fetch_failed" }, 502);
const type = (res.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
if (!type.startsWith("image/") || type === "image/svg+xml") return c.json({ error: "not_image" }, 415);
const len = Number(res.headers.get("content-length") ?? "0");
if (len > MAX_IMAGE_BYTES) return c.json({ error: "too_large" }, 413);
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
clearTimeout(timer);
res.resume();
return c.json({ error: "fetch_failed" }, 502);
}
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
if (!type.startsWith("image/") || type === "image/svg+xml") {
clearTimeout(timer);
res.resume();
return c.json({ error: "not_image" }, 415);
}
const len = Number(res.headers["content-length"] ?? "0");
if (len > MAX_IMAGE_BYTES) {
clearTimeout(timer);
res.resume();
return c.json({ error: "too_large" }, 413);
}
// Enforce the size limit while streaming.
let total = 0;
const limiter = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
transform(chunk, controller2) {
total += chunk.byteLength;
if (total > MAX_IMAGE_BYTES) controller.error(new Error("too large"));
else controller.enqueue(chunk);
if (total > MAX_IMAGE_BYTES) controller2.error(new Error("too large"));
else controller2.enqueue(chunk);
},
});
res.on("close", () => clearTimeout(timer));
const headers = new Headers({
"Content-Type": type,
"Cache-Control": "private, max-age=86400",
@@ -117,5 +185,6 @@ export async function imageProxyHandler(c: Context) {
"Cross-Origin-Resource-Policy": "same-origin",
});
if (len) headers.set("Content-Length", String(len));
return new Response(res.body.pipeThrough(limiter), { status: 200, headers });
const body = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>;
return new Response(body.pipeThrough(limiter), { status: 200, headers });
}