mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user