Skip to content

Commit 85663a5

Browse files
fix(service-storage): refuse an out-of-range chunkIndex and a non-byte-count presigned size at the upload doors (#27537)
domain:devx#1 · sunhaolin · session_017xRBcScm27Q3R5mkYLPWdB Fixes #27412 Fixes #27413 Clause-②: no (narrowing) Metadata-protocol: none Narrowed: a negative `size`, which the spec number (`GetPresignedUrlRequestSchema.size`, a bare `z.number()`) admits, is now refused `400` at the presigned door. ## 维护者速读 - 改了什么:存储上传的两个入口在门口拒收坏参数:分片上传的 chunkIndex 必须是 [0, totalChunks) 内的整数,预签名上传的 size 必须是非负有限数字;其余一律 400 INVALID_REQUEST,不落任何数据。 - 为什么改:之前 chunkIndex=-1 返回 500,越界分片被存下并计入进度,之后 complete 永远 409 且无法修复;size="abc" 返回 500,size=-5 会写入一条负大小的 sys_file 记录。 - 风险与回滚:收窄了这两个入口接受的请求(例如数字字符串 "100" 作为 size 现在被拒);SDK 与 Console 上传客户端都发送合法值,不受影响。回滚即 revert 本 PR。 - 先看:packages/services/service-storage/src/storage-routes.ts(分片门的范围判断位置:在 resume token 校验之后、读取字节之前),chunked-upload-integrity.test.ts,storage-limits.test.ts(夹具改为声明两个分片)。 - 两分钟验证:pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2 src/chunked-upload-integrity.test.ts src/storage-routes.test.ts ,预期 2 个文件全部通过。 - 需要你做的:无,席位落地(落地前席位做一次 contract-review 档复核)。 ## What changes Two upload doors in `@objectstack/service-storage` now refuse malformed request input at the door with `400 INVALID_REQUEST`, the code these doors already answer for a request they cannot take. **Chunk door** `PUT /api/v1/storage/upload/chunked/UPLOAD_ID/chunk/CHUNK_INDEX` (#27412) - The index is read strictly: decimal digits only, a safe integer. `parseInt` read `0.5` as `0`, and `1e0` as `1`. - It is then judged against the session's `total_chunks`: it must be in `[0, totalChunks)`. The check runs after the resume-token, organization and expiry checks (a caller without the token still gets `403 INVALID_RESUME_TOKEN` and learns nothing of the range) and before a byte is read, stored or counted. - The refusal names the range (`chunkIndex must be 0: this upload declared 1 chunk, indexed from zero.` / `chunkIndex must be a whole number from 0 to N: ...`) and carries `details: { chunkIndex, totalChunks }`. **Presigned door** `POST /api/v1/storage/upload/presigned` (#27413) - `size` must be a finite, non-negative JSON number (`GetPresignedUrlRequestSchema.size` is `z.number()`). It is judged after the required-field check and before the scope gate, the size limit and the pending `sys_file` insert. - The refusal message starts `size must be the file size in bytes`. **Exactly what is now refused** (the narrowing the contract review reads) - Chunk door: any `chunkIndex` path segment that is not all decimal digits (`-1`, `0.5`, `1e0`, `0x0`, `abc`), and any whole number at or past the session's `total_chunks`. Before: `-1` answered `500 INTERNAL`; `0.5` was stored as chunk `0`; an out-of-range whole number answered `200`, was stored and counted, and then blocked `complete` with `409` for good. - Presigned door: a `size` that is a string (numeric strings such as `"100"` included), `NaN`, an infinity, a negative number, or a boolean. Before: `"abc"` answered `500 INTERNAL` (the `sys_file` insert's number validation); `-5` answered `200` and stored a pending row with `size: -5`. A numeric string's previous answer depended on the engine's number-field handling at insert and was not measured here. - Unchanged: chunk indexes `0 .. totalChunks - 1`; sizes `0` and any positive number. The SDK (`storage.upload`, `resumeUpload`) and the Console `UploadProvider` send `File.size` and indexes in range. The completion door's own `unexpectedChunks` refusal stays: a session recorded before this change can still hold a stray chunk. ## Tests - `chunked-upload-integrity.test.ts`: new block for #27412. Seven refused indexes (`-1`, `1` = totalChunks, `7`, `0.5`, `1e0`, `0x0`, `abc`), each asserting status `400`, `error.code` `INVALID_REQUEST`, the range message, the details, that the backend `uploadChunk` was never called, that the session's parts and progress are unchanged, and that a following in-range upload completes `200` with the right bytes. Controls: the multi-chunk range message, the last declared index accepted, and the `403` ordering before the index is judged. The existing stray-chunk completion case now plants its stray part on the session row (the shape a pre-fix session carries), since the chunk door no longer stores one. - `storage-routes.test.ts`: new presign cases for #27413. Eight refused sizes, each asserting status `400`, `error.code` `INVALID_REQUEST`, the message naming `size`, and that `store.createFile` was never called. Controls: `0` and `1024` answer `200` and store a pending row with that size. - `storage-limits.test.ts`: two upload-limit cases started a 1 MiB upload (one declared chunk under the 5 MiB chunk floor) and then sent chunk index `1`. They now widen the session row to two declared chunks first; what they pin, bytes received across chunks against the limit, is unchanged. ## Acceptance notes - `GetPresignedUrlRequestSchema.size` in `packages/spec` is `z.number()` with no `.int()` or `.min(0)`, so the spec still declares negative and fractional sizes legal while the door now refuses negatives. The door follows the dispatch's ruling (finite, non-negative); a fractional size is still accepted. Not filed: no producer sends either. - History shape: three commits, not two. The `storage-limits.test.ts` fixture update is a consequence of the #27412 refusal and sits in its own commit after the two card commits, because folding it into the first commit needed a history rewrite that was not permitted in this session. ## Verification <details><summary>Package tests and typecheck at dbfaf81 (before the merge of origin/main; the merged commit touches no service-storage file)</summary> ``` pnpm --filter @objectstack/service-storage test && pnpm --filter @objectstack/service-storage typecheck Test Files 70 passed (70) Tests 1278 passed (1278) check:test-typecheck: OK, 0 file(s) / 0 error(s) os-verify-lock: VERDICT command-exit 0 ``` The first run, before the `storage-limits.test.ts` fixture update, failed the two limit cases named above (`Tests 2 failed | 1276 passed`). </details> <details><summary>Ablation, one per guard (source mutation through scripts/ablation-replace.mjs; the suites import the routes from src, so no dist build is involved)</summary> ``` range check removed (anchor x1 -> x0, blob 6c89ee5 -> 14e5b17d7623) chunked-upload-integrity.test.ts: Tests 3 failed | 20 passed (index 1, index 7, multi-chunk range) restored: blob == HEAD (6c89ee5), git diff HEAD empty size check removed (anchor x1 -> x0, blob 6c89ee5 -> ed2276f74506) storage-routes.test.ts: Tests 8 failed | 52 passed (all eight refused sizes) restored: blob == HEAD (6c89ee5), git diff HEAD empty ``` With the range check removed, `-1`, `0.5`, `1e0`, `0x0` and `abc` stay refused by the strict parse, as expected. </details> <details><summary>Derived gate families (dispatch-gates --commands at 3a10b29)</summary> ``` dispatch-gates --ran: 64 derived famil(ies) accounted for: 61 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3) 61 x exit 0 exit 3 PREREQUISITE NOT MET: pnpm check:dual-build-cjs-loads (workspace dists not built), pnpm check:i18n (CLI not built), node scripts/check-plugin-teardown-shape.mjs --self-test (shallow clone, pinned fixture commit unreachable) check-nul-bytes: OK ``` Those three are NOT MEASURED locally and are left to CI. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcScm27Q3R5mkYLPWdB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 79bf748 commit 85663a5

5 files changed

Lines changed: 199 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(service-storage): the chunk upload door refuses a `chunkIndex` the upload did not declare, and the presigned upload door refuses a `size` that is not a byte count
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A REST request value is refused at the presigned upload door (a negative size): no key of any metadata schema is removed, renamed or re-shaped, no export, type or error code is removed or renamed, and the refusal reuses the existing 400 INVALID_REQUEST envelope. There is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->
11+
12+
Narrowed: a negative `size`, which the spec number (`GetPresignedUrlRequestSchema.size`, a bare `z.number()`) admits, is now refused `400` at the presigned door.
13+
14+
- **Chunk door** (`PUT /api/v1/storage/upload/chunked/:uploadId/chunk/:chunkIndex`). **Before:** the index was read with `parseInt`, so `0.5` was read as `0`, `-1` reached the storage backend as part number 0 and answered `500 INTERNAL`, and an index at or past the upload's `totalChunks` was stored, counted in the progress, and then made `POST .../complete` answer `409` for good, since no door removes a stored chunk. **Now:** the index must be a whole number (decimal digits only) in `[0, totalChunks)`; anything else answers `400 INVALID_REQUEST` naming the valid range, with `details: { chunkIndex, totalChunks }`. Nothing is stored and the progress is unchanged, so the upload can still be completed. The check runs after the resume-token check, so a caller without the token still gets `403 INVALID_RESUME_TOKEN` first.
15+
- **Presigned upload door** (`POST /api/v1/storage/upload/presigned`). **Before:** only a missing `size` was refused. A non-numeric `size` such as `"abc"` reached the `sys_file` insert and failed there as `500 INTERNAL`, and a negative `size` such as `-5` answered `200` and stored a pending `sys_file` row with that size. **Now:** `size` must be a finite, non-negative JSON number (the `GetPresignedUrlRequestSchema.size` number); a string (a numeric string such as `"100"` included), `NaN`, an infinity, a negative number or a boolean answers `400 INVALID_REQUEST` naming `size`, and no `sys_file` row is written. `0` and any positive number behave as before.
16+
- **Who sees it.** Only a client that sent an index outside the range it was given, or a size that is not a JSON number of bytes. The SDK (`storage.upload`, `resumeUpload`) and the Console upload client send `0 .. totalChunks - 1` and `File.size`, so they are not affected.

‎packages/services/service-storage/src/chunked-upload-integrity.test.ts‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,11 @@ describe('[#22313] the chunked completion door assembles the parts the upload ho
222222
it('REFUSED: a chunk held beyond the declared chunk count', async () => {
223223
const { uploadId, resumeToken, fileId } = await init(100);
224224
const sent0 = await putChunk(uploadId, resumeToken, 0, fill('a', 100));
225-
await putChunk(uploadId, resumeToken, 3, fill('z', 5));
225+
// The chunk door refuses an index outside the declared range (#27412), so
226+
// a stray chunk can only be one a session recorded before that refusal
227+
// existed: planted on the session row, the shape such a session carries.
228+
const held = JSON.parse((await store.getSession(uploadId))!.parts ?? '[]') as Array<Record<string, unknown>>;
229+
await store.updateSession(uploadId, { parts: JSON.stringify([...held, { chunkIndex: 3, eTag: 'stray', size: 5 }]) });
226230

227231
const res = await complete(uploadId, [{ chunkIndex: 0, eTag: etagOf(sent0) }]);
228232

@@ -319,6 +323,71 @@ describe('[#22313] the chunked completion door assembles the parts the upload ho
319323
expect((await adapter.download(res._json.data.key)).equals(fill('a', 100))).toBe(true);
320324
});
321325

326+
// ── the chunk door refuses an index the upload did not declare (#27412) ──
327+
328+
describe('[#27412] the chunk door takes only a whole-number index in [0, totalChunks)', () => {
329+
async function putRaw(uploadId: string, resumeToken: string, chunkIndex: string, bytes: Buffer): Promise<Res> {
330+
const res = createMockRes();
331+
await server.handler('PUT', CHUNK)(
332+
createMockReq({ params: { uploadId, chunkIndex }, headers: { 'x-resume-token': resumeToken }, rawBody: async () => bytes }),
333+
res,
334+
);
335+
return res;
336+
}
337+
338+
// `1` is the declared count itself (one chunk declared, so index 0 alone).
339+
it.each(['-1', '1', '7', '0.5', '1e0', '0x0', 'abc'])(
340+
'REFUSED 400 INVALID_REQUEST: index %s, with nothing stored or counted, and the upload still completes',
341+
async (index) => {
342+
const { uploadId, resumeToken } = await init(100);
343+
const stored = vi.spyOn(adapter, 'uploadChunk');
344+
const before = await store.getSession(uploadId);
345+
346+
const res = await putRaw(uploadId, resumeToken, index, fill('z', 10));
347+
348+
expect(res._status, JSON.stringify(res._json)).toBe(400);
349+
expect(res._json.success).toBe(false);
350+
expect(res._json.error.code).toBe('INVALID_REQUEST');
351+
expect(res._json.error.message).toMatch(/^chunkIndex must be 0: this upload declared 1 chunk/);
352+
expect(res._json.error.details).toEqual({ chunkIndex: index, totalChunks: 1 });
353+
expect(stored, 'no byte reached the backend').not.toHaveBeenCalled();
354+
const after = await store.getSession(uploadId);
355+
expect(after!.parts ?? null).toEqual(before!.parts ?? null);
356+
expect(await progress(uploadId)).toMatchObject({ uploadedChunks: 0, uploadedSize: 0, percentComplete: 0 });
357+
358+
// The refusal left nothing behind that blocks the upload.
359+
stored.mockRestore();
360+
const sent0 = await putChunk(uploadId, resumeToken, 0, fill('a', 100));
361+
const done = await complete(uploadId, [{ chunkIndex: 0, eTag: etagOf(sent0) }]);
362+
expect(done._status, JSON.stringify(done._json)).toBe(200);
363+
expect((await adapter.download(done._json.data.key)).equals(fill('a', 100))).toBe(true);
364+
},
365+
);
366+
367+
it('names the range of a multi-chunk upload', async () => {
368+
const { uploadId, resumeToken, totalChunks } = await init(MIN_CHUNK + 10);
369+
expect(totalChunks).toBe(2);
370+
const res = await putRaw(uploadId, resumeToken, '2', fill('z', 10));
371+
expect(res._status, JSON.stringify(res._json)).toBe(400);
372+
expect(res._json.error.code).toBe('INVALID_REQUEST');
373+
expect(res._json.error.message).toMatch(/^chunkIndex must be a whole number from 0 to 1: this upload declared 2 chunks/);
374+
});
375+
376+
it('CONTROL: the last declared index is accepted', async () => {
377+
const { uploadId, resumeToken } = await init(MIN_CHUNK + 10);
378+
const res = await putRaw(uploadId, resumeToken, '1', fill('b', 10));
379+
expect(res._status, JSON.stringify(res._json)).toBe(200);
380+
expect(res._json.data.chunkIndex).toBe(1);
381+
});
382+
383+
it('CONTROL: a caller without the resume token learns nothing of the range — 403 before the index is judged', async () => {
384+
const { uploadId } = await init(100);
385+
const res = await putRaw(uploadId, 'not-the-token', '7', fill('z', 10));
386+
expect(res._status, JSON.stringify(res._json)).toBe(403);
387+
expect(res._json.error.code).toBe('INVALID_RESUME_TOKEN');
388+
});
389+
});
390+
322391
// ── sessions recorded before part sizes existed ───────────────────────
323392

324393
describe('a session whose parts predate the recorded size is judged by its running total', () => {

‎packages/services/service-storage/src/storage-limits.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,25 @@ describe('[#22283] §1 · the upload doors refuse a file over max_upload_mb, bef
232232
expect(rawBody).not.toHaveBeenCalled();
233233
});
234234

235+
/**
236+
* The init door floors a chunk at 5 MiB, so an upload under a 1 MiB limit
237+
* declares ONE chunk, and the chunk door refuses an index past the declared
238+
* count (#27412). The limit is judged on the bytes received across chunks,
239+
* so these cases widen the session row to two declared chunks to send its
240+
* bytes in two.
241+
*/
242+
async function declareTwoChunks(store: { updateSession(id: string, patch: Record<string, unknown>): Promise<unknown> }, uploadId: string) {
243+
await store.updateSession(uploadId, { total_chunks: 2 });
244+
}
245+
235246
it('chunk door: the chunk that takes the bytes received past the limit is 413 — not stored, progress unchanged', async () => {
236247
const { adapter, store, routes } = await mountDoors(SAVED_ONE_MB);
237248
const started = await call(routes, 'POST', `${BASE}/upload/chunked`, {
238249
body: { filename: 'c.bin', mimeType: 'application/octet-stream', totalSize: MIB },
239250
});
240251
expect(started.status).toBe(200);
241252
const { uploadId, resumeToken } = started.body.data;
253+
await declareTwoChunks(store, uploadId);
242254
const uploadChunk = vi.spyOn(adapter, 'uploadChunk');
243255
const chunk = (index: number, bytes: number, headers: Record<string, string> = {}) =>
244256
call(routes, 'PUT', `${BASE}/upload/chunked/:uploadId/chunk/:chunkIndex`, {
@@ -263,11 +275,12 @@ describe('[#22283] §1 · the upload doors refuse a file over max_upload_mb, bef
263275
});
264276

265277
it('chunk door: a RETRIED chunk index replaces its earlier bytes — a retry is not counted twice, a grown retry still is judged', async () => {
266-
const { adapter, routes } = await mountDoors(SAVED_ONE_MB);
278+
const { adapter, store, routes } = await mountDoors(SAVED_ONE_MB);
267279
const started = await call(routes, 'POST', `${BASE}/upload/chunked`, {
268280
body: { filename: 'r.bin', mimeType: 'application/octet-stream', totalSize: MIB },
269281
});
270282
const { uploadId, resumeToken } = started.body.data;
283+
await declareTwoChunks(store, uploadId);
271284
const uploadChunk = vi.spyOn(adapter, 'uploadChunk');
272285
const chunk = (index: number, bytes: number) =>
273286
call(routes, 'PUT', `${BASE}/upload/chunked/:uploadId/chunk/:chunkIndex`, {

‎packages/services/service-storage/src/storage-routes.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,43 @@ describe('Storage REST Routes', () => {
118118
await handler(req, res);
119119
expect(res._status).toBe(400);
120120
});
121+
122+
// [#27413] The declared size is a byte count: a finite, non-negative JSON
123+
// number. Anything else is refused at the door, before the pending
124+
// `sys_file` row — a non-numeric size used to fail that insert as a 500,
125+
// and a negative one was stored.
126+
it.each<[string, unknown]>([
127+
['a non-numeric string', 'abc'],
128+
['an empty string', ''],
129+
['a numeric string', '100'],
130+
['a negative number', -5],
131+
['a negative fraction', -0.5],
132+
['NaN', Number.NaN],
133+
['Infinity', Number.POSITIVE_INFINITY],
134+
['a boolean', true],
135+
])('REFUSED 400 INVALID_REQUEST: size as %s, with no sys_file row written', async (_label, size) => {
136+
const handler = httpServer._getHandler('POST', '/api/v1/storage/upload/presigned')!;
137+
const createFile = vi.spyOn(store, 'createFile');
138+
const res = createMockRes();
139+
await handler(createMockReq({ body: { filename: 'a.png', mimeType: 'image/png', size } }), res);
140+
141+
expect(res._status, JSON.stringify(res._json)).toBe(400);
142+
expect(res._json.success).toBe(false);
143+
expect(res._json.error.code).toBe('INVALID_REQUEST');
144+
expect(res._json.error.message).toMatch(/^size must be the file size in bytes/);
145+
expect(createFile, 'no pending sys_file row').not.toHaveBeenCalled();
146+
});
147+
148+
it.each([0, 1024])('CONTROL: size %d is accepted and stored on the pending row as sent', async (size) => {
149+
const handler = httpServer._getHandler('POST', '/api/v1/storage/upload/presigned')!;
150+
const res = createMockRes();
151+
await handler(createMockReq({ body: { filename: 'a.png', mimeType: 'image/png', size } }), res);
152+
153+
expect(res._status, JSON.stringify(res._json)).toBe(200);
154+
const row = await store.getFile(res._json.data.fileId);
155+
expect(row!.size).toBe(size);
156+
expect(row!.status).toBe('pending');
157+
});
121158
});
122159

123160
describe('POST /upload/complete', () => {

‎packages/services/service-storage/src/storage-routes.ts‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,43 @@ function uploadScopeRefusalMessage(scope: unknown): string {
159159
return `scope ${named} is not accepted: it is not an upload scope. ${allowed}`;
160160
}
161161

162+
/**
163+
* [#27413] The presigned door's declared `size`: a finite, non-negative JSON
164+
* number, the byte count `GetPresignedUrlRequestSchema.size` declares. A
165+
* string (even a numeric one), `NaN`, an infinity or a negative count is
166+
* refused `400` / `INVALID_REQUEST` before the pending `sys_file` row is
167+
* written — the code the door already answers a request missing its size
168+
* with. `0` is a legal empty file.
169+
*/
170+
function isDeclaredByteCount(size: unknown): size is number {
171+
return typeof size === 'number' && Number.isFinite(size) && size >= 0;
172+
}
173+
174+
const PRESIGNED_SIZE_REFUSAL_MESSAGE =
175+
'size must be the file size in bytes: a non-negative number, sent as a JSON number';
176+
177+
/**
178+
* [#27412] The chunk door's `chunkIndex` path segment, read strictly: decimal
179+
* digits only, so `-1`, `0.5`, `1e2` and `0x1` are not indexes. `null` ⇒ not
180+
* a whole number. The range half — below the upload's declared
181+
* `total_chunks` — is judged against the session row by the caller, because
182+
* a chunk stored outside that range is counted in the progress and then
183+
* blocks the completion door for good (no door removes a chunk).
184+
*/
185+
function parseChunkIndex(raw: string): number | null {
186+
if (!/^\d+$/.test(raw)) return null;
187+
const n = Number(raw);
188+
return Number.isSafeInteger(n) ? n : null;
189+
}
190+
191+
function chunkIndexRefusalMessage(totalChunks: number): string {
192+
const range = totalChunks === 1 ? 'must be 0' : `must be a whole number from 0 to ${totalChunks - 1}`;
193+
return (
194+
`chunkIndex ${range}: this upload declared ${totalChunks} chunk${totalChunks === 1 ? '' : 's'}, ` +
195+
'indexed from zero.'
196+
);
197+
}
198+
162199
/**
163200
* [#22332] How many times the chunk door tries to record a stored chunk in the
164201
* upload's progress before it refuses.
@@ -918,6 +955,13 @@ export function registerStorageFamily(
918955
sendError(res, 400, 'INVALID_REQUEST', 'filename, mimeType, and size are required');
919956
return;
920957
}
958+
// [#27413] Not a byte count ⇒ refused before anything is stored: a
959+
// non-numeric size otherwise failed the `sys_file` insert as a 500, and
960+
// a negative one was stored on a pending row.
961+
if (!isDeclaredByteCount(size)) {
962+
sendError(res, 400, 'INVALID_REQUEST', PRESIGNED_SIZE_REFUSAL_MESSAGE);
963+
return;
964+
}
921965
// [#22470] A scope outside the upload vocabulary is refused before
922966
// anything is stored or minted.
923967
if (!requireAcceptedUploadScope(scope, res)) return;
@@ -926,7 +970,7 @@ export function registerStorageFamily(
926970
// bytes are judged again at `_local/raw`; an S3 URL takes them straight
927971
// to the bucket, so this is the platform's one look at that upload.
928972
const { presignedTtl, maxUploadBytes } = currentLimits();
929-
if (!requireWithinUploadLimit(Number(size), maxUploadBytes, (n) => `The declared file size is ${n} bytes`, res)) return;
973+
if (!requireWithinUploadLimit(size, maxUploadBytes, (n) => `The declared file size is ${n} bytes`, res)) return;
930974

931975
const fileId = randomUUID();
932976
const key = buildKey(scope ?? 'user', fileId, filename);
@@ -1160,8 +1204,7 @@ export function registerStorageFamily(
11601204
if (authSession === false) return;
11611205
const writeContext: StorageWriteContext = { organizationId: authSession?.organizationId };
11621206
const { uploadId, chunkIndex: chunkIndexStr } = req.params;
1163-
const chunkIndex = parseInt(chunkIndexStr, 10);
1164-
if (!uploadId || isNaN(chunkIndex)) {
1207+
if (!uploadId || !chunkIndexStr) {
11651208
sendError(res, 400, 'INVALID_REQUEST', 'uploadId and chunkIndex are required');
11661209
return;
11671210
}
@@ -1199,6 +1242,22 @@ export function registerStorageFamily(
11991242
return;
12001243
}
12011244

1245+
// [#27412] The index must name a chunk this upload declared: a whole
1246+
// number in [0, total_chunks). Judged here — after the resume token
1247+
// (a caller who cannot prove it owns the session learns nothing of its
1248+
// chunk count) and before a byte is read, stored or counted. An index
1249+
// outside the range used to be stored and counted in the progress, then
1250+
// refused for good by the completion door; `-1` reached the backend as
1251+
// part number 0 and answered 500.
1252+
const totalChunks = Number(session.total_chunks);
1253+
const chunkIndex = parseChunkIndex(chunkIndexStr);
1254+
if (chunkIndex === null || !(chunkIndex < totalChunks)) {
1255+
sendError(res, 400, 'INVALID_REQUEST', chunkIndexRefusalMessage(totalChunks), {
1256+
details: { chunkIndex: chunkIndexStr, totalChunks },
1257+
});
1258+
return;
1259+
}
1260+
12021261
// [#22283] The bytes received so far plus this chunk, against the saved
12031262
// limit — the declared length first, so an oversized chunk is refused
12041263
// before it is read, then the bytes actually read, before the backend

0 commit comments

Comments
 (0)