diff --git a/src/backend/drivers/workers/WorkerDriver.test.ts b/src/backend/drivers/workers/WorkerDriver.test.ts index 380716d95..954f59095 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', () => { @@ -330,4 +331,150 @@ 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 () => { + const cfServer = await setupTestServer({ + workers: { XAUTHKEY: 'test-key', ACCOUNTID: 'test-account' }, + }); + const cfDriver = cfServer.drivers + .workers as unknown as WorkerDriver; + const store = cfServer.stores.subdomain; + const original = store.listByUserIdAndPrefix.bind(store); + 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( + runWithContext({ actor }, () => + cfDriver.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( + runWithContext({ actor }, () => + cfDriver.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 = original; + await cfServer.shutdown(); + } + }); + + 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 781db51c7..a2cd04e3f 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 let USE_LOCAL_WORKERD = false; @@ -94,14 +111,15 @@ export function getWorkerPreamble(): string { } /** - * Driver exposing the `workers` interface — Cloudflare Workers - * deployment, lifecycle, and file-path queries. + * Driver exposing the `workers` interface — Cloudflare Workers deployment, + * lifecycle, and file-path queries. * - * Each "worker" is a JS file in the user's Puter FS, deployed to - * Cloudflare Workers. A corresponding `subdomains` row with subdomain + * Each "worker" is a JS file in the user's Puter FS, deployed to Cloudflare + * Workers. A corresponding `subdomains` row with subdomain * `workers.puter.` ties the worker to its source file. * - * Config: `config.workers.{XAUTHKEY, ACCOUNTID, namespace?, internetExposedUrl?, loggingUrl?}`. + * Config: `config.workers.{XAUTHKEY, ACCOUNTID, namespace?, + * internetExposedUrl?, loggingUrl?}`. */ export class WorkerDriver extends PuterDriver { readonly driverInterface = 'workers'; @@ -141,9 +159,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; @@ -167,11 +187,12 @@ export class WorkerDriver extends PuterDriver { const subdomainName = `${WORKER_SUBDOMAIN_PREFIX}${workerName}`; // 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, @@ -600,8 +621,8 @@ export class WorkerDriver extends PuterDriver { /** * Mirror of the HTTP-layer `requireVerifiedGate` on /delete-site — only * active when `strict_email_verification_required` is truthy, so self- - * hosted installs without SMTP aren't bricked. Applied at the driver - * level so /drivers/call can't bypass the gate the HTTP route enforces. + * hosted installs without SMTP aren't bricked. Applied at the driver level + * so /drivers/call can't bypass the gate the HTTP route enforces. */ #requireVerified(actor: Actor): void { assertVerifiedEmail(