mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-13 15:20:26 +00:00
fix: cap FS batch array lengths (PUT-1894) (#4024)
This commit is contained in:
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);
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 -----------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user