diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index 7cd70ced76..fdde10dce2 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -212,6 +212,67 @@ describe('PuterServer host header validation', () => { }); }); +/** + * Express reads subdomains relative to a fixed label count, so a root domain + * deeper than two labels is the case that breaks: `puter` reads as an active + * subdomain of the root origin itself, which bounces every root request into + * the user-site redirect. + */ +describe('PuterServer subdomain routing on a multi-label root domain', () => { + let server: PuterServer; + let port: number; + + beforeAll(async () => { + port = await allocateEphemeralPort(); + server = await setupTestServer( + { + port, + domain: 'puter.example.localhost', + origin: `http://puter.example.localhost:${port}`, + api_base_url: `http://api.puter.example.localhost:${port}`, + static_hosting_domain: 'site.puter.example.localhost', + static_hosting_domain_alt: 'host.puter.example.localhost', + private_app_hosting_domain: 'app.puter.example.localhost', + private_app_hosting_domain_alt: 'dev.puter.example.localhost', + } as unknown as IConfig, + { listen: true }, + ); + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + // Host headers here carry no port: the redirect under test compares the + // host against `domain`, which is how it arrives from a proxy in practice. + it('serves the root origin instead of redirecting it to the hosting domain', async () => { + const res = await rawRequest(port, '/', { + host: 'puter.example.localhost', + }); + expect(res.status).not.toBe(302); + expect(res.headers.location).toBeUndefined(); + }); + + it('still redirects a user subdomain of that domain to the hosting domain', async () => { + const res = await rawRequest(port, '/some/path', { + host: 'alice.puter.example.localhost', + }); + expect(res.status).toBe(302); + expect(res.headers.location).toBe( + 'http://alice.site.puter.example.localhost/some/path', + ); + }); + + it('still recognizes reserved subdomains of that domain', async () => { + const res = await rawRequest(port, '/healthcheck', { + host: 'api.puter.example.localhost', + origin: 'https://third-party.example', + }); + expect(res.headers.location).toBeUndefined(); + expect(res.headers['access-control-allow-credentials']).toBe('true'); + }); +}); + describe('PuterServer host header validation — permissive modes', () => { let server: PuterServer; let port: number; diff --git a/src/backend/server.ts b/src/backend/server.ts index 251f16eb1c..3f62b3f394 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -52,6 +52,7 @@ import { requireCreditsGate } from './core/http/middleware/credits'; import { createStepUpGate } from './core/http/middleware/stepUpSession'; import { createNotFoundHandler } from './core/http/middleware/notFoundHandler'; import { installProcessGuards } from './util/processGuards'; +import { subdomainOffsetForDomain } from './util/subdomains'; import { requireAntiCsrf, setAntiCsrfRedis, @@ -242,6 +243,14 @@ export class PuterServer { // Cloudflare/nginx hop). Never `true` in prod: that trusts every hop // and makes XFF forgeable. this.#app.set('trust proxy', this.#config.trust_proxy ?? false); + // Every subdomain gate reads `req.subdomains`, which express derives by + // dropping `subdomain offset` labels from the right of the hostname. + // The offset is the root domain's own label count, so a deployment on + // `puter.example.com` doesn't read `puter` as an active subdomain. + this.#app.set( + 'subdomain offset', + subdomainOffsetForDomain(this.#config.domain), + ); this.#installGlobalMiddleware(); // Instantiate drivers BEFORE controllers so controllers can receive diff --git a/src/backend/util/subdomains.test.ts b/src/backend/util/subdomains.test.ts new file mode 100644 index 0000000000..43ca413c29 --- /dev/null +++ b/src/backend/util/subdomains.test.ts @@ -0,0 +1,50 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import { subdomainOffsetForDomain } from './subdomains.ts'; + +describe('subdomainOffsetForDomain', () => { + it('keeps express default for a two-label root domain', () => { + expect(subdomainOffsetForDomain('puter.com')).toBe(2); + expect(subdomainOffsetForDomain('puter.localhost')).toBe(2); + }); + + it('counts every label of a deeper root domain', () => { + expect(subdomainOffsetForDomain('puter.example.com')).toBe(3); + expect(subdomainOffsetForDomain('puter.eu.example.co.uk')).toBe(5); + }); + + it('counts a single-label root domain as one', () => { + expect(subdomainOffsetForDomain('localhost')).toBe(1); + }); + + it('ignores casing, surrounding space, port and a leading dot', () => { + expect(subdomainOffsetForDomain(' Puter.Example.COM ')).toBe(3); + expect(subdomainOffsetForDomain('puter.example.com:4100')).toBe(3); + expect(subdomainOffsetForDomain('.puter.example.com')).toBe(3); + }); + + it('falls back to the express default when no domain is configured', () => { + expect(subdomainOffsetForDomain(undefined)).toBe(2); + expect(subdomainOffsetForDomain(null)).toBe(2); + expect(subdomainOffsetForDomain('')).toBe(2); + expect(subdomainOffsetForDomain(' ')).toBe(2); + }); +}); diff --git a/src/backend/util/subdomains.ts b/src/backend/util/subdomains.ts new file mode 100644 index 0000000000..2a0683d062 --- /dev/null +++ b/src/backend/util/subdomains.ts @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** Express's own default, used when `domain` is missing or unusable. */ +const DEFAULT_SUBDOMAIN_OFFSET = 2; + +/** + * How many labels express must drop from the right of a hostname before what's + * left counts as a subdomain. Express defaults to 2, which only holds for a + * two-label root domain — on `puter.example.com` it would report `puter` as an + * active subdomain of every root request, sending the root origin through the + * user-site redirect instead of the routes that serve it. + */ +export function subdomainOffsetForDomain( + domain: string | undefined | null, +): number { + if (typeof domain !== 'string') return DEFAULT_SUBDOMAIN_OFFSET; + const labels = domain + .trim() + .toLowerCase() + .split(':')[0] + .split('.') + .filter(Boolean); + return labels.length > 0 ? labels.length : DEFAULT_SUBDOMAIN_OFFSET; +}