fix: cap FS batch array lengths (PUT-1894) (#4024)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-02 10:06:21 -07:00
1 parent a4f1e665df
commit 86bb94d3d9
7 files changed
+219 -3

No files matched your search

@@ -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<string, Promise<number | null>>();
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<ClientCompleteWriteResponse[]>,
) {
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<ClientSignMultipartPartsResponse>,
) {
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,
@@ -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<SignMultipartPartsRequest>({
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();
@@ -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<unknown> };
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!;
@@ -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);
+4 -1
View File
@@ -28,7 +28,10 @@ import type { RouteOptions, RouteRateLimit } from '../../core/http/types';
type Spec = RouteRateLimit | NonNullable<RouteOptions['concurrent']>;
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])
+21
View File
@@ -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 -----------------------------------------
/**
+5 -1
View File
@@ -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.