mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-29 12:50:59 +00:00
fix: puter site redirect for non paid apps (#3104)
* fix: puter site redirect for non paid apps * add tests
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user