diff --git a/src/backend/drivers/workers/WorkerDriver.test.ts b/src/backend/drivers/workers/WorkerDriver.test.ts index 7ede24692..4d8dbc673 100644 --- a/src/backend/drivers/workers/WorkerDriver.test.ts +++ b/src/backend/drivers/workers/WorkerDriver.test.ts @@ -13,6 +13,7 @@ import { Actor } from '../../core/actor.js'; import { runWithContext } from '../../core/context.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; +import { INTERNAL_ADMISSION_BYPASS } from './WorkerDriver.js'; import type { WorkerDriver } from './WorkerDriver.js'; describe('WorkerDriver', () => { @@ -605,4 +606,158 @@ describe('WorkerDriver', () => { expect(rows).toEqual([]); }); }); + + describe('admission bypass', () => { + const unverified = (): Actor => + makeActor({ + user: { + uuid: 'unverified-uuid', + id: 1, + username: 'unverified', + email: 'unverified@test.com', + email_confirmed: false, + }, + }); + + it('rejects an unverified account without the bypass', async () => { + const strictServer = await setupTestServer({ + strict_email_verification_required: true, + }); + const strictDriver = strictServer.drivers + .workers as unknown as WorkerDriver; + try { + await expect( + runWithContext({ actor: unverified() }, () => + strictDriver.create({ + appId: 'test-app', + workerName: 'existing', + filePath: '/test.js', + }), + ), + ).rejects.toMatchObject({ + statusCode: 400, + message: 'Account email is not verified', + }); + } finally { + await strictServer.shutdown(); + } + }); + + it('lets the bypass past the verified-email gate', async () => { + const strictServer = await setupTestServer({ + strict_email_verification_required: true, + }); + const strictDriver = strictServer.drivers + .workers as unknown as WorkerDriver; + try { + // Cloudflare is unconfigured in tests, so getting as far as the + // 503 proves the email gate no longer stopped the call. + await expect( + runWithContext({ actor: unverified() }, () => + strictDriver.create({ + appId: 'test-app', + workerName: 'existing', + filePath: '/test.js', + [INTERNAL_ADMISSION_BYPASS]: true, + }), + ), + ).rejects.toMatchObject({ statusCode: 503 }); + } finally { + await strictServer.shutdown(); + } + }); + + it('skips the per-user worker limit', async () => { + // The limit is checked after the Cloudflare-config gate, so the + // driver needs credentials to reach it. Set them on the running + // server rather than booting a configured one: a server that boots + // with an account id also demands a built worker preamble, which + // is not built for backend tests. + const store = server.stores.subdomain; + const originalList = store.listByUserIdAndPrefix.bind(store); + const driverConfig = ( + target as unknown as { config: Record } + ).config; + const originalWorkersConfig = driverConfig.workers; + driverConfig.workers = { + XAUTHKEY: 'test-key', + ACCOUNTID: 'test-account', + }; + let listCalls = 0; + store.listByUserIdAndPrefix = (async () => { + listCalls++; + return Array.from({ length: 100 }, (_, i) => ({ + subdomain: `workers.puter.w${i}`, + })) as never; + }) as typeof store.listByUserIdAndPrefix; + + try { + await expect( + inCtx(() => + target.create({ + appId: 'test-app', + workerName: 'atcap', + filePath: '/test.js', + }), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + expect(listCalls).toBe(1); + + // With the bypass the limit is never even counted; the call + // fails later, on the missing source file. + await expect( + inCtx(() => + target.create({ + appId: 'test-app', + workerName: 'atcap', + filePath: '/test.js', + [INTERNAL_ADMISSION_BYPASS]: true, + }), + ), + ).rejects.not.toMatchObject({ statusCode: 403 }); + expect(listCalls).toBe(1); + } finally { + store.listByUserIdAndPrefix = originalList; + driverConfig.workers = originalWorkersConfig; + } + }); + + it('cannot be forged through caller-supplied args', async () => { + const strictServer = await setupTestServer({ + strict_email_verification_required: true, + }); + const strictDriver = strictServer.drivers + .workers as unknown as WorkerDriver; + // Driver args reach the method as parsed JSON from the request + // body, so parse these the same way a call would. + const forgeries = [ + '{"skipAdmission":true}', + '{"skipAdmissionChecks":true}', + '{"INTERNAL_ADMISSION_BYPASS":true}', + '{"Symbol(workers.internalAdmissionBypass)":true}', + '{"__proto__":{"skipAdmission":true}}', + ]; + try { + for (const body of forgeries) { + const forged = JSON.parse(body) as Record; + expect(Object.getOwnPropertySymbols(forged)).toHaveLength(0); + await expect( + runWithContext({ actor: unverified() }, () => + strictDriver.create({ + ...forged, + appId: 'test-app', + workerName: 'existing', + filePath: '/test.js', + } as Parameters[0]), + ), + ).rejects.toMatchObject({ + statusCode: 400, + message: 'Account email is not verified', + }); + } + } finally { + await strictServer.shutdown(); + } + }); + }); }); diff --git a/src/backend/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts index aa3e352be..3bad3f7c6 100644 --- a/src/backend/drivers/workers/WorkerDriver.ts +++ b/src/backend/drivers/workers/WorkerDriver.ts @@ -3,18 +3,19 @@ * * This file is part of Puter. * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. + * Puter is free software: you can redistribute it and/or modify it under the + * terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any + * later version. * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. * * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . + * along with this program. If not, see + * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). */ import { existsSync, readFileSync } from 'node:fs'; @@ -41,6 +42,22 @@ import { const CF_BASE_URL = 'https://api.cloudflare.com/client/v4/accounts'; const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/; const MAX_WORKERS_PER_USER = 100; + +/** + * Opt-in key for `create()` that skips the admission checks a _new_ worker has + * to clear — the per-user limit and the verified-email gate — for a worker that + * already exists and cleared them when it was first created. Re-running + * admission on an existing worker can only take a working worker away from its + * owner. + * + * Deliberately a symbol, not a named field: driver arguments arrive as parsed + * JSON from the caller and are passed through untouched, and `JSON.parse` can + * never produce a symbol-keyed property. Only in-process code holding this + * import can set it. A string key here would be a privilege-escalation hole. + */ +export const INTERNAL_ADMISSION_BYPASS = Symbol( + 'workers.internalAdmissionBypass', +); const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB // How far to scan an app's child apps when resolving which workers it may // see. A user is capped at MAX_WORKERS_PER_USER workers, so only that many @@ -146,9 +163,11 @@ export class WorkerDriver extends PuterDriver { workerName: string; filePath: string; authorization?: string; + [INTERNAL_ADMISSION_BYPASS]?: boolean; }): Promise { const actor = this.#requireActor(); - this.#requireVerified(actor); + const skipAdmission = args[INTERNAL_ADMISSION_BYPASS] === true; + if (!skipAdmission) this.#requireVerified(actor); const workerName = String(args.workerName ?? '').toLowerCase(); const filePath = String(args.filePath ?? ''); const appId = args.appId || actor.app?.uid; @@ -190,11 +209,12 @@ export class WorkerDriver extends PuterDriver { this.#requireCfConfig(); // Quota check — count existing workers.puter.* subdomains owned by user - const existingWorkers = - await this.stores.subdomain.listByUserIdAndPrefix( - actor.user.id, - WORKER_SUBDOMAIN_PREFIX, - ); + const existingWorkers = skipAdmission + ? [] + : await this.stores.subdomain.listByUserIdAndPrefix( + actor.user.id, + WORKER_SUBDOMAIN_PREFIX, + ); if (existingWorkers.length >= MAX_WORKERS_PER_USER) { throw new HttpError( 403,