fix: derive express subdomain offset from the configured root domain (#3575)

Express derives `req.subdomains` by dropping a fixed number of labels
from the right of the hostname, defaulting to 2. A deployment whose
`domain` has more than two labels therefore reads its own root domain as
an active subdomain, so the user-site redirect sends the root origin to
the static hosting domain, which sends it back. Self-hosting docs
recommend exactly that shape (`puter.example.com`).

Set the offset from the label count of `config.domain`. Two-label
domains keep the express default, so existing deployments are unchanged.

Fixes #3561
This commit is contained in:
Parman Mohammadalizadeh
2026-08-16 23:48:20 -07:00
committed by GitHub
parent 3b791bb334
commit facb844747
4 changed files with 161 additions and 0 deletions
+61
View File
@@ -214,6 +214,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;
+9
View File
@@ -56,6 +56,7 @@ import { validateSubscriptionRequirement } from './services/metering/enforcement
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,
@@ -246,6 +247,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
+50
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});
+41
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
/** 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;
}