Files
picpeak/backend/src/services/chunkedUploadService.js
T
Paul Nothaft d89401810f 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
2026-09-11 08:30:19 +02:00

421 lines
13 KiB
JavaScript

const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const logger = require('../utils/logger');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getChunksPath = () => path.join(getStoragePath(), 'chunks');
// In-memory store for active uploads (in production, consider Redis)
const activeUploads = new Map();
// Chunk size: 10MB
const CHUNK_SIZE = 10 * 1024 * 1024;
// Upload expiration: 24 hours
const UPLOAD_EXPIRATION_MS = 24 * 60 * 60 * 1000;
function totalReceivedBytes(uploadMeta) {
let total = 0;
for (const size of uploadMeta.chunkSizes.values()) total += size;
return total;
}
// Tagged errors so the routes can answer 413/400 instead of a blanket 500.
function fileTooLargeError(maxFileSizeBytes) {
const err = new Error(`File too large. Maximum size is ${Math.floor(maxFileSizeBytes / (1024 * 1024))} MB per file.`);
err.code = 'FILE_TOO_LARGE';
err.statusCode = 413;
return err;
}
function invalidChunkError(message) {
const err = new Error(message);
err.code = 'INVALID_CHUNK';
err.statusCode = 400;
return err;
}
/**
* Initialize a new chunked upload
* @param {Object} options - Upload options
* @returns {Promise<Object>} - Upload metadata
*/
async function initializeUpload(options) {
const {
filename,
fileSize,
mimeType,
eventId,
totalChunks,
maxFileSizeBytes
} = options;
// Strip any directory components from the client-supplied filename. It is
// later joined onto the temp merge dir (path.join(tempDir, filename)), and
// path.join does NOT neutralise `../` — a filename like `../../uploads/
// logos/evil.svg` would escape the temp dir and overwrite arbitrary files
// (GHSA-pc72-jf53-w28j). basename() collapses it to the leaf name only.
const safeFilename = path.basename(String(filename || ''));
if (!safeFilename || safeFilename === '.' || safeFilename === '..') {
throw new Error('Invalid filename');
}
// Generate unique upload ID
const uploadId = crypto.randomUUID();
// Create chunks directory for this upload
const uploadDir = path.join(getChunksPath(), uploadId);
await fs.mkdir(uploadDir, { recursive: true });
// Calculate expected chunks
const expectedChunks = totalChunks || Math.ceil(fileSize / CHUNK_SIZE);
// The per-file cap is enforced on the BYTES ACTUALLY RECEIVED, not on the
// client-declared fileSize the init route checks: a client can declare
// `fileSize: 1` and then stream whatever it likes through the chunk route.
// Missing/invalid cap means "no cap" (callers outside the admin routes).
const cap = Number(maxFileSizeBytes);
const sizeCap = Number.isFinite(cap) && cap > 0 ? cap : Infinity;
// Store upload metadata
const uploadMeta = {
uploadId,
filename: safeFilename,
fileSize,
mimeType,
eventId,
expectedChunks,
receivedChunks: new Set(),
// Bytes per chunk index, so a re-sent chunk replaces rather than adds.
chunkSizes: new Map(),
maxFileSizeBytes: sizeCap,
uploadDir,
createdAt: Date.now(),
expiresAt: Date.now() + UPLOAD_EXPIRATION_MS,
status: 'in_progress'
};
activeUploads.set(uploadId, uploadMeta);
logger.info('Initialized chunked upload', {
uploadId,
filename: safeFilename,
fileSize,
expectedChunks,
eventId
});
return {
uploadId,
chunkSize: CHUNK_SIZE,
expectedChunks,
expiresAt: uploadMeta.expiresAt
};
}
/**
* 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|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, source, { declaredBytes } = {}) {
const uploadMeta = activeUploads.get(uploadId);
if (!uploadMeta) {
throw new Error('Upload not found or expired');
}
if (uploadMeta.status !== 'in_progress') {
throw new Error(`Upload is ${uploadMeta.status}`);
}
// Check expiration
if (Date.now() > uploadMeta.expiresAt) {
await abortUpload(uploadId);
throw new Error('Upload expired');
}
// Only the announced chunk indices are valid — anything else would merge
// into nothing (a gap) or let more chunks in than the declared file has.
if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= uploadMeta.expectedChunks) {
throw invalidChunkError(`Invalid chunk index ${chunkIndex}: expected 0-${uploadMeta.expectedChunks - 1}`);
}
// 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.
//
// 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);
}
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
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, chunkLength);
const progress = (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100;
logger.debug('Chunk uploaded', {
uploadId,
chunkIndex,
receivedChunks: uploadMeta.receivedChunks.size,
expectedChunks: uploadMeta.expectedChunks,
progress: progress.toFixed(1)
});
return {
chunkIndex,
received: uploadMeta.receivedChunks.size,
expected: uploadMeta.expectedChunks,
progress,
complete: uploadMeta.receivedChunks.size === uploadMeta.expectedChunks
};
}
/**
* Complete the upload by merging all chunks
* @param {string} uploadId - Upload ID
* @returns {Promise<Object>} - Merged file info
*/
async function completeUpload(uploadId) {
const uploadMeta = activeUploads.get(uploadId);
if (!uploadMeta) {
throw new Error('Upload not found or expired');
}
// Verify all chunks received
if (uploadMeta.receivedChunks.size !== uploadMeta.expectedChunks) {
throw new Error(`Missing chunks: received ${uploadMeta.receivedChunks.size} of ${uploadMeta.expectedChunks}`);
}
uploadMeta.status = 'merging';
// Create temp file for merged result
const tempDir = path.join(getStoragePath(), 'temp', `merge_${Date.now()}_${Math.random().toString(36).substring(7)}`);
await fs.mkdir(tempDir, { recursive: true });
const mergedFilePath = path.join(tempDir, uploadMeta.filename);
const writeStream = require('fs').createWriteStream(mergedFilePath);
try {
// Merge chunks in order
for (let i = 0; i < uploadMeta.expectedChunks; i++) {
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(i).padStart(6, '0')}`);
const chunkData = await fs.readFile(chunkPath);
await new Promise((resolve, reject) => {
writeStream.write(chunkData, (err) => {
if (err) reject(err);
else resolve();
});
});
}
await new Promise((resolve) => writeStream.end(resolve));
// Verify file size
const stats = await fs.stat(mergedFilePath);
if (stats.size !== uploadMeta.fileSize) {
logger.warn('Merged file size mismatch', {
expected: uploadMeta.fileSize,
actual: stats.size
});
}
// Backstop for the per-chunk running total above: the merged file is
// the number that matters, so it is the number that is checked last.
if (stats.size > uploadMeta.maxFileSizeBytes) {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true }).catch(() => {});
activeUploads.delete(uploadId);
throw fileTooLargeError(uploadMeta.maxFileSizeBytes);
}
// Clean up chunks
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
uploadMeta.status = 'completed';
activeUploads.delete(uploadId);
logger.info('Chunked upload completed', {
uploadId,
filename: uploadMeta.filename,
fileSize: stats.size,
eventId: uploadMeta.eventId
});
return {
path: mergedFilePath,
filename: uploadMeta.filename,
size: stats.size,
mimeType: uploadMeta.mimeType,
eventId: uploadMeta.eventId,
tempDir
};
} catch (error) {
writeStream.destroy();
uploadMeta.status = 'failed';
throw error;
}
}
/**
* Abort and clean up an upload
* @param {string} uploadId - Upload ID
*/
async function abortUpload(uploadId) {
const uploadMeta = activeUploads.get(uploadId);
if (uploadMeta) {
try {
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
} catch (err) {
logger.warn('Failed to clean up upload directory', { uploadId, error: err.message });
}
activeUploads.delete(uploadId);
logger.info('Chunked upload aborted', { uploadId });
}
}
/**
* Get upload status
* @param {string} uploadId - Upload ID
* @returns {Object|null} - Upload status or null if not found
*/
function getUploadStatus(uploadId) {
const uploadMeta = activeUploads.get(uploadId);
if (!uploadMeta) {
return null;
}
return {
uploadId,
filename: uploadMeta.filename,
fileSize: uploadMeta.fileSize,
receivedChunks: uploadMeta.receivedChunks.size,
expectedChunks: uploadMeta.expectedChunks,
progress: (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100,
status: uploadMeta.status,
createdAt: uploadMeta.createdAt,
expiresAt: uploadMeta.expiresAt
};
}
/**
* Clean up expired uploads
*/
async function cleanupExpiredUploads() {
const now = Date.now();
const expiredIds = [];
for (const [uploadId, meta] of activeUploads.entries()) {
if (now > meta.expiresAt) {
expiredIds.push(uploadId);
}
}
for (const uploadId of expiredIds) {
await abortUpload(uploadId);
}
if (expiredIds.length > 0) {
logger.info(`Cleaned up ${expiredIds.length} expired uploads`);
}
return expiredIds.length;
}
const cleanupTask = require('./scheduledTask').scheduledTask(cleanupExpiredUploads, {
interval: 60 * 60 * 1000
});
cleanupTask.start();
module.exports = {
stop: () => cleanupTask.stop(),
initializeUpload,
uploadChunk,
completeUpload,
abortUpload,
getUploadStatus,
cleanupExpiredUploads,
CHUNK_SIZE
};