Bound request bodies and artifact uploads (Refs #94)
CI / checks (push) Successful in 4m34s

This commit is contained in:
2026-10-09 16:52:37 -04:00
parent 8fcb8312a3
commit 9eb3aa84c3
44 changed files with 603 additions and 79 deletions
+78
View File
@@ -0,0 +1,78 @@
import { error } from '@sveltejs/kit';
import { MAX_ARTIFACT_BYTES } from '../artifact-limits.js';
export const JSON_BODY_LIMIT = 1024 * 1024;
// Leave room for form fields and multipart framing around a maximum-size file.
export const ARTIFACT_FORM_LIMIT = MAX_ARTIFACT_BYTES + 1024 * 1024;
export const BETA_REVISION_FORM_LIMIT = 2 * MAX_ARTIFACT_BYTES + 1024 * 1024;
/** Enforce the limit before parsing, including absent or understated lengths. */
export async function boundedBody(request: Request, maxBytes: number) {
if (Number(request.headers.get('content-length')) > maxBytes) {
void request.body?.cancel().catch(() => {});
error(413, 'Request is too large.');
}
const reader = request.body?.getReader();
if (!reader) return Buffer.alloc(0);
const chunks: Buffer[] = [];
let block = Buffer.alloc(0);
let used = 0;
let received = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > maxBytes) {
// Cancellation may wait for another branch of a cloned request.
void reader.cancel().catch(() => {});
error(413, 'Request is too large.');
}
// Coalesce tiny chunks so bookkeeping cannot outgrow the byte limit.
let offset = 0;
while (offset < value.byteLength) {
if (used === block.length) {
block = Buffer.allocUnsafe(Math.min(maxBytes, 64 * 1024));
chunks.push(block);
used = 0;
}
const length = Math.min(value.byteLength - offset, block.length - used);
block.set(value.subarray(offset, offset + length), used);
used += length;
offset += length;
}
}
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks, received);
}
// Match Request.json()'s result type; each route retains its input validation.
export async function jsonBody(
request: Request,
maxBytes = JSON_BODY_LIMIT
): ReturnType<Request['json']> {
return JSON.parse((await boundedBody(request, maxBytes)).toString('utf8'));
}
export async function artifactFormBody(
request: Request,
maxBytes = ARTIFACT_FORM_LIMIT
): Promise<FormData> {
const body = await boundedBody(request, maxBytes);
let form: FormData;
try {
form = await new Response(body, {
headers: { 'Content-Type': request.headers.get('content-type') ?? '' }
}).formData();
} catch {
error(400, 'Request body must be valid form data.');
}
// Validate every part before any handler copies file bytes or writes versions.
for (const value of form.values()) {
const size = typeof value === 'string' ? Buffer.byteLength(value, 'utf8') : value.size;
if (size > MAX_ARTIFACT_BYTES) error(413, 'Artifacts must not exceed 32 MiB.');
}
return form;
}