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:
@@ -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<void>((r, j) => viaName.listen(PORT, RESOLVED, r).on("error", j));
|
||||
await new Promise<void>((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<IncomingMessage>((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<void>((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:[email protected]/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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user