mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-28 17:07:16 +00:00
add limit bypass
This commit is contained in:
@@ -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<string, unknown>;
|
||||
expect(Object.getOwnPropertySymbols(forged)).toHaveLength(0);
|
||||
await expect(
|
||||
runWithContext({ actor: unverified() }, () =>
|
||||
strictDriver.create({
|
||||
...forged,
|
||||
appId: 'test-app',
|
||||
workerName: 'existing',
|
||||
filePath: '/test.js',
|
||||
} as Parameters<WorkerDriver['create']>[0]),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'Account email is not verified',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await strictServer.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
* 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.<name>` 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<unknown> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user