121 lines
4.6 KiB
TypeScript
121 lines
4.6 KiB
TypeScript
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 recognized", () => {
|
|
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);
|
|
}
|
|
});
|