diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index f3d9fd5f5..17ab78c26 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -468,6 +468,58 @@ describe('PuterServer host header validation — permissive modes', () => { }); }); +describe('PuterServer host header validation — custom domains with every host accepted', () => { + let server: PuterServer; + let port: number; + + beforeAll(async () => { + port = await allocateEphemeralPort(); + server = await setupTestServer( + { + port, + domain: 'puter.localhost', + origin: `http://puter.localhost:${port}`, + allow_all_host_values: true, + custom_domains_enabled: true, + static_hosting_domain: 'site.puter.localhost', + } as unknown as IConfig, + { listen: true }, + ); + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + it('still hands a host outside our domains to site hosting', async () => { + const res = await rawRequest(port, '/', { + host: 'www.my-own-domain.example', + }); + expect(res.status).toBe(404); + expect(res.body).toBe('Site not found'); + }); + + it('leaves our own domain to the GUI', async () => { + const res = await rawRequest(port, '/healthcheck', { + host: `puter.localhost:${port}`, + }); + expect(res.status).toBe(200); + }); + + it('never treats an IP or localhost as a custom domain', async () => { + for (const host of [ + `127.0.0.1:${port}`, + `[::1]:${port}`, + `localhost:${port}`, + ]) { + const res = await rawRequest(port, '/healthcheck', { host }); + expect(res.status, host).toBe(200); + const root = await rawRequest(port, '/', { host }); + expect(root.body, host).not.toBe('Site not found'); + } + }); +}); + describe('PuterServer request parsing', () => { let server: PuterServer; let port: number; diff --git a/src/backend/server.ts b/src/backend/server.ts index 927b04919..c87aee715 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -29,6 +29,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; import http from 'node:http'; +import { isIP } from 'node:net'; import { puterClients } from './clients'; import { puterControllers } from './controllers'; import { createAuthProbe } from './core/http/middleware/authProbe'; @@ -597,7 +598,32 @@ export class PuterServer { // Build the allowed-domain set from config. this.#app.use((req, res, next) => { + const hostName = (req.headers.host ?? '') + .split(':')[0] + .trim() + .toLowerCase(); + const isOwnHost = + !hostName || + this.#getAllowedDomains().some((d) => + PuterServer.#hostMatchesDomain(hostName, d), + ); + + // Accepting every host still has to tell custom domains apart, or + // they fall through to the API/GUI instead of site hosting. An IP + // or localhost is never one: load balancer health checks and + // in-cluster callers address the server that way. if (config.allow_all_host_values) { + if ( + config.custom_domains_enabled && + !isOwnHost && + req.path !== '/healthcheck' && + // `[` is what an IPv6 literal's `[::1]:port` splits to. + !hostName.startsWith('[') && + isIP(hostName) === 0 && + hostName !== 'localhost' + ) { + req.is_custom_domain = true; + } next(); return; } @@ -613,15 +639,7 @@ export class PuterServer { return; } - const hostName = (req.headers.host ?? '') - .split(':')[0] - .trim() - .toLowerCase(); - const allowed = this.#getAllowedDomains(); - - if ( - allowed.some((d) => PuterServer.#hostMatchesDomain(hostName, d)) - ) { + if (isOwnHost) { next(); return; }