This commit is contained in:
Neal Shah authored and GitHub committed 2026-10-23 17:33:59 -04:00
1 parent 9c90c7afde
commit 03e483e372
7 files changed
+234 -33

No files matched your search

+4
View File
@@ -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.
@@ -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),
@@ -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();
@@ -196,10 +196,12 @@ export async function resolveOwnedAppForHostedSite(opts: {
appStore: Pick<AppStore, 'listOwnedByIndexUrlCandidates'>;
config: PrivateHostingConfig;
requirePrivate?: boolean;
/** Match against this hosting-domain host instead of the request's. */
host?: string;
}): Promise<AppLike | null> {
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<string>();
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);
@@ -1030,6 +1030,133 @@ describe('createPuterSiteMiddleware — site.access.check', () => {
});
});
describe('createPuterSiteMiddleware — hosts outside the configured domains', () => {
const claims = new Map<string, string>();
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('<html>custom</html>'),
'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<void>((resolve) => setImmediate(resolve));
expect(next).not.toHaveBeenCalled();
expect(out.statusCode).toBe(200);
expect(String(out.body)).toBe('<html>custom</html>');
});
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 () => {
+63 -20
View File
@@ -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 `<prefix>.<matched>`; subdomain is the left-most label.
const prefix = host.slice(0, host.length - matched.length - 1);
const subdomain = prefix.split('.')[0] || '';
// `host` is `<prefix>.<matched>`; 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)
+5 -2
View File
@@ -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 () => {