diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index 8766dc05c..c20e31a18 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -52,7 +52,9 @@ import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js'; import { PuterController } from '../types.js'; import { STORAGE_OP_COSTS } from '../../services/metering/costs.js'; import { + FS_BATCH_WRITE_MAX_ITEMS, FS_MULTIPART_LIMIT, + FS_MULTIPART_MAX_PARTS, FS_MUTATE_LIMIT, FS_READ_CONCURRENT, FS_READ_LIMIT, @@ -222,6 +224,7 @@ export class FSController extends PuterController { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const appUidLookupCache = new Map>(); + this.#assertBatchItemCount(req.body); const requests = Array.isArray(req.body) ? await Promise.all( req.body.map(async (requestBody) => { @@ -367,6 +370,7 @@ export class FSController extends PuterController { res: Response, ) { const userId = this.#getActorUserId(req); + this.#assertBatchItemCount(req.body); const requests = Array.isArray(req.body) ? req.body.map((requestBody) => { return this.#withGuiMetadata( @@ -443,6 +447,17 @@ export class FSController extends PuterController { res: Response, ) { const userId = this.#getActorUserId(req); + const partNumbers = req.body?.partNumbers; + if ( + Array.isArray(partNumbers) && + partNumbers.length > FS_MULTIPART_MAX_PARTS + ) { + throw new HttpError( + 400, + `Too many partNumbers in one request (max ${FS_MULTIPART_MAX_PARTS})`, + { legacyCode: 'bad_request' }, + ); + } await this.#assertUploadSessionWriteAccess(req, userId, [ req.body?.uploadId, ]); @@ -863,6 +878,7 @@ export class FSController extends PuterController { return; } + this.#assertBatchItemCount(req.body); const requests = Array.isArray(req.body) ? await Promise.all( req.body.map(async (requestBody) => { @@ -2382,6 +2398,16 @@ export class FSController extends PuterController { return guiMetadata; } + #assertBatchItemCount(items: unknown): void { + if (Array.isArray(items) && items.length > FS_BATCH_WRITE_MAX_ITEMS) { + throw new HttpError( + 400, + `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`, + { legacyCode: 'bad_request' }, + ); + } + } + /** * The write handlers build on `req.body` being an object. A request whose * body never parsed leaves it undefined, and the first field read after @@ -3019,6 +3045,7 @@ export class FSController extends PuterController { { legacyCode: 'bad_request' }, ); } + this.#assertBatchItemCount(manifest.items); const manifestGuiMetadata = this.#extractGuiMetadata( manifest, diff --git a/src/backend/controllers/fs/FSController.write.test.ts b/src/backend/controllers/fs/FSController.write.test.ts index 3e76a1bdf..41649fd25 100644 --- a/src/backend/controllers/fs/FSController.write.test.ts +++ b/src/backend/controllers/fs/FSController.write.test.ts @@ -28,6 +28,7 @@ import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; import type { FSController } from './FSController.js'; +import { FS_BATCH_WRITE_MAX_ITEMS, FS_MULTIPART_MAX_PARTS } from './limits.js'; import type { ClientSignedWriteResponse, CompleteWriteRequest, @@ -661,6 +662,60 @@ describe('batch write handlers with a malformed element', () => { }); }); +describe('batch write handlers at the item cap', () => { + // Null elements fail the per-item body check, so which 400 comes back + // shows whether the cap ran before any item was read. + const handlers: Array< + [string, (c: typeof controller, r: Request, res: Response) => unknown] + > = [ + [ + 'startBatchWrites', + (c, r, res) => c.startBatchWrites(r as never, res as never), + ], + ['batchWrites', (c, r, res) => c.batchWrites(r as never, res as never)], + [ + 'completeBatchWrites', + (c, r, res) => c.completeBatchWrites(r as never, res as never), + ], + ]; + + const callWith = async ( + call: (typeof handlers)[number][1], + length: number, + ) => { + const { actor } = await makeUser(); + const req = makeReq({ actor }); + (req as { body?: unknown }).body = Array(length).fill(null); + const { res } = makeRes(); + return withActor(actor, async () => call(controller, req, res)); + }; + + it.each(handlers)( + '%s rejects one item over the cap up front', + async (_label, call) => { + await expect( + callWith(call, FS_BATCH_WRITE_MAX_ITEMS + 1), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'bad_request', + message: `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`, + }); + }, + ); + + it.each(handlers)( + '%s lets exactly the cap through to the per-item checks', + async (_label, call) => { + await expect( + callWith(call, FS_BATCH_WRITE_MAX_ITEMS), + ).rejects.toMatchObject({ + statusCode: 400, + message: 'A request body is required', + }); + }, + ); +}); + describe('FSController.write storage allowance', () => { let limitedServer: PuterServer; let limitedController: FSController; @@ -1283,6 +1338,29 @@ describe('FSController.signMultipartParts', () => { ), ).rejects.toMatchObject({ statusCode: 404 }); }); + + // The session is unknown, so reaching the lookup would answer 404. + it.each([ + [FS_MULTIPART_MAX_PARTS + 1, 400], + [FS_MULTIPART_MAX_PARTS, 404], + ])('answers %i part numbers with %i', async (length, statusCode) => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + controller.signMultipartParts( + makeReq({ + body: { + uploadId: uuidv4(), + partNumbers: Array(length).fill(1), + }, + actor, + }), + res, + ), + ), + ).rejects.toMatchObject({ statusCode }); + }); }); // -- /fs/batchWrite (JSON mode) --------------------------------------- @@ -1716,6 +1794,35 @@ describe('FSController.batchWrites (multipart)', () => { }); }); + it('rejects a manifest over the item cap', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + controller.batchWrites( + makeMultipartReq( + [ + { + kind: 'field', + name: 'manifest', + value: JSON.stringify({ + items: Array( + FS_BATCH_WRITE_MAX_ITEMS + 1, + ).fill(null), + }), + }, + ], + actor, + ), + res, + ), + ), + ).rejects.toMatchObject({ + statusCode: 400, + message: `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`, + }); + }); + it('rejects a manifest item without fileMetadata', async () => { const { actor } = await makeUser(); const { res } = makeRes(); diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts index 4a85791ee..523d10e43 100644 --- a/src/backend/controllers/fs/LegacyFSController.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.test.ts @@ -33,7 +33,7 @@ import { signFile } from '../../util/fileSigning.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; import { SHARE_LIST_LIMIT } from '../share/limits.js'; import type { LegacyFSController } from './LegacyFSController.js'; -import { FS_MUTATE_LIMIT } from './limits.js'; +import { FS_MUTATE_LIMIT, FS_SIGN_MAX_ITEMS } from './limits.js'; // ── Test harness ──────────────────────────────────────────────────── // @@ -1919,6 +1919,53 @@ describe('LegacyFSController.sign', () => { ).rejects.toMatchObject({ statusCode: 400 }); }); + // The missing app would 404 if any item or the grant were looked at first. + it('rejects items over the cap before resolving anything', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + withActor(actor, () => + controller.sign( + makeReq({ + body: { + items: Array(FS_SIGN_MAX_ITEMS + 1).fill({ + path: '/x', + action: 'read', + }), + app_uid: `does-not-exist-${uuidv4()}`, + }, + actor, + }), + res, + ), + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'bad_request', + message: `Too many items in one request (max ${FS_SIGN_MAX_ITEMS})`, + }); + }); + + it('accepts exactly the cap', async () => { + const { actor } = await makeUser(); + const { res, captured } = makeRes(); + await withActor(actor, () => + controller.sign( + makeReq({ + body: { + items: Array(FS_SIGN_MAX_ITEMS).fill({ + action: 'read', + }), + }, + actor, + }), + res, + ), + ); + const body = captured.body as { signatures: Array }; + expect(body.signatures).toHaveLength(FS_SIGN_MAX_ITEMS); + }); + it('signs a valid entry by path and returns a signature', async () => { const { actor } = await makeUser(); const username = actor.user!.username!; diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index a27d813c6..b80defedf 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -72,6 +72,7 @@ import { FS_SEARCH_CONCURRENT, FS_SEARCH_LIMIT, FS_SIGN_LIMIT, + FS_SIGN_MAX_ITEMS, FS_SIGNED_CONCURRENT, FS_SIGNED_READ_LIMIT, FS_SIGNED_WRITE_LIMIT, @@ -1351,6 +1352,12 @@ export class LegacyFSController extends PuterController { throw new HttpError(400, '`items` is required', { legacyCode: 'bad_request', }); + if (items.length > FS_SIGN_MAX_ITEMS) + throw new HttpError( + 400, + `Too many items in one request (max ${FS_SIGN_MAX_ITEMS})`, + { legacyCode: 'bad_request' }, + ); const actingApp = actor.effectiveApp; const signingCfg = signingConfigFromAppConfig(this.config); diff --git a/src/backend/controllers/fs/limits.test.ts b/src/backend/controllers/fs/limits.test.ts index 53b71171b..8fb2b769c 100644 --- a/src/backend/controllers/fs/limits.test.ts +++ b/src/backend/controllers/fs/limits.test.ts @@ -28,7 +28,10 @@ import type { RouteOptions, RouteRateLimit } from '../../core/http/types'; type Spec = RouteRateLimit | NonNullable; -const all = Object.entries(limits) as Array<[string, Spec | RouteRateLimit[]]>; +// Rate and concurrency specs only; the per-request item caps are plain numbers. +const all = Object.entries(limits).filter( + ([, value]) => typeof value === 'object', +) as Array<[string, Spec | RouteRateLimit[]]>; const flat: Array<[string, Spec]> = all.flatMap(([name, value]) => Array.isArray(value) ? value.map((v, i): [string, Spec] => [`${name}[${i}]`, v]) diff --git a/src/backend/controllers/fs/limits.ts b/src/backend/controllers/fs/limits.ts index 56f2f907d..4b4b17cc1 100644 --- a/src/backend/controllers/fs/limits.ts +++ b/src/backend/controllers/fs/limits.ts @@ -185,6 +185,27 @@ export const FS_HELPER_LIMIT = userWindow('fs:helper', 120, 60, 30); */ export const FS_POLL_LIMIT = userWindow('fs:poll', 240, 240, 120); +// -- Per-request item caps ------------------------------------------- +// +// The windows above count requests; these bound what one request can fan out +// to. Each item costs its own path resolution and ACL check, and an upload item +// also a pending session and presigned URLs. Checked before any per-item work. + +/** + * `startBatchWrite` / `completeBatchWrite` / `batchWrite`. Puter.js chunks + * uploads at this size. + */ +export const FS_BATCH_WRITE_MAX_ITEMS = 500; + +/** Entries per `/sign`. Each may also write an app grant. */ +export const FS_SIGN_MAX_ITEMS = 500; + +/** + * Part numbers per `signMultipartParts`: every part a multipart upload can have + * (1-10,000). + */ +export const FS_MULTIPART_MAX_PARTS = 10_000; + // -- Legacy multipart upload ----------------------------------------- /** diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 4001f66c5..249e68070 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -140,7 +140,9 @@ Per minute unless stated: | Limit | Value | Scope | | ----- | ----- | ----- | | Uploads in progress | 10,000 | Per user, all apps | -| Files in one `startBatchWrite` | 10,000 | Per request | +| Files and folders in one `startBatchWrite`, `completeBatchWrite` or `batchWrite` | 500 | Per request | +| Entries in one `/sign` request | 500 | Per request | +| Part numbers in one `signMultipartParts` | 10,000 | Per request | | Legacy `/batch` request | 256 multipart parts (fields and files combined) or 256 JSON operations, 64 files, 100 MiB per file, 1 MiB per field, 256 MiB in total | Per request | | Signed-URL reads | 3,000/min | Per network | | Signed-URL writes | 600/min | Per network | @@ -150,6 +152,8 @@ Per minute unless stated: Non-write operations (mkdir/shortcut/move/delete) inside a legacy `/batch` request also count against the Mutations limits above. +A request over one of the per-request caps fails as a whole before any item is processed, with `400 bad_request` (`413` for legacy `/batch`). `puter.fs.upload()` already splits larger uploads into requests of 500. + An upload is in progress from `startWrite`/`startBatchWrite` (which `puter.fs.upload()` and `puter.fs.write()` use) until it completes, is cancelled, or expires. The count includes uploads by collaborators into the account's folders. Over the limit, new uploads fail with `429 too_many_requests` until some finish. The Puter desktop makes PDF upload thumbnails locally within these budgets. Going over one skips the preview; the upload itself is unaffected.