Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/backend/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/backend/util/subdomains.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 41 additions & 0 deletions src/backend/util/subdomains.ts
Original file line number Diff line number Diff line change
@@ -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;
}