155 lines
4.8 KiB
TypeScript
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",
|
|
});
|
|
});
|