Files
EpicNext-Cms/integration/proxy.test.ts
Simo 9a2d73a6f6
CI / check (push) Failing after 1m45s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped
feat(docker): provide verified-header proxy profiles for self-hosted clones
2026-09-13 20:15:10 +02:00

155 lines
4.8 KiB
TypeScript

import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { request } from "node:https";
import { isIP } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import {
GenericContainer,
type StartedTestContainer,
Wait,
} from "testcontainers";
import { afterAll, beforeAll, expect, it } from "vitest";
const exec = promisify(execFile);
const containers: StartedTestContainer[] = [];
let temporary: string;
let certificate: Buffer;
let key: Buffer;
let peer: string;
let direct: StartedTestContainer;
const spoofed = {
Host: "hotel.example",
"X-Forwarded-For": "198.51.100.40",
"X-Real-IP": "198.51.100.41",
"CF-Connecting-IP": "198.51.100.42",
"X-Real-Client-IP": "198.51.100.43",
Forwarded: "for=198.51.100.44",
"X-Forwarded-Proto": "http",
"X-Forwarded-Host": "attacker.invalid",
};
function get(container: StartedTestContainer, headers = spoofed) {
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = request(
{
hostname: container.getHost(),
port: container.getMappedPort(443),
path: "/",
rejectUnauthorized: false,
headers,
timeout: 5000,
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
},
);
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("Proxy fixture timeout")));
req.end();
});
}
async function start(template: string, trustedPeer?: string) {
let config = await readFile(`deployment/proxy/${template}`, "utf8");
if (trustedPeer)
config = config.replaceAll(
"203.0.113.10/32",
`${trustedPeer}/${isIP(trustedPeer) === 6 ? 128 : 32}`,
);
config = config
.replaceAll(
"/etc/letsencrypt/live/hotel.example/fullchain.pem",
"/etc/nginx/test.pem",
)
.replaceAll(
"/etc/letsencrypt/live/hotel.example/privkey.pem",
"/etc/nginx/test.key",
);
const inherited = template.includes("direct")
? "set_real_ip_from 0.0.0.0/0; real_ip_header X-Real-IP;"
: "";
const fixture = `${inherited}\n${config}\nserver { listen 127.0.0.1:3002; location / { default_type application/json; return 200 '{"xff":"$http_x_forwarded_for","real":"$http_x_real_ip","cf":"$http_cf_connecting_ip","derived":"$http_x_real_client_ip","forwarded":"$http_forwarded","host":"$http_host","proto":"$http_x_forwarded_proto"}'; } }`;
const container = await new GenericContainer("nginx:1.28-alpine")
.withCopyContentToContainer([
{ content: fixture, target: "/etc/nginx/conf.d/default.conf" },
{ content: certificate, target: "/etc/nginx/test.pem" },
{ content: key, target: "/etc/nginx/test.key" },
])
.withExposedPorts(443)
.withWaitStrategy(Wait.forLogMessage("start worker processes"))
.withStartupTimeout(60000)
.start();
containers.push(container);
return container;
}
beforeAll(async () => {
temporary = await mkdtemp(join(tmpdir(), "cms-proxy-integration-"));
await exec(
"openssl",
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-days",
"1",
"-subj",
"/CN=hotel.example",
"-keyout",
join(temporary, "key.pem"),
"-out",
join(temporary, "cert.pem"),
],
{ timeout: 15000 },
);
certificate = await readFile(join(temporary, "cert.pem"));
key = await readFile(join(temporary, "key.pem"));
direct = await start("nginx-direct.example.conf");
}, 120000);
afterAll(async () => {
await Promise.allSettled(containers.map((container) => container.stop()));
if (temporary) await rm(temporary, { recursive: true, force: true });
});
it("replaces forged forwarding headers with the original peer even with an inherited real-IP rule", async () => {
const response = await get(direct);
expect(response.status).toBe(200);
const headers = JSON.parse(response.body);
peer = headers.xff;
expect(isIP(peer)).toBeGreaterThan(0);
expect(Object.values(spoofed)).not.toContain(peer);
expect(headers).toEqual({
xff: peer,
real: peer,
cf: "",
derived: "",
forwarded: "",
host: "hotel.example",
proto: "https",
});
});
it("rejects a direct client when the remote edge has not been trusted", async () => {
const restricted = await start("nginx-trusted-proxy.example.conf");
expect((await get(restricted)).status).toBe(403);
});
it("accepts the verified client address only through an explicitly trusted peer", async () => {
if (!peer) peer = JSON.parse((await get(direct)).body).xff;
const trusted = await start("nginx-trusted-proxy.example.conf", peer);
const response = await get(trusted);
expect(response.status).toBe(200);
expect(JSON.parse(response.body)).toEqual({
xff: spoofed["X-Forwarded-For"],
real: spoofed["X-Forwarded-For"],
cf: "",
derived: "",
forwarded: "",
host: "hotel.example",
proto: "https",
});
});