Files
ihasmail-inbuxa/server/src/imageproxy.test.ts
T
jcoffey-dev bb25355c23 AGPL source offer and name cleanup
Every build writes the exact source it was built from, uncommitted work and
new files included, as source.tar.gz next to the app, named after that tree.
Docker builds, which have no git, pack the build context and name it by a
hash of its files. The sign-in page and Settings > About link to it instead of
a repository that can drift.

What users, operators and packagers see no longer names the upstream server:
- interface text, in all nine catalogues, with a token-session line for
  Security;
- server messages;
- the settings, now MAIL_SERVER_URL, MAIL_SERVERS_FILE, ADMIN_URL and
  MAIL_SERVER_FOLLOW_ADVERTISED_URLS, and mail-servers.example.json;
- the Tenants notice, which is gone;
- the README, CONTRIBUTING and SECURITY.

ihasmail's own FEATURES, KNOWN-ISSUES and ROADMAP stay with public ihasmail,
and INBUXA.md is folded into the README.
2026-09-19 00:13:12 -07:00

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.MAIL_SERVER_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);
}
});