blacklist dynamic workers (#3582)

This commit is contained in:
Neal Shah
2026-08-14 18:35:34 -04:00
committed by GitHub
parent 6254714484
commit 6a7c09f54e
4 changed files with 213 additions and 12 deletions
+9 -6
View File
@@ -367,8 +367,10 @@ export type EventMap = {
}>;
// ---- Subdomains ----
'subdomain.delete': { subdomain: string };
'subdomain.update': { subdomain: string };
// `uid` is the row uuid — for `delete` it is the only surviving handle a
// listener can use to find state keyed to the row after it is gone.
'subdomain.delete': { subdomain: string; uid?: string };
'subdomain.update': { subdomain: string; uid?: string };
'site.htmlServed': {
subdomain: string;
entry: unknown;
@@ -540,10 +542,11 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -1206,3 +1206,166 @@ describe('createPuterSiteMiddleware — .puter_site_config', () => {
expect(String(out.body)).toContain('Not Found');
});
});
// ── __workers/ (worker source) ──────────────────────────────────────
//
// `__workers/` at a site root holds server-side worker source. It is the
// one thing under a site root that is NOT public: users' backend code can
// carry trade secrets, so every route to those bytes — direct request,
// folder fallback, custom error page — must dead-end in the same 404 a
// missing path produces.
describe('createPuterSiteMiddleware — __workers/ is never served', () => {
const setupSiteWithWorker = async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `dw-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
await server.services.fs.mkdir(owner.id, {
path: `${homePath}/__workers`,
createMissingParents: true,
});
await writeFile(
owner.id,
`${homePath}/__workers/matchmaking.worker.js`,
Buffer.from('export default { secret: true };'),
'application/javascript',
);
return { owner, homePath, sub };
};
const request = async (sub: string, path: string) => {
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({ hostname: `${sub}.site.puter.localhost`, path }),
res,
vi.fn(),
);
await new Promise<void>((resolve) => setImmediate(resolve));
return out;
};
it('404s a direct request for a worker source file that exists', async () => {
const { sub } = await setupSiteWithWorker();
const out = await request(sub, '/__workers/matchmaking.worker.js');
expect(out.statusCode).toBe(404);
expect(String(out.body)).not.toContain('secret');
});
it('404s the folder itself — no index.html fallback into it', async () => {
const { owner, homePath, sub } = await setupSiteWithWorker();
await writeFile(
owner.id,
`${homePath}/__workers/index.html`,
Buffer.from('<html>listing</html>'),
'text/html',
);
for (const path of ['/__workers', '/__workers/']) {
const out = await request(sub, path);
expect(out.statusCode).toBe(404);
expect(String(out.body)).not.toContain('listing');
}
});
it('404s case variants and URL-encoded spellings of the folder', async () => {
const { sub } = await setupSiteWithWorker();
for (const path of [
'/__Workers/matchmaking.worker.js',
'/__WORKERS/matchmaking.worker.js',
'/%5F%5Fworkers/matchmaking.worker.js',
'/a/../__workers/matchmaking.worker.js',
]) {
const out = await request(sub, path);
expect(out.statusCode).toBe(404);
expect(String(out.body)).not.toContain('secret');
}
});
it('404s __workers/ at any depth — a site rooted above another site must not serve the inner one', async () => {
// Same FS layout, but the serving site is rooted one level up:
// the worker source now sits at /inner/__workers/… from its
// point of view, and must be just as unreachable.
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
await server.services.fs.mkdir(owner.id, {
path: `${homePath}/inner/__workers`,
createMissingParents: true,
});
await writeFile(
owner.id,
`${homePath}/inner/__workers/matchmaking.worker.js`,
Buffer.from('export default { secret: true };'),
'application/javascript',
);
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `dwo-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
const out = await request(
sub,
'/inner/__workers/matchmaking.worker.js',
);
expect(out.statusCode).toBe(404);
expect(String(out.body)).not.toContain('secret');
});
it('refuses a custom error page that points into __workers/', async () => {
// `errors.404.file` aimed at worker source would publish it on
// every missing path — the rule is ignored and the default 404
// page served instead.
const { owner, homePath, sub } = await setupSiteWithWorker();
await writeFile(
owner.id,
`${homePath}/.puter_site_config`,
Buffer.from(
JSON.stringify({
errors: {
'404': {
file: '/__workers/matchmaking.worker.js',
status: 200,
},
},
}),
),
'application/json',
);
const out = await request(sub, '/no-such-page');
expect(out.statusCode).toBe(404);
expect(String(out.body)).not.toContain('secret');
expect(String(out.body)).toContain('Not Found');
});
it('still applies an SPA fallback to __workers/ requests when the target is outside the folder', async () => {
// The block hides the folder; it does not exempt those URLs from
// the site's own 404 handling. A fallback pointing at a public
// file keeps working — the fiction is "this path does not exist",
// and this is exactly what a missing path serves.
const { owner, homePath, sub } = await setupSiteWithWorker();
const shell = Buffer.from('<html>spa-shell</html>');
await writeFile(owner.id, `${homePath}/index.html`, shell, 'text/html');
await writeFile(
owner.id,
`${homePath}/.puter_site_config`,
Buffer.from(
JSON.stringify({
errors: { '404': { file: '/index.html', status: 200 } },
}),
),
'application/json',
);
const out = await request(sub, '/__workers/matchmaking.worker.js');
expect(out.statusCode).toBe(200);
const piped = out.body as Buffer | undefined;
expect(Buffer.isBuffer(piped)).toBe(true);
expect(piped!.equals(shell)).toBe(true);
});
});
+33 -4
View File
@@ -71,6 +71,24 @@ import {
* serving.
*/
/**
* Reserved folder at a site root holding server-side worker source
* (`__workers/<name>.worker.js`). Its contents are backend code — users' trade
* secrets live there — so nothing under it is ever served: not directly, not
* via the folder→index.html fallback, and not as a custom error page target.
* Matched on the decoded, normalized path, and case-insensitively, so no
* alternate spelling of the same entry can reach the bytes.
*
* Matched at ANY depth, not just the site root: site roots can nest, and a site
* rooted above another site would otherwise serve the inner site's `__workers/`
* as ordinary content under `/inner/__workers/...`.
*/
const WORKERS_FOLDER = '__workers';
const isWorkersSourcePath = (urlPath: string): boolean =>
urlPath
.split('/')
.some((segment) => segment.toLowerCase() === WORKERS_FOLDER);
const SUBDOMAIN_404 = `<div style="font-size: 20px;
text-align: center;
height: calc(100vh);
@@ -493,9 +511,14 @@ export const createPuterSiteMiddleware = (
// Subdomain hosting bypasses ACL by design: anything the owner placed
// under the registered root_dir is treated as public. Path traversal
// is blocked above by `pathPosix.normalize` anchoring at `/`.
let entry = isConfigRequest
? null
: await layers.stores.fsEntry.getEntryByPath(filePath);
// `__workers/` is carved out of that bypass: worker source is the one
// thing under a site root that is NOT public. Treated exactly like the
// config file — no lookup at all, so the folder→index.html fallback
// below can't resolve into it either.
let entry =
isConfigRequest || isWorkersSourcePath(resolvedUrlPath)
? null
: await layers.stores.fsEntry.getEntryByPath(filePath);
if (entry?.isDir) {
// Folder request → fall back to <folder>/index.html, the same
// way `/` is rewritten to `/index.html` at the site root above.
@@ -513,7 +536,13 @@ export const createPuterSiteMiddleware = (
let statusOverride: number | undefined;
if (!entry || entry.isDir) {
const errorTarget = resolveErrorTarget(siteConfig, 404, rootPath);
if (errorTarget) {
// A config pointing `errors.404.file` into `__workers/` would
// publish worker source through the error page — refuse it the
// same way a nonexistent target is refused.
if (
errorTarget &&
!isWorkersSourcePath(errorTarget.absPath.slice(rootPath.length))
) {
const candidate = await layers.stores.fsEntry.getEntryByPath(
errorTarget.absPath,
);
@@ -389,7 +389,10 @@ export class SubdomainDriver extends PuterDriver {
try {
this.clients.event.emit(
'subdomain.update',
{ subdomain: row.subdomain as string },
{
subdomain: row.subdomain as string,
uid: String(row.uuid),
},
{},
);
} catch {
@@ -434,7 +437,10 @@ export class SubdomainDriver extends PuterDriver {
try {
this.clients.event.emit(
'subdomain.delete',
{ subdomain: row.subdomain as string },
{
subdomain: row.subdomain as string,
uid: String(row.uuid),
},
{},
);
} catch {