feat(docker): provide verified-header proxy profiles for self-hosted clones
CI / check (push) Failing after 1m45s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-13 20:15:10 +02:00
1 parent fe34d4ac93
commit 9a2d73a6f6
6 files changed
+404

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Opt in through COMPOSE_FILE in the clone's .env; see docs/operations/docker-installation.md.
# The base service uses Linux host networking: bind the process, do not add ports.
services:
cms:
environment:
HOSTNAME: 127.0.0.1
PORT: "3002"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
@@ -0,0 +1,43 @@
# Include at nginx http scope (for example /etc/nginx/conf.d/cms.conf).
# Mode: browsers connect directly to this nginx, on the CMS Docker host.
# Replace EVERY hotel.example and both certificate paths before nginx -t.
# Requires ngx_http_realip_module: $realip_remote_addr keeps the socket peer
# even when an unrelated global real_ip configuration rewrites $remote_addr.
server {
listen 80;
listen [::]:80;
server_name hotel.example;
if ($host != hotel.example) { return 444; }
return 308 https://hotel.example$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name hotel.example;
if ($host != hotel.example) { return 444; }
ssl_certificate /etc/letsencrypt/live/hotel.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hotel.example/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Studio's authenticated attachment endpoints accept at most 52 MiB.
# This ingress allowance includes multipart overhead; application caps remain.
client_max_body_size 64m;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Host hotel.example;
proxy_set_header X-Forwarded-Host hotel.example;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $realip_remote_addr;
proxy_set_header X-Real-IP $realip_remote_addr;
proxy_set_header CF-Connecting-IP "";
proxy_set_header X-Real-Client-IP "";
proxy_set_header Forwarded "";
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 300s;
proxy_cache off;
}
}
@@ -0,0 +1,51 @@
# ALTERNATIVE to nginx-direct.example.conf; never enable both for the same host.
# Remote edge -> TLS -> this nginx on the CMS host -> loopback CMS.
# Replace hotel.example/certificate paths and BOTH occurrences of 203.0.113.10/32.
# The example peer is reserved documentation space, so it permits no real edge.
# Requires ngx_http_realip_module. The remote edge MUST overwrite X-Forwarded-For
# with one verified client IP and enforce the public HTTPS/Host configuration.
geo $realip_remote_addr $cms_trusted_edge {
default 0;
203.0.113.10/32 1;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name hotel.example;
if ($host != hotel.example) { return 444; }
if ($cms_trusted_edge = 0) { return 403; }
ssl_certificate /etc/letsencrypt/live/hotel.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hotel.example/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 64m;
set_real_ip_from 203.0.113.10/32;
real_ip_header X-Forwarded-For;
real_ip_recursive off;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Host hotel.example;
proxy_set_header X-Forwarded-Host hotel.example;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header CF-Connecting-IP "";
proxy_set_header X-Real-Client-IP "";
proxy_set_header Forwarded "";
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 300s;
proxy_cache off;
}
}
# Cloudflare variant: use this same restricted-edge mode, NOT the direct mode.
# Replace the documentation peer in BOTH geo/set_real_ip_from lists with the
# current verified Cloudflare IPv4 AND IPv6 CIDRs, then change real_ip_header to
# CF-Connecting-IP. Use Full (strict) TLS and review authenticated origin pulls.
# CF-Connecting-IP is still removed before forwarding to the CMS: nginx sends
# only its normalized, trusted result in X-Forwarded-For and X-Real-IP.
+61
View File
@@ -24,6 +24,67 @@ These headers are trustworthy only when the ingress sanitizes them. Configure th
Restrict direct access to the application port so requests must pass through that ingress. The provided Compose file uses host networking with `HOSTNAME=0.0.0.0`; it does not enforce this restriction or provision nginx/Traefik trust rules. Verify the host firewall and actual reverse-proxy configuration before relying on client IPs for blocking, auditing or abuse limits. Repository tests prove rejection of the derived-header bypass and malformed addresses; they do not certify the deployed forwarding trust chain.
## Opt-in profile: Nginx on the same host
Use this profile for a new Linux Compose clone whose public HTTPS endpoint is Nginx on that same host. It leaves `docker-compose.yml` and CI-managed production deployments unchanged. Nginx must include `ngx_http_realip_module`; check `nginx -V` before using the templates. The CMS remains on the existing host network for database/Redis connectivity, but its HTTP process listens on `127.0.0.1:3002`. Host networking shares the host network namespace; a `ports:` mapping would not provide the restriction. See [Docker host networking](https://docs.docker.com/engine/network/drivers/host/).
1. Run `bash cms install --configure-only` and choose the intended public HTTPS URL. Obtain a valid certificate for that hostname using the host's existing certificate-management process. The templates do not issue certificates or configure renewal.
2. In the clone's existing `.env`, add or replace this single setting, preserving all other values:
```dotenv
COMPOSE_FILE=docker-compose.yml:deployment/proxy/compose.loopback.yml
```
Keep `APP_URL`, `AUTH_URL` and the saved installer public URL on the same canonical `https://` hostname. The profile pins the existing port 3002 as well as the loopback address. Do not place `COMPOSE_FILE` in `.docker-install`; that file accepts only `MODE` and `PUBLIC_URL`.
3. Copy [nginx-direct.example.conf](../../deployment/proxy/nginx-direct.example.conf) into the host's Nginx configuration directory, outside this Git clone. Replace **every** `hotel.example` and both certificate paths. Load it at `http` scope, for example through `/etc/nginx/conf.d/cms.conf`. Review any existing virtual host for that hostname to avoid two competing configurations. The example handles only CMS HTTP traffic; emulator WebSocket and other hotel services need their own reviewed ingress.
4. Validate the effective Nginx configuration with `nginx -t`, then enable/reload it through the host's normal service-management procedure. Prepare this endpoint before starting the installer, because installation verifies the saved public URL. It can return an upstream-unavailable response until the CMS starts.
5. From the clone root, remove conflicting Compose overrides from the deployment shell and validate the model:
```sh
unset COMPOSE_FILE COMPOSE_PATH_SEPARATOR COMPOSE_ENV_FILES COMPOSE_DISABLE_ENV_FILE
docker compose config --quiet
bash cms install
```
These `unset` commands remove shell overrides; they do not remove the `COMPOSE_FILE` line in `.env`. Do not set `COMPOSE_DISABLE_ENV_FILE=1`, use an alternate `--env-file`, or supply a competing shell `COMPOSE_FILE` for this workflow. Environment values can override `.env` selection. Do not print or paste the full rendered Compose configuration, because it contains runtime credentials. See [Compose predefined variables and precedence](https://docs.docker.com/compose/how-tos/environment-variables/envvars/).
6. Confirm the running configuration without dumping the environment:
```sh
docker compose exec -T cms node -e 'if(process.env.HOSTNAME!=="127.0.0.1"||process.env.PORT!=="3002")process.exit(1);console.log("CMS configured for 127.0.0.1:3002")'
ss -lnt '( sport = :3002 )'
curl --fail --silent --show-error https://hotel.example/api/health
```
The listener must be `127.0.0.1:3002`, not `0.0.0.0:3002` or `[::]:3002`. From another machine, the host's public IP on port 3002 must be unreachable. Check both address families when the host has IPv6. Loopback isolation covers the CMS process only: other containers and host services, including Byparr, retain their existing bindings.
The direct template uses the original socket peer (`$realip_remote_addr`), overwrites the two accepted forwarding headers, and removes incoming `CF-Connecting-IP`, `X-Real-Client-IP` and `Forwarded`. It fixes forwarded host/protocol to the configured HTTPS origin. An unrelated inherited real-IP rule cannot turn a caller-supplied header into the forwarded client address in this mode. See [Nginx original-peer variables](https://nginx.org/en/docs/http/ngx_http_realip_module.html) and [header replacement/removal](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header).
Response buffering is disabled for progress streams, and this example adds no proxy response cache or CORS policy. Its 64 MiB ingress body cap accommodates the existing 52 MiB Studio attachment limit; route and Server Action limits remain authoritative and may be lower. TLS 1.2/1.3 are configured explicitly. See [Nginx buffering](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering) and [TLS protocols](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_protocols).
### Why the profile survives an update
The installer preserves an existing `.env`. The installer invokes `docker-update.sh`, and the updater's config, build, candidate run, cutover and rollback paths all call **bare `docker compose` from the clone root**, without `-f`. Compose therefore reads the saved file list on each invocation. The base file appears first so its relative mount/build paths remain rooted in the clone; the later profile overrides only the CMS environment and healthcheck. No installer/updater patch is required for this selection. See [Compose merge order and relative paths](https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/).
Keep the profile selected for every routine `bash cms update` and selected-release update. A one-off `docker compose -f ... up` does not persist selection for later installer/updater commands. Do not add an untracked `docker-compose.override.yml`: it makes the clone dirty unless separately excluded and is bypassed when `COMPOSE_FILE` selects an explicit list. Before selecting an older release, verify that `deployment/proxy/compose.loopback.yml` exists in that release; a missing selected file fails configuration rather than silently using the public bind.
This profile does not change `scripts/ci-deploy.sh`, which owns a separate `docker run` deployment and currently sets its own public binding. Do not use the clone installer to take over a host managed by CI. Restricting that deployment's listener requires a separate compatibility review and rollout.
### Separate mode: verified remote edge or Cloudflare
A remote proxy cannot connect to the CMS loopback listener directly. The supported topology here is **remote edge → TLS → Nginx on the CMS host → loopback CMS**, retaining the same Compose profile. Use [nginx-trusted-proxy.example.conf](../../deployment/proxy/nginx-trusted-proxy.example.conf) instead of the direct template. Do not enable both templates for one hostname.
For your own remote edge, replace `203.0.113.10/32` in both the `geo` peer allowlist and `set_real_ip_from` with the exact approved connection source addresses. The reserved example address deliberately permits no real edge. Require the edge to overwrite `X-Forwarded-For` with one verified client IP, use the configured hostname, enforce public HTTPS, and validate this origin's TLS certificate. The origin checks the original socket peer before accepting the rewritten address. Never use `0.0.0.0/0`, `::/0` or arbitrary client networks as trusted proxies. See [Nginx real-IP trust configuration](https://nginx.org/en/docs/http/ngx_http_realip_module.html).
For Cloudflare, use that same restricted-edge mode and replace both peer lists with the **current verified IPv4 and IPv6 Cloudflare ranges**, maintained by the operator; use `real_ip_header CF-Connecting-IP` instead of `X-Forwarded-For`. Configure Full (strict) TLS and review authenticated origin pulls. Obtain ranges from [Cloudflare's official IP list](https://www.cloudflare.com/ips/) and follow its [visitor-IP restoration guidance](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/). Do not copy a historical range list from a support ticket or trust `CF-Connecting-IP` merely because it is present. This setup still removes the CF header before the CMS and forwards only Nginx's normalized result in XFF/X-Real-IP.
The direct template intentionally records the CDN/edge socket address when placed behind an unconfigured CDN; it does not silently trust an upstream header. After configuring the restricted-edge mode, verify with requests from an allowed edge and a disallowed direct client, including forged CF/XFF headers, and check the recorded client address. Neither the repository nor the installer changes the host firewall or certifies another proxy's header behavior.
### Local verification and deployment boundary
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
## Routine and selected-release updates
```sh
+154
View File
@@ -0,0 +1,154 @@
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",
});
});
+86
View File
@@ -0,0 +1,86 @@
import { spawnSync } from "node:child_process";
import {
copyFileSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, it } from "vitest";
const root = process.cwd();
const hasCompose =
spawnSync("docker", ["compose", "version"], {
encoding: "utf8",
timeout: 10_000,
}).status === 0;
it.skipIf(!hasCompose)(
"loads the loopback profile from .env for bare Compose commands without changing mounts or host networking",
() => {
const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-"));
try {
mkdirSync(path.join(directory, "deployment/proxy"), { recursive: true });
copyFileSync(
path.join(root, "docker-compose.yml"),
path.join(directory, "docker-compose.yml"),
);
copyFileSync(
path.join(root, "deployment/proxy/compose.loopback.yml"),
path.join(directory, "deployment/proxy/compose.loopback.yml"),
);
writeFileSync(
path.join(directory, ".env"),
[
"COMPOSE_FILE=docker-compose.yml:deployment/proxy/compose.loopback.yml",
"COMPOSE_PATH_SEPARATOR=:",
"CMS_RELEASE=reviewed-release",
"HOSTNAME=0.0.0.0",
"PORT=9999",
"HOTEL_NAME=Proxy fixture",
"DATABASE_URL=mysql://fixture:[email protected]/fixture",
].join("\n"),
);
const environment = { ...process.env };
for (const key of Object.keys(environment))
if (
key.startsWith("COMPOSE_") ||
["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key)
)
delete environment[key];
const result = spawnSync(
"docker",
["compose", "config", "--format", "json"],
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
);
expect(result.status, result.stderr).toBe(0);
const config = JSON.parse(result.stdout);
const cms = config.services.cms;
expect(cms.environment.HOSTNAME).toBe("127.0.0.1");
expect(cms.environment.PORT).toBe("3002");
expect(cms.network_mode).toBe("host");
expect(cms.ports).toBeUndefined();
expect(cms.image).toBe("epicnext-cms:reviewed-release");
expect(cms.healthcheck.test).toEqual([
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]);
expect(cms.volumes.map((volume) => volume.target).sort()).toEqual([
"/app/public/nitro-assets",
"/app/public/swf",
"/app/storage",
"/var/www/Gamedata",
]);
expect(cms.environment.DATABASE_URL).toBe(
"mysql://fixture:[email protected]/fixture",
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
},
30_000,
);