From d1afe6c3cfba5071f898663c4770c8266b3e85fc Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Tue, 12 May 2026 21:03:33 -0700 Subject: [PATCH] fix: puter site redirect for non paid apps (#3104) * fix: puter site redirect for non paid apps * add tests --- .../http/middleware/privateAppGate.test.ts | 68 +++++++++++++++ .../core/http/middleware/privateAppGate.ts | 32 +++++++ .../core/http/middleware/puterSite.test.ts | 83 ++++++++++++------- src/backend/core/http/middleware/puterSite.ts | 17 +++- 4 files changed, 168 insertions(+), 32 deletions(-) diff --git a/src/backend/core/http/middleware/privateAppGate.test.ts b/src/backend/core/http/middleware/privateAppGate.test.ts index e43fb9c51..0a93bcfb2 100644 --- a/src/backend/core/http/middleware/privateAppGate.test.ts +++ b/src/backend/core/http/middleware/privateAppGate.test.ts @@ -28,6 +28,7 @@ import { buildAppCenterFallback, buildHostingConfig, buildPrivateHostRedirect, + buildPublicHostRedirect, getBootstrapToken, hostMatchesPrivateDomain, normalizeHost, @@ -356,6 +357,73 @@ describe('buildPrivateHostRedirect', () => { }); }); +describe('buildPublicHostRedirect', () => { + // Mirror of buildPrivateHostRedirect — swaps the private hosting + // domain for the public one. Used when a non-private app (or no app + // at all) hits the private host, so a paid-→-free app's old + // `puter.app` URL still resolves on `puter.site`. + const cfg = buildHostingConfig({ + domain: 'puter.localhost', + static_hosting_domain: 'site.puter.localhost:4100', + static_hosting_domain_alt: null, + private_app_hosting_domain: 'app.puter.localhost', + private_app_hosting_domain_alt: null, + protocol: 'http', + } as unknown as IConfig); + + const reqOf = (init: Partial): Request => + ({ + hostname: init.hostname, + originalUrl: init.originalUrl, + protocol: init.protocol ?? 'http', + headers: init.headers ?? {}, + }) as unknown as Request; + + it('swaps the private hosting domain for the public one (preserving port + path + query)', () => { + const url = buildPublicHostRedirect( + reqOf({ + hostname: 'beans.app.puter.localhost', + originalUrl: '/some/path?x=1', + }), + cfg, + ); + expect(url).toBe( + 'http://beans.site.puter.localhost:4100/some/path?x=1', + ); + }); + + it("defaults the path to '/' when originalUrl is empty", () => { + const url = buildPublicHostRedirect( + reqOf({ hostname: 'beans.app.puter.localhost' }), + cfg, + ); + expect(url).toBe('http://beans.site.puter.localhost:4100/'); + }); + + it('returns null when no public hosting domain is configured', () => { + const noPublic = { + ...cfg, + staticDomains: [], + staticDomainsRaw: [], + }; + expect( + buildPublicHostRedirect( + reqOf({ hostname: 'beans.app.puter.localhost' }), + noPublic, + ), + ).toBeNull(); + }); + + it('returns null for the bare private host (no subdomain to forward)', () => { + expect( + buildPublicHostRedirect( + reqOf({ hostname: 'app.puter.localhost' }), + cfg, + ), + ).toBeNull(); + }); +}); + // ── Login bootstrap HTML ──────────────────────────────────────────── describe('renderLoginBootstrapHtml', () => { diff --git a/src/backend/core/http/middleware/privateAppGate.ts b/src/backend/core/http/middleware/privateAppGate.ts index 0d34da2bb..4aed8cacb 100644 --- a/src/backend/core/http/middleware/privateAppGate.ts +++ b/src/backend/core/http/middleware/privateAppGate.ts @@ -564,6 +564,38 @@ export function buildPrivateHostRedirect( void app; // reserved for future use (logging) } +/** + * Mirror of {@link buildPrivateHostRedirect} — produces the public-host + * equivalent URL for a request that landed on the private hosting domain + * but doesn't belong there (non-private app, or no app at all). Used so a + * formerly-paid app that's now free (or a plain hosted site) resolves on + * `puter.site` instead of 404ing on `puter.app`. + */ +export function buildPublicHostRedirect( + req: Request, + config: PrivateHostingConfig, +): string | null { + const publicDomain = config.staticDomainsRaw[0] ?? config.staticDomains[0]; + if (!publicDomain) return null; + const host = normalizeHost(req.hostname); + if (!host) return null; + const subdomain = subdomainFromHost(host, [ + ...config.staticDomains, + ...config.privateDomains, + ]); + if (!subdomain) return null; + try { + const protocol = config.protocol || req.protocol || 'https'; + const base = `${protocol}://${subdomain}.${publicDomain}`; + const reqPath = (req.originalUrl || '/').startsWith('/') + ? req.originalUrl || '/' + : `/${req.originalUrl}`; + return new URL(reqPath, base).toString(); + } catch { + return null; + } +} + /** Redirect URL when private access is denied — lands on the app-center listing. */ export function buildAppCenterFallback( app: AppLike, diff --git a/src/backend/core/http/middleware/puterSite.test.ts b/src/backend/core/http/middleware/puterSite.test.ts index baef0729b..6a4bec3b7 100644 --- a/src/backend/core/http/middleware/puterSite.test.ts +++ b/src/backend/core/http/middleware/puterSite.test.ts @@ -114,6 +114,7 @@ const makeRes = () => { const makeReq = (init: { hostname: string; path?: string; + originalUrl?: string; protocol?: string; headers?: Record; cookies?: Record; @@ -121,6 +122,9 @@ const makeReq = (init: { ({ hostname: init.hostname, path: init.path ?? '/', + // Redirect helpers use originalUrl (preserves query string). + // Default to `path` when caller doesn't care to distinguish. + originalUrl: init.originalUrl ?? init.path ?? '/', protocol: init.protocol ?? 'http', headers: init.headers ?? {}, cookies: init.cookies ?? {}, @@ -358,12 +362,15 @@ describe('createPuterSiteMiddleware — subdomain lookup', () => { }); }); -// ── Private hosting domain refusal ────────────────────────────────── +// ── Private hosting domain → public-host redirect ─────────────────── describe('createPuterSiteMiddleware — private hosting domain', () => { - it('404s a subdomain on the private host that has no private app (prevents public-site leak via private host)', async () => { + it('302s a subdomain on the private host with no private app to the equivalent puter.site URL (covers freed-paid-app bookmarks + plain hosted sites)', async () => { // Owner exists, subdomain exists, but it has no associated - // private app. On the *private* host this must refuse, not serve. + // private app. On the *private* host this used to 404; now it + // mirrors the public→private redirect so a paid app whose price + // dropped to 0 still resolves on `puter.site` when accessed via + // its old `puter.app` URL. const owner = await makeUser(); const sub = `leak-${Math.random().toString(36).slice(2, 8)}`; await server.stores.subdomain.create({ @@ -376,15 +383,18 @@ describe('createPuterSiteMiddleware — private hosting domain', () => { makeReq({ // Note: app.puter.localhost is the *private* hosting domain. hostname: `${sub}.app.puter.localhost`, + path: '/some/deep/path.html', }), ); - expect(out.statusCode).toBe(404); - expect(out.body).toBe('Subdomain not found'); + expect(out.redirected).toEqual({ + status: 302, + url: `http://${sub}.site.puter.localhost/some/deep/path.html`, + }); }); - it('uses the alt private hosting domain when configured', async () => { + it('uses the alt private hosting domain when configured (same redirect to the public host)', async () => { // Coverage for the `private_app_hosting_domain_alt` slot — same - // refusal logic, but via the alternate host that the deployment + // redirect logic, but via the alternate host that the deployment // can use for legacy traffic. const owner = await makeUser(); const sub = `altleak-${Math.random().toString(36).slice(2, 8)}`; @@ -399,8 +409,40 @@ describe('createPuterSiteMiddleware — private hosting domain', () => { mw, makeReq({ hostname: `${sub}.apps.alt.localhost` }), ); + expect(out.redirected).toEqual({ + status: 302, + url: `http://${sub}.site.puter.localhost/`, + }); + }); + + it('falls back to 404 when no public hosting domain is configured (no leak)', async () => { + // Without a static_hosting_domain to redirect to we have no safe + // target; the original refusal must still apply so a public-app + // subdomain doesn't accidentally serve via the private host. + const owner = await makeUser(); + const sub = `nopub-${Math.random().toString(36).slice(2, 8)}`; + await server.stores.subdomain.create({ + userId: owner.id, + subdomain: sub, + }); + const mw = createPuterSiteMiddleware( + { + ...hostingConfig, + static_hosting_domain: null, + } as unknown as IConfig, + { + clients: server.clients, + stores: server.stores, + services: server.services, + }, + ); + const { out } = await runMiddleware( + mw, + makeReq({ hostname: `${sub}.app.puter.localhost` }), + ); expect(out.statusCode).toBe(404); expect(out.body).toBe('Subdomain not found'); + expect(out.redirected).toBeUndefined(); }); }); @@ -466,12 +508,7 @@ describe('createPuterSiteMiddleware — file serving', () => { rootDirId: homeEntry!.id, }); const body = Buffer.from('hi'); - await writeFile( - owner.id, - `${homePath}/index.html`, - body, - 'text/html', - ); + await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html'); const mw = buildMiddleware(); const { res, out } = makeRes(); @@ -546,12 +583,7 @@ describe('createPuterSiteMiddleware — file serving', () => { rootDirId: homeEntry!.id, }); const body = Buffer.from('default doc'); - await writeFile( - owner.id, - `${homePath}/index.html`, - body, - 'text/html', - ); + await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html'); const mw = buildMiddleware(); const { res, out } = makeRes(); @@ -657,11 +689,7 @@ describe('createPuterSiteMiddleware — file serving', () => { const body = Buffer.from('not a directory'); // Write a file and use ITS id as the subdomain's root_dir_id — // the middleware must reject because root must be a directory. - await writeFile( - owner.id, - `${homePath}/Documents/somefile.txt`, - body, - ); + await writeFile(owner.id, `${homePath}/Documents/somefile.txt`, body); const fileEntry = await server.stores.fsEntry.getEntryByPath( `${homePath}/Documents/somefile.txt`, ); @@ -733,12 +761,7 @@ describe('createPuterSiteMiddleware — file serving', () => { rootDirId: homeEntry!.id, }); const body = Buffer.from('inside'); - await writeFile( - owner.id, - `${homePath}/safe.txt`, - body, - 'text/plain', - ); + await writeFile(owner.id, `${homePath}/safe.txt`, body, 'text/plain'); const mw = buildMiddleware(); const { res, out } = makeRes(); diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts index 7b468d84d..7f1712e2f 100644 --- a/src/backend/core/http/middleware/puterSite.ts +++ b/src/backend/core/http/middleware/puterSite.ts @@ -29,6 +29,7 @@ import { buildAppCenterFallback, buildHostingConfig, buildPrivateHostRedirect, + buildPublicHostRedirect, hostMatchesPrivateDomain, renderLoginBootstrapHtml, resolvePrivateAppForHostedSite, @@ -344,8 +345,20 @@ export const createPuterSiteMiddleware = ( // third-party resources loaded from the app. res.setHeader('Referrer-Policy', 'no-referrer'); } else if (privateHostingDomains.has(matched)) { - // Private host with no private app → refuse. Prevents a - // public-app subdomain from leaking via the private host. + // 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) + // whose old `puter.app` URL is still bookmarked/shared; + // - a plain hosted site that has no associated app. + // Redirect to the equivalent `puter.site` URL so the content + // resolves on the correct origin instead of 404ing. + const redirectUrl = buildPublicHostRedirect(req, hostingCfg); + if (redirectUrl) { + res.redirect(302, redirectUrl); + return; + } + // No public hosting domain configured — fall back to refusing + // rather than leaking via the private host. res.status(404).type('text/plain').send('Subdomain not found'); return; } else {