From 03e483e372356b7724ac192dda121f2f57837a58 Mon Sep 17 00:00:00 2001 From: Neal Shah <30693865+ProgrammerIn-wonderland@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:33:59 -0400 Subject: [PATCH] PUT-2252 (#4311) --- src/backend/clients/event/types.ts | 4 + .../http/middleware/hostRedirects.test.ts | 24 +++ .../core/http/middleware/hostRedirects.ts | 3 + .../core/http/middleware/privateAppGate.ts | 6 +- .../core/http/middleware/puterSite.test.ts | 140 ++++++++++++++++-- src/backend/core/http/middleware/puterSite.ts | 83 ++++++++--- src/backend/server.test.ts | 7 +- 7 files changed, 234 insertions(+), 33 deletions(-) diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index e5bc7d8f4..294c9e274 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -679,6 +679,10 @@ export type EventMap = { requestHash?: string; mime: string; }; + // Asked for a request whose host is outside every configured domain. A + // listener that recognises the host sets `result.subdomain` to the site + // it serves; an unclaimed host is answered with a 404. + 'site.resolveHost': { host: string; result: { subdomain: string | null } }; // Asked before a hosted file is streamed. A listener that owns the site // sets `result.allowed = false` to withhold the entry; the visitor then // sees the same 404 as for a missing file. diff --git a/src/backend/core/http/middleware/hostRedirects.test.ts b/src/backend/core/http/middleware/hostRedirects.test.ts index faa5e47c4..eec56cd45 100644 --- a/src/backend/core/http/middleware/hostRedirects.test.ts +++ b/src/backend/core/http/middleware/hostRedirects.test.ts @@ -128,6 +128,18 @@ describe('createWwwRedirect', () => { expect(next).toHaveBeenCalledTimes(1); }); + it('leaves www. alone on a host outside our domains', () => { + const req = makeReq({ + subdomains: ['com', 'example', 'www'], + host: 'www.example.com', + }); + (req as Request & { is_custom_domain?: boolean }).is_custom_domain = + true; + const { out, next } = run(createWwwRedirect(config), req); + expect(out.redirectArgs).toBeUndefined(); + expect(next).toHaveBeenCalledTimes(1); + }); + it("passes through when config.domain isn't configured (no target to redirect to)", () => { const { out, next } = run( createWwwRedirect({} as IConfig), @@ -879,6 +891,18 @@ describe('createNativeAppStatic', () => { const withRoot = () => createNativeAppStatic({ native_apps_root: root } as unknown as IConfig); + it('leaves a native app name alone on a host outside our domains', async () => { + const req = staticReq({ + subdomains: ['com', 'example', 'editor'], + path: '/index.html', + }); + (req as Request & { is_custom_domain?: boolean }).is_custom_domain = + true; + const { out, next } = await runStatic(withRoot(), req); + expect(out.sent).toBeUndefined(); + expect(next).toHaveBeenCalledTimes(1); + }); + it('is a no-op when native_apps_root is unset', async () => { const { out, next } = await runStatic( createNativeAppStatic(config), diff --git a/src/backend/core/http/middleware/hostRedirects.ts b/src/backend/core/http/middleware/hostRedirects.ts index 5c5e19b74..2b532d235 100644 --- a/src/backend/core/http/middleware/hostRedirects.ts +++ b/src/backend/core/http/middleware/hostRedirects.ts @@ -207,6 +207,8 @@ const dispatchClaimed = async ( export const createWwwRedirect = (config: IConfig): RequestHandler => { const domain = (config.domain ?? '').toLowerCase(); return (req, res, next) => { + // `www.` on a host outside our domains belongs to that host's site. + if (req.is_custom_domain) return next(); const active = req.subdomains?.[req.subdomains.length - 1] ?? ''; if (active !== 'www') return next(); if (!domain) return next(); @@ -305,6 +307,7 @@ export const createNativeAppStatic = (config: IConfig): RequestHandler => { return (_req, _res, next) => next(); } return async (req, res, next) => { + if (req.is_custom_domain) return next(); const active = ( req.subdomains?.[req.subdomains.length - 1] ?? '' ).toLowerCase(); diff --git a/src/backend/core/http/middleware/privateAppGate.ts b/src/backend/core/http/middleware/privateAppGate.ts index 7af6ce925..2c4a1dee5 100644 --- a/src/backend/core/http/middleware/privateAppGate.ts +++ b/src/backend/core/http/middleware/privateAppGate.ts @@ -196,10 +196,12 @@ export async function resolveOwnedAppForHostedSite(opts: { appStore: Pick; config: PrivateHostingConfig; requirePrivate?: boolean; + /** Match against this hosting-domain host instead of the request's. */ + host?: string; }): Promise { if (!opts.site?.user_id) return null; - const host = normalizeHost(opts.req.hostname); + const host = normalizeHost(opts.host ?? opts.req.hostname); if (!host) return null; const hostedSubdomain = subdomainFromHost(host, [ @@ -215,7 +217,7 @@ export async function resolveOwnedAppForHostedSite(opts: { const hostCandidates = new Set(); hostCandidates.add(host); const headerHost = - typeof opts.req.headers?.host === 'string' + !opts.host && typeof opts.req.headers?.host === 'string' ? opts.req.headers.host.trim().toLowerCase() : ''; if (headerHost) hostCandidates.add(headerHost); diff --git a/src/backend/core/http/middleware/puterSite.test.ts b/src/backend/core/http/middleware/puterSite.test.ts index a0ecd37f3..cd58da60c 100644 --- a/src/backend/core/http/middleware/puterSite.test.ts +++ b/src/backend/core/http/middleware/puterSite.test.ts @@ -1030,6 +1030,133 @@ describe('createPuterSiteMiddleware — site.access.check', () => { }); }); +describe('createPuterSiteMiddleware — hosts outside the configured domains', () => { + const claims = new Map(); + const resolveHost = ( + _key: string, + data: { host: string; result: { subdomain: string | null } }, + ) => { + const sub = claims.get(data.host); + if (sub) data.result.subdomain = sub; + }; + + beforeAll(() => { + server.clients.event.on('site.resolveHost', resolveHost as never); + }); + + afterAll(() => { + server.clients.event.off('site.resolveHost', resolveHost as never); + }); + + const customReq = (hostname: string, path = '/index.html') => { + const req = makeReq({ hostname, path }); + (req as Request & { is_custom_domain?: boolean }).is_custom_domain = + true; + return req; + }; + + const makeSite = async (opts: { suspended?: boolean } = {}) => { + const owner = await makeUserWithHome(); + const homePath = `/${owner.username}`; + const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath); + const sub = `cust-${Math.random().toString(36).slice(2, 8)}`; + await server.stores.subdomain.create({ + userId: owner.id, + subdomain: sub, + rootDirId: homeEntry!.id, + }); + await writeFile( + owner.id, + `${homePath}/index.html`, + Buffer.from('custom'), + 'text/html', + ); + if (opts.suspended) { + await server.stores.user.update(owner.id, { suspended: 1 }); + } + const host = `${sub}.example.test`; + claims.set(host, sub); + return { owner, sub, host }; + }; + + it('serves the claimed site on its host', async () => { + const { host } = await makeSite(); + const { out, next } = await runMiddleware( + buildMiddleware(), + customReq(host), + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(next).not.toHaveBeenCalled(); + expect(out.statusCode).toBe(200); + expect(String(out.body)).toBe('custom'); + }); + + it('404s an unclaimed host instead of falling through to the API/GUI', async () => { + const { out, next } = await runMiddleware( + buildMiddleware(), + customReq('nobody-claims-this.example.test'), + ); + expect(next).not.toHaveBeenCalled(); + expect(out.statusCode).toBe(404); + expect(out.body).toBe('Site not found'); + }); + + it('404s when a listener throws', async () => { + const host = 'throws.example.test'; + const boom = (_key: string, data: { host: string }) => { + if (data.host === host) throw new Error('boom'); + }; + server.clients.event.on('site.resolveHost', boom as never); + try { + const { out, next } = await runMiddleware( + buildMiddleware(), + customReq(host), + ); + expect(next).not.toHaveBeenCalled(); + expect(out.statusCode).toBe(404); + } finally { + server.clients.event.off('site.resolveHost', boom as never); + } + }); + + it('404s a claimed site whose owner is suspended', async () => { + const { host } = await makeSite({ suspended: true }); + const { out } = await runMiddleware(buildMiddleware(), customReq(host)); + expect(out.statusCode).toBe(404); + expect(out.body).toBe('Subdomain not found'); + }); + + it('404s a private app rather than serving it ungated', async () => { + const { owner, sub, host } = await makeSite(); + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`, `is_private`) VALUES (?, ?, ?, ?, ?, ?)', + [ + `app-${uuidv4()}`, + sub, + sub, + `http://${sub}.site.puter.localhost/`, + owner.id, + 1, + ], + ); + const { out } = await runMiddleware(buildMiddleware(), customReq(host)); + expect(out.statusCode).toBe(404); + expect(out.body).toBe('Site not found'); + expect(out.redirected).toBeUndefined(); + expect(out.cookies).toEqual({}); + }); + + it('passes the host through untouched when custom domains are not flagged', async () => { + const { host } = await makeSite(); + const { out, next } = await runMiddleware( + buildMiddleware(), + makeReq({ hostname: host }), + ); + expect(next).toHaveBeenCalledTimes(1); + expect(out.statusCode).toBeUndefined(); + }); +}); + describe('createPuterSiteMiddleware — .puter_site_config', () => { const setupSiteWithConfig = async (config: unknown) => { const owner = await makeUserWithHome(); @@ -1254,12 +1381,7 @@ describe('createPuterSiteMiddleware — .puter_site_config', () => { errors: { '404': { file: '/index.html', status: 200 } }, }); const body = Buffer.from('real-page'); - await writeFile( - owner.id, - `${homePath}/page.html`, - body, - 'text/html', - ); + await writeFile(owner.id, `${homePath}/page.html`, body, 'text/html'); const mw = buildMiddleware(); const { res, out } = makeRes(); @@ -1373,9 +1495,9 @@ describe('createPuterSiteMiddleware — .puter_site_config', () => { // SPA fallback still fires → cache served the deleted config. expect(second.out.statusCode).toBe(200); - expect( - (second.out.body as Buffer | undefined)?.equals(body), - ).toBe(true); + expect((second.out.body as Buffer | undefined)?.equals(body)).toBe( + true, + ); }); it('ignores `errors` entries with status codes outside 4xx/5xx', async () => { diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts index 4c3c1d33e..77697c07e 100644 --- a/src/backend/core/http/middleware/puterSite.ts +++ b/src/backend/core/http/middleware/puterSite.ts @@ -61,12 +61,12 @@ import { * - Private-app gate via `app.privateAccess.check` — marketplace extension * decides; default is denied + redirect to `app-center` * - Range / ETag / Last-Modified passthrough via `fsEntry.readContent` + * - Hosts outside the configured domains (`custom_domains_enabled`) map to a site + * via `site.resolveHost`; unclaimed ones 404. Public sites only. * * Deferred (not yet implemented): * * - `.at` username-based sites (UUIDv5-keyed `/user/Public`). - * - Custom domains (subdomains table `domain` column) — requires host validation - * to allow arbitrary hostnames first. * * Site config: * @@ -228,29 +228,60 @@ export const createPuterSiteMiddleware = ( if (!host) return next(); const matched = matchHostingDomain(host); - if (!matched) return next(); + // A host outside every configured domain. Listeners may map it to a + // site; an unclaimed one is answered here rather than falling through + // to the API/GUI routers. + const isCustomHost = !matched && Boolean(req.is_custom_domain); + if (!matched && !isCustomHost) return next(); - // Bare hosting domain (e.g. `puter.site`) → redirect to the main site. - if (host === matched) { - if (domain) { - res.redirect(302, `${req.protocol}://${domain}`); + let subdomain: string; + if (isCustomHost) { + const resolveEvent = { + host, + result: { subdomain: null as string | null }, + }; + try { + await layers.clients.event.emitAndWait( + 'site.resolveHost', + resolveEvent, + {}, + ); + } catch { + resolveEvent.result.subdomain = null; + } + const claimed = resolveEvent.result.subdomain; + if (typeof claimed !== 'string' || !claimed) { + res.status(404).type('text/plain').send('Site not found'); return; } - res.status(404).type('text/plain').send('Subdomain not found'); - return; - } + subdomain = claimed; + } else { + const hostingDomain = matched as string; + // Bare hosting domain (e.g. `puter.site`) → redirect to the main site. + if (host === hostingDomain) { + if (domain) { + res.redirect(302, `${req.protocol}://${domain}`); + return; + } + res.status(404).type('text/plain').send('Subdomain not found'); + return; + } - // `host` is `.`; subdomain is the left-most label. - const prefix = host.slice(0, host.length - matched.length - 1); - const subdomain = prefix.split('.')[0] || ''; + // `host` is `.`; subdomain is the left-most label. + const prefix = host.slice( + 0, + host.length - hostingDomain.length - 1, + ); + subdomain = prefix.split('.')[0] || ''; - if (!subdomain || subdomain === 'www') { - if (domain) { - res.redirect(302, `${req.protocol}://${domain}`); + if (!subdomain || subdomain === 'www') { + if (domain) { + res.redirect(302, `${req.protocol}://${domain}`); + return; + } + res.status(404).type('text/plain').send('Subdomain not found'); return; } - res.status(404).type('text/plain').send('Subdomain not found'); - return; } const site = (await layers.stores.subdomain.getBySubdomain( @@ -286,6 +317,11 @@ export const createPuterSiteMiddleware = ( site: { user_id: site.user_id }, appStore: layers.stores.app, config: hostingCfg, + // App index URLs name the hosting-domain address, not the + // custom host the visitor used. + host: isCustomHost + ? `${subdomain}.${hostingDomains[0]}` + : undefined, }), ); const rootLookup = @@ -300,7 +336,14 @@ export const createPuterSiteMiddleware = ( const isPrivateApp = Boolean(associatedApp?.is_private); const privateApp = isPrivateApp ? associatedApp : null; - if (isPrivateApp) { + if (isCustomHost) { + // The private-app gate and its cookies are bound to the private + // hosting domain, so these hosts serve public sites only. + if (isPrivateApp) { + res.status(404).type('text/plain').send('Site not found'); + return; + } + } else if (isPrivateApp) { // Private apps must run on the private hosting domain. If a // visitor arrives via the public domain (puter.site), redirect // them to the equivalent private-host URL so the cookie scope @@ -448,7 +491,7 @@ export const createPuterSiteMiddleware = ( res.redirect(302, cleanPath); return; } - } else if (privateHostingDomains.has(matched)) { + } else if (privateHostingDomains.has(matched as string)) { // Non-private content landed on the private hosting domain — // mirror of the private redirect above. Covers two cases: // - a paid app that just flipped to free (is_private 1→0) diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index f1f842a6a..f3d9fd5f5 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -441,6 +441,7 @@ describe('PuterServer host header validation — permissive modes', () => { allow_no_host_header: false, custom_domains_enabled: true, allow_nipio_domains: true, + static_hosting_domain: 'site.puter.localhost', } as unknown as IConfig, { listen: true }, ); @@ -450,11 +451,13 @@ describe('PuterServer host header validation — permissive modes', () => { await server?.shutdown(); }); - it('lets an unknown host through when custom domains are enabled', async () => { + it('hands an unknown host to site hosting when custom domains are enabled', async () => { const res = await rawRequest(port, '/', { host: 'my-own-domain.example', }); - expect(res.status).not.toBe(400); + // No site claims it, so it 404s instead of reaching the GUI. + expect(res.status).toBe(404); + expect(res.body).toBe('Site not found'); }); it('accepts nip.io hosts when they are opted in', async () => {