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
+2
View File
@@ -0,0 +1,2 @@
// Keep managed artifacts within the host agent's MAX_FILE limit.
export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
+6
View File
@@ -2,6 +2,7 @@ import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { createHash } from 'node:crypto';
import { db } from '../db/index.js';
import { configFileForGame, type ArtifactKind } from '../config.js';
import { MAX_ARTIFACT_BYTES } from '../../artifact-limits.js';
export type { ArtifactKind };
@@ -72,6 +73,7 @@ export class ScriptError extends Error {
| 'baseline-exists'
| 'empty-overlay'
| 'invalid-release-version'
| 'too-large'
) {
super(message);
}
@@ -218,6 +220,8 @@ export function createScript(input: CreateScriptInput): {
script: ScriptRow;
version: ScriptVersionRow;
} {
if (input.content.byteLength > MAX_ARTIFACT_BYTES)
throw new ScriptError('Artifacts must not exceed 32 MiB.', 'too-large');
if (!input.name.trim()) throw new ScriptError('script name is required', 'name-empty');
const kind: ArtifactKind = input.kind ?? 'script';
if (!isArtifactKind(kind)) throw new ScriptError(`invalid kind: ${input.kind}`, 'invalid-kind');
@@ -316,6 +320,8 @@ export function uploadNewVersion(
notes?: string,
releaseVersion?: string
): ScriptVersionRow {
if (content.byteLength > MAX_ARTIFACT_BYTES)
throw new ScriptError('Artifacts must not exceed 32 MiB.', 'too-large');
const script = getScript(scriptId);
if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script');
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { GithubPluginAsset, GithubPluginRelease } from '$lib/plugin-github.js';
import { MAX_ARTIFACT_BYTES } from '../../artifact-limits.js';
const MAX_DLL_BYTES = 64 * 1024 * 1024;
const CACHE_MS = 5 * 60 * 1000;
const cache = new Map<string, { expires: number; release: Promise<GithubPluginRelease> }>();
@@ -157,7 +157,7 @@ async function fetchRelease(repository: string, tag: string | null): Promise<Git
a.state === 'uploaded' &&
Number.isSafeInteger(a.size) &&
a.size > 0 &&
a.size <= MAX_DLL_BYTES
a.size <= MAX_ARTIFACT_BYTES
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- The preceding filter validates the decoded asset fields.
.map((a: any) => ({
@@ -238,7 +238,7 @@ export async function downloadGithubAsset(
502
);
}
const content = await limitedBody(response, MAX_DLL_BYTES);
const content = await limitedBody(response, MAX_ARTIFACT_BYTES);
if (content.length !== asset.size)
throw new GithubReleaseError(
'Downloaded size differs from the release asset. Preview the release again.',
+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;
}