Skip to content

Commit d1afe6c

Browse files
authored
fix: puter site redirect for non paid apps (#3104)
* fix: puter site redirect for non paid apps * add tests
1 parent e58dd48 commit d1afe6c

4 files changed

Lines changed: 168 additions & 32 deletions

File tree

src/backend/core/http/middleware/privateAppGate.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
buildAppCenterFallback,
2929
buildHostingConfig,
3030
buildPrivateHostRedirect,
31+
buildPublicHostRedirect,
3132
getBootstrapToken,
3233
hostMatchesPrivateDomain,
3334
normalizeHost,
@@ -356,6 +357,73 @@ describe('buildPrivateHostRedirect', () => {
356357
});
357358
});
358359

360+
describe('buildPublicHostRedirect', () => {
361+
// Mirror of buildPrivateHostRedirect — swaps the private hosting
362+
// domain for the public one. Used when a non-private app (or no app
363+
// at all) hits the private host, so a paid-→-free app's old
364+
// `puter.app` URL still resolves on `puter.site`.
365+
const cfg = buildHostingConfig({
366+
domain: 'puter.localhost',
367+
static_hosting_domain: 'site.puter.localhost:4100',
368+
static_hosting_domain_alt: null,
369+
private_app_hosting_domain: 'app.puter.localhost',
370+
private_app_hosting_domain_alt: null,
371+
protocol: 'http',
372+
} as unknown as IConfig);
373+
374+
const reqOf = (init: Partial<Request>): Request =>
375+
({
376+
hostname: init.hostname,
377+
originalUrl: init.originalUrl,
378+
protocol: init.protocol ?? 'http',
379+
headers: init.headers ?? {},
380+
}) as unknown as Request;
381+
382+
it('swaps the private hosting domain for the public one (preserving port + path + query)', () => {
383+
const url = buildPublicHostRedirect(
384+
reqOf({
385+
hostname: 'beans.app.puter.localhost',
386+
originalUrl: '/some/path?x=1',
387+
}),
388+
cfg,
389+
);
390+
expect(url).toBe(
391+
'http://beans.site.puter.localhost:4100/some/path?x=1',
392+
);
393+
});
394+
395+
it("defaults the path to '/' when originalUrl is empty", () => {
396+
const url = buildPublicHostRedirect(
397+
reqOf({ hostname: 'beans.app.puter.localhost' }),
398+
cfg,
399+
);
400+
expect(url).toBe('http://beans.site.puter.localhost:4100/');
401+
});
402+
403+
it('returns null when no public hosting domain is configured', () => {
404+
const noPublic = {
405+
...cfg,
406+
staticDomains: [],
407+
staticDomainsRaw: [],
408+
};
409+
expect(
410+
buildPublicHostRedirect(
411+
reqOf({ hostname: 'beans.app.puter.localhost' }),
412+
noPublic,
413+
),
414+
).toBeNull();
415+
});
416+
417+
it('returns null for the bare private host (no subdomain to forward)', () => {
418+
expect(
419+
buildPublicHostRedirect(
420+
reqOf({ hostname: 'app.puter.localhost' }),
421+
cfg,
422+
),
423+
).toBeNull();
424+
});
425+
});
426+
359427
// ── Login bootstrap HTML ────────────────────────────────────────────
360428

