fix(upload): stop buffering a chunk body before anything checks its size

The chunk route drained the whole request into an array and concatenated it
before calling uploadChunk — which is where every check lives. So a 300MB body
sent against an unknown upload id was read in full, cost ~300MB of heap, and
was only then answered with an error. The per-file cap was real but applied
after the damage, and nothing looked at Content-Length at all.

uploadChunk now takes the request stream itself and consumes nothing until the
upload id, the chunk index and the declared Content-Length have all been
checked against the remaining allowance. A body that clears those is streamed
straight to the chunk file under a hard byte cap, so a sender that lies about
its length — or sends none, the Transfer-Encoding: chunked case — is cut off
at the allowance instead of being read to the end.

A Buffer is still accepted, so the existing callers and chunkedUploadSizeCap
tests are untouched. Worth noting the old code silently did nothing when
handed a stream: fs.promises.writeFile accepts an async iterable, so the body
was written while `chunkData.length` was undefined and the cap comparison was
NaN > max, i.e. always false.

Reachable only for an authenticated admin holding photos.upload on an event
they own, and only once the CSRF gate lets application/octet-stream through.

Relates to issue 1403
This commit is contained in:
Paul Nothaft
2026-09-11 08:30:19 +02:00
parent c4d89c9d64
commit d89401810f
3 changed files with 232 additions and 15 deletions
@@ -0,0 +1,150 @@
/**
* A rejected chunk must not cost its own size in memory (#1403).
*
* The route used to drain the whole request into an array and `Buffer.concat`
* it before calling uploadChunk, which is where every check lives — the
* per-file cap, the chunk index, and even "does this upload id exist". So a
* 300MB body against an unknown upload id was read in full, added ~300MB to
* RSS, and was then answered with an error. The size cap was real but only
* applied after the damage.
*
* The contract these tests pin: uploadChunk consumes NOTHING until every check
* has passed, and once it does start reading it stops at the remaining
* allowance rather than trusting the sender.
*/
const path = require('path');
const os = require('os');
const fs = require('fs').promises;
const { Readable } = require('stream');
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-stream-test-${process.pid}`);
const chunkedUpload = require('../../src/services/chunkedUploadService');
const MB = 1024 * 1024;
const init = (overrides = {}) => chunkedUpload.initializeUpload({
filename: 'clip.mp4',
fileSize: 1,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 2,
maxFileSizeBytes: 1 * MB,
...overrides,
});
/**
* A readable that reports how much of it was actually pulled. Bytes are
* generated lazily, so "never read" really means the body never materialized.
*/
function countingSource(totalBytes, sliceSize = 64 * 1024) {
let remaining = totalBytes;
const source = new Readable({
read() {
if (remaining <= 0) return this.push(null);
const n = Math.min(sliceSize, remaining);
remaining -= n;
source.bytesRead += n;
this.push(Buffer.alloc(n));
},
});
source.bytesRead = 0;
return source;
}
describe('chunked upload streams the body under a cap (#1403)', () => {
afterAll(async () => {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
});
describe('refused before the body is read', () => {
it('reads nothing for an unknown upload id', async () => {
const source = countingSource(8 * MB);
await expect(chunkedUpload.uploadChunk('does-not-exist', 0, source, { declaredBytes: 8 * MB }))
.rejects.toThrow('Upload not found or expired');
expect(source.bytesRead).toBe(0);
});
it('reads nothing for an out-of-range chunk index', async () => {
const { uploadId } = await init();
const source = countingSource(8 * MB);
await expect(chunkedUpload.uploadChunk(uploadId, 99, source, { declaredBytes: 8 * MB }))
.rejects.toMatchObject({ code: 'INVALID_CHUNK', statusCode: 400 });
expect(source.bytesRead).toBe(0);
});
it('reads nothing when Content-Length already exceeds the cap', async () => {
const { uploadId } = await init();
const source = countingSource(8 * MB);
await expect(chunkedUpload.uploadChunk(uploadId, 0, source, { declaredBytes: 8 * MB }))
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
expect(source.bytesRead).toBe(0);
expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull();
});
it('counts what earlier chunks already banked when checking Content-Length', async () => {
const { uploadId } = await init();
await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.75 * MB));
const source = countingSource(0.5 * MB);
// 0.75MB banked + 0.5MB declared > the 1MB cap.
await expect(chunkedUpload.uploadChunk(uploadId, 1, source, { declaredBytes: 0.5 * MB }))
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
expect(source.bytesRead).toBe(0);
});
});
describe('a sender that lies, or says nothing', () => {
it('stops at the allowance instead of reading the whole body', async () => {
const { uploadId } = await init();
// No declaredBytes at all — the Transfer-Encoding: chunked case.
const source = countingSource(8 * MB);
await expect(chunkedUpload.uploadChunk(uploadId, 0, source))
.rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
// The overshoot is whatever the readable had already buffered ahead when
// the cap tripped — a small constant tied to highWaterMark, NOT a
// function of the body size. That is the whole claim: 8MB offered, ~1MB
// read. The slack is deliberately loose so this doesn't turn into a
// Node-version canary.
expect(source.bytesRead).toBeLessThan(2 * MB);
});
it('leaves no partial chunk file behind when it cuts a body off', async () => {
const { uploadId } = await init();
const meta = chunkedUpload.getUploadStatus(uploadId);
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(8 * MB)))
.rejects.toMatchObject({ statusCode: 413 });
// abortUpload removes the whole directory; assert nothing survived it.
await expect(fs.readdir(path.join(process.env.STORAGE_PATH, 'chunks', uploadId)))
.rejects.toMatchObject({ code: 'ENOENT' });
expect(meta).not.toBeNull();
});
});
describe('the happy path still works', () => {
it('writes a streamed chunk and reports progress', async () => {
const { uploadId } = await init();
const result = await chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.25 * MB), {
declaredBytes: 0.25 * MB,
});
expect(result).toMatchObject({ chunkIndex: 0, received: 1, expected: 2, complete: false });
const chunkPath = path.join(process.env.STORAGE_PATH, 'chunks', uploadId, 'chunk_000000');
expect((await fs.stat(chunkPath)).size).toBe(0.25 * MB);
});
it('still accepts a Buffer, the shape the service was written for', async () => {
const { uploadId } = await init();
const result = await chunkedUpload.uploadChunk(uploadId, 0, Buffer.alloc(0.25 * MB));
expect(result).toMatchObject({ chunkIndex: 0, received: 1 });
});
it('lets a re-sent chunk replace itself without double-counting', async () => {
const { uploadId } = await init();
await chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.6 * MB), { declaredBytes: 0.6 * MB });
// Same index again: the first copy's 0.6MB must not count toward the cap.
await expect(
chunkedUpload.uploadChunk(uploadId, 0, countingSource(0.6 * MB), { declaredBytes: 0.6 * MB }),
).resolves.toBeTruthy();
});
});
});
+10 -8
View File
@@ -1701,14 +1701,16 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
try {
const { uploadId, chunkIndex } = req.params;
// Get chunk data from request body
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const chunkData = Buffer.concat(chunks);
const result = await chunkedUpload.uploadChunk(uploadId, parseInt(chunkIndex), chunkData);
// The request stream is handed over unread (#1403). Every check — unknown
// upload id, bad index, the per-file cap against Content-Length — runs
// inside uploadChunk before a byte is consumed, and the body is then
// streamed to the chunk file under a hard cap rather than concatenated in
// memory. Buffering it first meant a rejected 300MB request still cost
// 300MB of heap.
const declaredBytes = Number(req.headers['content-length']);
const result = await chunkedUpload.uploadChunk(uploadId, parseInt(chunkIndex), req, {
declaredBytes: Number.isFinite(declaredBytes) ? declaredBytes : undefined,
});
res.json(result);
} catch (error) {
+72 -7
View File
@@ -115,14 +115,54 @@ async function initializeUpload(options) {
};
}
/**
* Stream `source` into `chunkPath`, refusing to write more than `allowance`
* bytes (#1403). The cap is the backstop for a request that lies about its
* Content-Length or omits it: the moment the running total passes the
* allowance the source is destroyed and the partial file removed, so an
* oversized body costs the allowance rather than its own size.
*/
function writeChunkStream(source, chunkPath, allowance) {
const fsSync = require('fs');
return new Promise((resolve, reject) => {
const out = fsSync.createWriteStream(chunkPath);
let written = 0;
let failed = null;
const fail = (err) => {
if (failed) return;
failed = err;
source.destroy();
out.destroy();
fsSync.unlink(chunkPath, () => reject(err));
};
source.on('data', (buf) => {
written += buf.length;
if (written > allowance) {
fail(Object.assign(new Error('CHUNK_OVER_ALLOWANCE'), { overAllowance: true }));
}
});
source.on('error', fail);
out.on('error', fail);
out.on('finish', () => { if (!failed) resolve(written); });
source.pipe(out);
});
}
/**
* Upload a single chunk
* @param {string} uploadId - Upload ID
* @param {number} chunkIndex - Chunk index (0-based)
* @param {Buffer} chunkData - Chunk data
* @param {Buffer|import('stream').Readable} source - Chunk bytes, or a stream
* of them (the request). A stream is never read until every check below has
* passed, so a rejected request costs nothing (#1403).
* @param {Object} [options]
* @param {number} [options.declaredBytes] - Content-Length, when the caller
* has one. Checked against the remaining allowance before the body is read.
* @returns {Promise<Object>} - Chunk upload result
*/
async function uploadChunk(uploadId, chunkIndex, chunkData) {
async function uploadChunk(uploadId, chunkIndex, source, { declaredBytes } = {}) {
const uploadMeta = activeUploads.get(uploadId);
if (!uploadMeta) {
@@ -148,19 +188,44 @@ async function uploadChunk(uploadId, chunkIndex, chunkData) {
// Enforce the per-file cap on the running byte total. The upload is
// aborted, not just rejected: the chunks on disk are already over the
// limit and the client can't complete the file any more.
const receivedBytes = totalReceivedBytes(uploadMeta) - (uploadMeta.chunkSizes.get(chunkIndex) || 0) + chunkData.length;
if (receivedBytes > uploadMeta.maxFileSizeBytes) {
//
// What this chunk may still contribute — everything already banked, minus a
// re-sent copy of this same index. Computed before the body is touched so a
// Content-Length that already blows the budget is refused having read zero
// bytes (#1403).
const bankedBytes = totalReceivedBytes(uploadMeta) - (uploadMeta.chunkSizes.get(chunkIndex) || 0);
const allowance = uploadMeta.maxFileSizeBytes - bankedBytes;
if (Number.isFinite(declaredBytes) && declaredBytes > allowance) {
await abortUpload(uploadId);
throw fileTooLargeError(uploadMeta.maxFileSizeBytes);
}
// Write chunk to disk
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
await fs.writeFile(chunkPath, chunkData);
let chunkLength;
if (Buffer.isBuffer(source)) {
if (bankedBytes + source.length > uploadMeta.maxFileSizeBytes) {
await abortUpload(uploadId);
throw fileTooLargeError(uploadMeta.maxFileSizeBytes);
}
await fs.writeFile(chunkPath, source);
chunkLength = source.length;
} else {
try {
chunkLength = await writeChunkStream(source, chunkPath, allowance);
} catch (err) {
if (err.overAllowance) {
await abortUpload(uploadId);
throw fileTooLargeError(uploadMeta.maxFileSizeBytes);
}
throw err;
}
}
// Mark chunk as received
uploadMeta.receivedChunks.add(chunkIndex);
uploadMeta.chunkSizes.set(chunkIndex, chunkData.length);
uploadMeta.chunkSizes.set(chunkIndex, chunkLength);
const progress = (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100;