add limit bypass (#3500)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

* add limit bypass

* update test
This commit is contained in:
Neal Shah
2026-08-03 19:43:59 -04:00
committed by GitHub
parent 8df72ef343
commit eb830799c0
2 changed files with 190 additions and 15 deletions
@@ -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<string, unknown> }
).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<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();
}
});
});
});
+35 -15
View File
@@ -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
// 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<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;
@@ -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,