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;
+}