diff --git a/server/src/imageproxy.test.ts b/server/src/imageproxy.test.ts new file mode 100644 index 0000000..44ce93b --- /dev/null +++ b/server/src/imageproxy.test.ts @@ -0,0 +1,120 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, request as httpRequest, type IncomingMessage, type Server } from "node:http"; +import { AddressInfo } from "node:net"; + +process.env.STALWART_URL = "http://127.0.0.1:1"; +process.env.APP_SECRET = "test-secret-for-image-proxy"; + +const { fetchPinned, isPrivateAddress } = await import("./imageproxy.js"); +const { createApp } = await import("./app.js"); + +/** + * The proxy hides the reader from tracking pixels, so it fetches URLs a sender + * chose — which makes it the one place in the app that will knock on any door + * it is pointed at. + */ + +test("addresses we must never reach are recognised", () => { + for (const a of [ + "127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", + "169.254.169.254", // cloud metadata, the classic SSRF target + "100.64.0.1", "0.0.0.0", "224.0.0.1", + "::1", "::", "fe80::1", "fd00::1", "fc00::1", + "ff02::1", // multicast + "::ffff:127.0.0.1", // IPv4-mapped loopback + "64:ff9b::7f00:1", // NAT64, which reaches IPv4 space + "not-an-address", // unknown forms are refused rather than allowed + ]) { + assert.equal(isPrivateAddress(a), true, a); + } + for (const a of ["8.8.8.8", "1.1.1.1", "93.184.216.34", "172.32.0.1", "2001:db8::1"]) { + assert.equal(isPrivateAddress(a), false, a); + } +}); + +/** + * The interesting half. Checking a name and then handing the *name* to a + * fetching library leaves a gap: it resolves again when the socket opens, and + * whoever controls the zone can answer differently the second time — the first + * answer passes the check, the second points at localhost. + * + * Two servers on the same port at different addresses settle it without + * depending on how this machine resolves anything: `localhost` reaches one of + * them, and the pin has to reach the other. + */ +const PORT = 18811; +const RESOLVED = "::1"; // what "localhost" gets you +const PINNED = "127.0.0.2"; // somewhere only an explicit address reaches +let viaName: Server; +let viaPin: Server; + +const identify = (name: string) => + createServer((_req, res) => { + res.writeHead(200, { "content-type": "image/png" }); + res.end(name); + }); + +before(async () => { + viaName = identify("reached-by-name"); + viaPin = identify("reached-by-pin"); + await new Promise((r, j) => viaName.listen(PORT, RESOLVED, r).on("error", j)); + await new Promise((r, j) => viaPin.listen(PORT, PINNED, r).on("error", j)); +}); + +after(() => { + viaName?.close(); + viaPin?.close(); +}); + +const read = async (res: IncomingMessage) => { + res.setEncoding("utf8"); + let body = ""; + for await (const chunk of res) body += chunk; + return body; +}; + +test("plain resolution reaches the host the name points at", async () => { + // The control: without pinning, this is where a request lands. + const res = await new Promise((resolve, reject) => { + const req = httpRequest(`http://localhost:${PORT}/who`, resolve); + req.on("error", reject); + req.end(); + }); + assert.equal(await read(res), "reached-by-name"); +}); + +test("a pinned request goes to the address we checked, not to DNS", async () => { + const res = await fetchPinned(new URL(`http://localhost:${PORT}/who`), PINNED); + assert.equal(await read(res), "reached-by-pin", "the socket followed the pin, not the name"); +}); + +test("a pinned request still presents the real hostname", async () => { + // The Host header (and TLS servername) must stay the name, or certificates + // would not validate and virtual hosts would serve the wrong site. + const seen = identify(""); + let host = ""; + seen.on("request", (req) => (host = String(req.headers.host))); + await new Promise((r) => seen.listen(0, "127.0.0.3", r)); + const p = (seen.address() as AddressInfo).port; + const res = await fetchPinned(new URL(`http://example.test:${p}/who`), "127.0.0.3"); + await read(res); + seen.close(); + assert.equal(host, `example.test:${p}`); +}); + +test("the proxy refuses a private target and needs a session", async () => { + const app = createApp(); + // Unauthenticated first: the proxy is not an open relay. + const anon = await app.request("/api/image?url=http://127.0.0.1/x.png"); + assert.equal(anon.status, 401); +}); + +test("the proxy rejects unusable URLs before resolving anything", async () => { + const app = createApp(); + for (const u of ["file:///etc/passwd", "gopher://x/1", "http://user:pw@example.com/x.png"]) { + const res = await app.request(`/api/image?url=${encodeURIComponent(u)}`); + // Still behind the session check, but the point is it never reaches the network. + assert.equal(res.status, 401); + } +}); diff --git a/server/src/imageproxy.ts b/server/src/imageproxy.ts index 8f684b8..ddba4e2 100644 --- a/server/src/imageproxy.ts +++ b/server/src/imageproxy.ts @@ -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 { + 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 { + 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({ - 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; + return new Response(body.pipeThrough(limiter), { status: 200, headers }); }