From 2f35dadb28a5cbbc34dc073767834291c96145cb Mon Sep 17 00:00:00 2001 From: ProgrammerIn-wonderland <3838shah@gmail.com> Date: Sun, 1 Nov 2026 20:20:05 -0500 Subject: [PATCH] fix(hosting): choose the site and subdomain from Host, not X-Forwarded-Host With trust proxy on, express derives req.hostname and req.subdomains from X-Forwarded-Host, which any client can send, while host validation reads the raw Host. A request could serve one hosted site's files on the main domain, or reach the API on a hosting host. The header is now dropped before routing. Fixes PUT-2272 Co-Authored-By: Claude Opus 5.5 --- src/backend/server.test.ts | 53 ++++++++++++++++++++++++++++++++++++++ src/backend/server.ts | 11 ++++++++ 2 files changed, 64 insertions(+) diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index 4e12bf10f..0b381c358 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -472,6 +472,59 @@ describe('PuterServer host header validation — permissive modes', () => { }); }); +describe('PuterServer behind a trusted proxy', () => { + let server: PuterServer; + let port: number; + + beforeAll(async () => { + port = await allocateEphemeralPort(); + server = await setupTestServer( + { + port, + domain: 'puter.localhost', + origin: `http://puter.localhost:${port}`, + api_base_url: `http://api.puter.localhost:${port}`, + allow_all_host_values: false, + allow_no_host_header: false, + custom_domains_enabled: false, + static_hosting_domain: 'site.puter.localhost', + trust_proxy: 1, + } as unknown as IConfig, + { listen: true }, + ); + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + it('serves the main domain whatever X-Forwarded-Host names', async () => { + const res = await rawRequest(port, '/healthcheck', { + host: `puter.localhost:${port}`, + 'x-forwarded-host': 'nosuchsite.site.puter.localhost', + }); + expect(res.status).toBe(200); + expect(res.headers['x-frame-options']).toBe('SAMEORIGIN'); + }); + + it('keeps a hosted-site host on site hosting', async () => { + const res = await rawRequest(port, '/healthcheck', { + host: `nosuchsite.site.puter.localhost:${port}`, + 'x-forwarded-host': 'api.puter.localhost', + }); + expect(res.status).toBe(404); + }); + + it('reads subdomains from Host', async () => { + const res = await rawRequest(port, '/healthcheck', { + host: `api.puter.localhost:${port}`, + 'x-forwarded-host': 'puter.localhost', + }); + expect(res.status).toBe(200); + expect(res.headers['x-frame-options']).toBeUndefined(); + }); +}); + describe('PuterServer host header validation — custom domains with every host accepted', () => { let server: PuterServer; let port: number; diff --git a/src/backend/server.ts b/src/backend/server.ts index 398578b26..74b28d25f 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -413,6 +413,17 @@ export class PuterServer { * `#materializeRoute` as those options ship. */ #installGlobalMiddleware() { + // -- Forwarded host ------------------------------------------ + // With `trust proxy` set, express derives `req.hostname` and + // `req.subdomains` from X-Forwarded-Host, which any client can send. + // Dropping it keeps them on Host, the header host validation checks, + // so a request can't pick a different site or subdomain router than + // the host it was sent to. + this.#app.use((req, _res, next) => { + delete req.headers['x-forwarded-host']; + next(); + }); + // -- Egress metering ----------------------------------------- // First, so the byte counter wraps `res.write` before compression // does and therefore counts what actually goes out rather than what