fix: puter site redirect for non paid apps (#3104)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

* fix: puter site redirect for non paid apps

* add tests
This commit is contained in:
Daniel Salazar
2026-05-12 21:03:33 -07:00
committed by GitHub
parent e58dd48e80
commit d1afe6c3cf
4 changed files with 168 additions and 32 deletions
@@ -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>): 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', () => {
@@ -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,
@@ -114,6 +114,7 @@ const makeRes = () => {
const makeReq = (init: {
hostname: string;
path?: string;
originalUrl?: string;
protocol?: string;
headers?: Record<string, string>;
cookies?: Record<string, string>;
@@ -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('<html>hi</html>');
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();
+15 -2
View File
@@ -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 {