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 <noreply@anthropic.com>
This commit is contained in:
ProgrammerIn-wonderlandandClaude Opus 5.5 committed 2026-11-02 18:32:03 -05:00
1 parent 58456ae87c
commit 2f35dadb28
2 files changed
+64

No files matched your search

+53
View File
@@ -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;
+11
View File
@@ -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