361429
describe('renderLoginBootstrapHtml', () => {

src/backend/core/http/middleware/privateAppGate.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,38 @@ export function buildPrivateHostRedirect(
564564
void app; // reserved for future use (logging)
565565
}
566566

567+
/**
568+
* Mirror of {@link buildPrivateHostRedirect} — produces the public-host
569+
* equivalent URL for a request that landed on the private hosting domain
570+
* but doesn't belong there (non-private app, or no app at all). Used so a
571+
* formerly-paid app that's now free (or a plain hosted site) resolves on
572+
* `puter.site` instead of 404ing on `puter.app`.
573+
*/
574+
export function buildPublicHostRedirect(
575+
req: Request,
576+
config: PrivateHostingConfig,
577+
): string | null {
578+
const publicDomain = config.staticDomainsRaw[0] ?? config.staticDomains[0];
579+
if (!publicDomain) return null;
580+
const host = normalizeHost(req.hostname);
581+
if (!host) return null;
582+
const subdomain = subdomainFromHost(host, [
583+
...config.staticDomains,
584+
...config.privateDomains,
585+
]);
586+
if (!subdomain) return null;
587+
try {
588+
const protocol = config.protocol || req.protocol || 'https';
589+
const base = `${protocol}://${subdomain}.${publicDomain}`;
590+
const reqPath = (req.originalUrl || '/').startsWith('/')
591+
? req.originalUrl || '/'
592+
: `/${req.originalUrl}`;
593+
return new URL(reqPath, base).toString();
594+
} catch {
595+
return null;
596+
}
597+
}
598+
567599
/** Redirect URL when private access is denied — lands on the app-center listing. */
568600
export function buildAppCenterFallback(
569601
app: AppLike,

src/backend/core/http/middleware/puterSite.test.ts

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,17 @@ const makeRes = () => {
114114
const makeReq = (init: {
115115
hostname: string;
116116
path?: string;
117+
originalUrl?: string;
117118
protocol?: string;
118119
headers?: Record<string, string>;
119120
cookies?: Record<string, string>;
120121
}): Request =>
121122
({
122123
hostname: init.hostname,
123124
path: init.path ?? '/',
125+
// Redirect helpers use originalUrl (preserves query string).
126+
// Default to `path` when caller doesn't care to distinguish.
127+
originalUrl: init.originalUrl ?? init.path ?? '/',
124128
protocol: init.protocol ?? 'http',
125129
headers: init.headers ?? {},
126130
cookies: init.cookies ?? {},
@@ -358,12 +362,15 @@ describe('createPuterSiteMiddleware — subdomain lookup', () => {
358362
});
359363
});
360364

361-
// ── Private hosting domain refusal ──────────────────────────────────
365+
// ── Private hosting domain → public-host redirect ───────────────────
362366

363367
describe('createPuterSiteMiddleware — private hosting domain', () => {
364-
it('404s a subdomain on the private host that has no private app (prevents public-site leak via private host)', async () => {
368+
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 () => {
365369
// Owner exists, subdomain exists, but it has no associated
366-
// private app. On the *private* host this must refuse, not serve.
370+
// private app. On the *private* host this used to 404; now it
371+
// mirrors the public→private redirect so a paid app whose price
372+
// dropped to 0 still resolves on `puter.site` when accessed via
373+
// its old `puter.app` URL.
367374
const owner = await makeUser();
368375
const sub = `leak-${Math.random().toString(36).slice(2, 8)}`;
369376
await server.stores.subdomain.create({
@@ -376,15 +383,18 @@ describe('createPuterSiteMiddleware — private hosting domain', () => {
376383
makeReq({
377384
// Note: app.puter.localhost is the *private* hosting domain.
378385
hostname: `${sub}.app.puter.localhost`,
386+
path: '/some/deep/path.html',
379387
}),
380388
);
381-
expect(out.statusCode).toBe(404);
382-
expect(out.body).toBe('Subdomain not found');
389+
expect(out.redirected).toEqual({
390+
status: 302,
391+
url: `http://${sub}.site.puter.localhost/some/deep/path.html`,
392+
});
383393
});
384394

385-
it('uses the alt private hosting domain when configured', async () => {
395+
it('uses the alt private hosting domain when configured (same redirect to the public host)', async () => {
386396
// Coverage for the `private_app_hosting_domain_alt` slot — same
387-
// refusal logic, but via the alternate host that the deployment
397+
// redirect logic, but via the alternate host that the deployment
388398
// can use for legacy traffic.
389399
const owner = await makeUser();
390400
const sub = `altleak-${Math.random().toString(36).slice(2, 8)}`;
@@ -399,8 +409,40 @@ describe('createPuterSiteMiddleware — private hosting domain', () => {
399409
mw,
400410
makeReq({ hostname: `${sub}.apps.alt.localhost` }),
401411
);
412+
expect(out.redirected).toEqual({
413+
status: 302,
414+
url: `http://${sub}.site.puter.localhost/`,
415+
});
416+
});
417+
418+
it('falls back to 404 when no public hosting domain is configured (no leak)', async () => {
419+
// Without a static_hosting_domain to redirect to we have no safe
420+
// target; the original refusal must still apply so a public-app
421+
// subdomain doesn't accidentally serve via the private host.
422+
const owner = await makeUser();
423+
const sub = `nopub-${Math.random().toString(36).slice(2, 8)}`;
424+
await server.stores.subdomain.create({
425+
userId: owner.id,
426+
subdomain: sub,
427+
});
428+
const mw = createPuterSiteMiddleware(
429+
{
430+
...hostingConfig,
431+
static_hosting_domain: null,
432+
} as unknown as IConfig,
433+
{
434+
clients: server.clients,
435+
stores: server.stores,
436+
services: server.services,
437+
},
438+
);
439+
const { out } = await runMiddleware(
440+
mw,
441+
makeReq({ hostname: `${sub}.app.puter.localhost` }),
442+
);
402443
expect(out.statusCode).toBe(404);
403444
expect(out.body).toBe('Subdomain not found');
445+
expect(out.redirected).toBeUndefined();
404446
});
405447
});
406448

@@ -466,12 +508,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
466508
rootDirId: homeEntry!.id,
467509
});
468510
const body = Buffer.from('<html>hi</html>');
469-
await writeFile(
470-
owner.id,
471-
`${homePath}/index.html`,
472-
body,
473-
'text/html',
474-
);
511+
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
475512

476513
const mw = buildMiddleware();
477514
const { res, out } = makeRes();
@@ -546,12 +583,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
546583
rootDirId: homeEntry!.id,
547584
});
548585
const body = Buffer.from('default doc');
549-
await writeFile(
550-
owner.id,
551-
`${homePath}/index.html`,
552-
body,
553-
'text/html',
554-
);
586+
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
555587

556588
const mw = buildMiddleware();
557589
const { res, out } = makeRes();
@@ -657,11 +689,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
657689
const body = Buffer.from('not a directory');
658690
// Write a file and use ITS id as the subdomain's root_dir_id —
659691
// the middleware must reject because root must be a directory.
660-
await writeFile(
661-
owner.id,
662-
`${homePath}/Documents/somefile.txt`,
663-
body,
664-
);
692+
await writeFile(owner.id, `${homePath}/Documents/somefile.txt`, body);
665693
const fileEntry = await server.stores.fsEntry.getEntryByPath(
666694
`${homePath}/Documents/somefile.txt`,
667695
);
@@ -733,12 +761,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
733761
rootDirId: homeEntry!.id,
734762
});
735763
const body = Buffer.from('inside');
736-
await writeFile(
737-
owner.id,
738-
`${homePath}/safe.txt`,
739-
body,
740-
'text/plain',
741-
);
764+
await writeFile(owner.id, `${homePath}/safe.txt`, body, 'text/plain');
742765

743766
const mw = buildMiddleware();
744767
const { res, out } = makeRes();

src/backend/core/http/middleware/puterSite.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
buildAppCenterFallback,
3030
buildHostingConfig,
3131
buildPrivateHostRedirect,
32+
buildPublicHostRedirect,
3233
hostMatchesPrivateDomain,
3334
renderLoginBootstrapHtml,
3435
resolvePrivateAppForHostedSite,
@@ -344,8 +345,20 @@ export const createPuterSiteMiddleware = (
344345
// third-party resources loaded from the app.
345346
res.setHeader('Referrer-Policy', 'no-referrer');
346347
} else if (privateHostingDomains.has(matched)) {
347-
// Private host with no private app → refuse. Prevents a
348-
// public-app subdomain from leaking via the private host.
348+
// Non-private content landed on the private hosting domain —
349+
// mirror of the private redirect above. Covers two cases:
350+
// - a paid app that just flipped to free (is_private 1→0)
351+
// whose old `puter.app` URL is still bookmarked/shared;
352+
// - a plain hosted site that has no associated app.
353+
// Redirect to the equivalent `puter.site` URL so the content
354+
// resolves on the correct origin instead of 404ing.
355+
const redirectUrl = buildPublicHostRedirect(req, hostingCfg);
356+
if (redirectUrl) {
357+
res.redirect(302, redirectUrl);
358+
return;
359+
}
360+
// No public hosting domain configured — fall back to refusing
361+
// rather than leaking via the private host.
349362
res.status(404).type('text/plain').send('Subdomain not found');
350363
return;
351364
} else {

0 commit comments

Comments
 (0)