mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
blacklist dynamic workers (#3582)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user