mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user