From 9eb3aa84c3f2e6b2a7d18880c3bc53cd218f8852 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 9 Oct 2026 16:52:37 -0400 Subject: [PATCH] Bound request bodies and artifact uploads (Refs #94) --- Dockerfile | 1 + docker-compose.yml | 2 +- docs/guides/native-hosts.md | 26 ++- docs/guides/plugin-releases.md | 2 +- src/lib/artifact-limits.ts | 2 + src/lib/server/fleet/scripts.ts | 6 + .../server/integrations/github-releases.ts | 6 +- src/lib/server/request-body.ts | 78 ++++++++ src/routes/api/autoscaling/+server.ts | 6 +- src/routes/api/betas/+server.ts | 8 +- src/routes/api/betas/[id]/promote/+server.ts | 8 +- src/routes/api/betas/[id]/retry/+server.ts | 8 +- .../api/betas/[id]/revisions/+server.ts | 6 +- src/routes/api/betas/[id]/test/+server.ts | 6 +- src/routes/api/betas/presets/+server.ts | 6 +- src/routes/api/blueprints/+server.ts | 6 +- src/routes/api/blueprints/[id]/+server.ts | 6 +- src/routes/api/configs/inspect/+server.ts | 8 +- .../api/deployment-collections/+server.ts | 6 +- .../deployment-collections/preview/+server.ts | 6 +- .../api/deployments/house-party/+server.ts | 8 +- src/routes/api/deployments/preview/+server.ts | 6 +- src/routes/api/drift/+server.ts | 8 +- src/routes/api/maintenance/+server.ts | 8 +- src/routes/api/maintenance/[id]/+server.ts | 8 +- .../api/plugins/github/import/+server.ts | 8 +- src/routes/api/plutonium/+server.ts | 8 +- src/routes/api/provisioning/+server.ts | 8 +- src/routes/api/rebalancing/+server.ts | 6 +- src/routes/api/scripts/+server.ts | 3 +- src/routes/api/scripts/[id]/scope/+server.ts | 8 +- .../api/scripts/[id]/versions/+server.ts | 3 +- src/routes/api/scripts/inspect/+server.ts | 3 +- src/routes/api/server-keys/+server.ts | 13 +- src/routes/api/servers/+server.ts | 8 +- src/routes/api/settings/+server.ts | 9 +- src/routes/api/settings/retention/+server.ts | 6 +- .../api/settings/retention/preview/+server.ts | 6 +- src/routes/api/telemetry/+server.ts | 8 +- src/routes/api/watchdog/+server.ts | 8 +- src/routes/api/watchdog/[id]/+server.ts | 8 +- tests/unit/github-plugin-import.test.ts | 2 +- tests/unit/request-body-routes.test.ts | 181 ++++++++++++++++++ tests/unit/request-body.test.ts | 145 ++++++++++++++ 44 files changed, 603 insertions(+), 79 deletions(-) create mode 100644 src/lib/artifact-limits.ts create mode 100644 src/lib/server/request-body.ts create mode 100644 tests/unit/request-body-routes.test.ts create mode 100644 tests/unit/request-body.test.ts diff --git a/Dockerfile b/Dockerfile index de06592..baff69b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,7 @@ RUN chmod +x /entrypoint.sh ENV NODE_ENV=production ENV PORT=3000 ENV HOST=0.0.0.0 +# Legacy host imports stream whole game files; buffering routes enforce their own caps. ENV BODY_SIZE_LIMIT=Infinity ENV SQLITE_TMPDIR=/data/.sqlite-tmp EXPOSE 3000 diff --git a/docker-compose.yml b/docker-compose.yml index 3e01490..aea8a4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-} EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-} EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false} - # Large game files are streamed with manifest size and checksum limits. + # Legacy host imports stream whole game files; buffering routes enforce their own caps. BODY_SIZE_LIMIT: 'Infinity' DB_PATH: /data/cherry.db GAME_FILES_ROOT: /data/game-files diff --git a/docs/guides/native-hosts.md b/docs/guides/native-hosts.md index de3f8db..ee55bc0 100644 --- a/docs/guides/native-hosts.md +++ b/docs/guides/native-hosts.md @@ -657,10 +657,28 @@ an open Game Files page. ### Proxy limits and older uploads -The controller and reverse proxy must accept 8 MiB upload requests and folder -manifests up to 32 MiB. Compose sets `BODY_SIZE_LIMIT=Infinity`; application routes -still enforce these limits, declared file sizes and checksums. Set a proxy body -limit of at least 32 MiB (for example `client_max_body_size 32m` in nginx). +The container and Compose retain `BODY_SIZE_LIMIT=Infinity` for legacy source-host +imports, which stream whole game files directly to disk and verify manifest sizes +and checksums. Routes that buffer request bodies enforce limits before parsing: +ordinary JSON requests (including telemetry) are limited to 1 MiB, authentication +and user-management JSON to 8 KiB, game-file upload chunks to 8 MiB, and folder +manifests to 32 MiB. Existing agent endpoints retain their individual limits, up +to 128 MiB for operation results. Game-file uploads also enforce declared sizes +and checksums. + +Script, plugin and config artifacts are limited to 32 MiB per file, matching the +host agent. Multipart uploads allow 33 MiB total, including fields and framing; +beta revisions allow 65 MiB total for multiple replacement files. Both the declared +request length and actual streamed bytes are checked before multipart parsing. +Oversized ordinary JSON and artifact uploads receive HTTP 413. GitHub plugin imports use +the same 32 MiB artifact limit. + +Set a proxy body limit of at least 128 MiB (for example +`client_max_body_size 128m` in nginx) for buffered endpoints. If legacy source-host +imports are still in use, allow the largest game file on +`/api/agent/imports/*/files/*`; these transfers do not use the browser's 8 MiB +chunks. Route limits apply even when connecting directly to the controller. + Existing uploads, host caches and server references remain usable. Previously named libraries are grouped under T5 or T6, and the most recently published upload for each game becomes its latest files. Their immutable IDs and stored diff --git a/docs/guides/plugin-releases.md b/docs/guides/plugin-releases.md index 514fda4..0abcf69 100644 --- a/docs/guides/plugin-releases.md +++ b/docs/guides/plugin-releases.md @@ -40,7 +40,7 @@ GitHub release is imported from its detail page. ## Limits - Public repositories on github.com only; no GitHub credentials are needed. -- Direct `.dll` release assets up to 64 MB; archives and source-code downloads +- Direct `.dll` release assets up to 32 MiB; archives and source-code downloads are not extracted. Other formats can still use the manual upload workflow. - Release tags have the same 80-character limit as manually entered versions. - GitHub rate limits, missing releases, download failures and checksum failures diff --git a/src/lib/artifact-limits.ts b/src/lib/artifact-limits.ts new file mode 100644 index 0000000..2beeb0e --- /dev/null +++ b/src/lib/artifact-limits.ts @@ -0,0 +1,2 @@ +// Keep managed artifacts within the host agent's MAX_FILE limit. +export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024; diff --git a/src/lib/server/fleet/scripts.ts b/src/lib/server/fleet/scripts.ts index 08d02be..05797a1 100644 --- a/src/lib/server/fleet/scripts.ts +++ b/src/lib/server/fleet/scripts.ts @@ -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'); diff --git a/src/lib/server/integrations/github-releases.ts b/src/lib/server/integrations/github-releases.ts index 721226c..64c045e 100644 --- a/src/lib/server/integrations/github-releases.ts +++ b/src/lib/server/integrations/github-releases.ts @@ -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 }>(); @@ -157,7 +157,7 @@ async function fetchRelease(repository: string, tag: string | null): Promise 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.', diff --git a/src/lib/server/request-body.ts b/src/lib/server/request-body.ts new file mode 100644 index 0000000..0194e08 --- /dev/null +++ b/src/lib/server/request-body.ts @@ -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 { + return JSON.parse((await boundedBody(request, maxBytes)).toString('utf8')); +} + +export async function artifactFormBody( + request: Request, + maxBytes = ARTIFACT_FORM_LIMIT +): Promise { + 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; +} diff --git a/src/routes/api/autoscaling/+server.ts b/src/routes/api/autoscaling/+server.ts index f4a00f2..7fa16d0 100644 --- a/src/routes/api/autoscaling/+server.ts +++ b/src/routes/api/autoscaling/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { saveScaling, deleteScaling, @@ -12,7 +13,7 @@ export function GET() { } export async function POST({ request }: { request: Request }) { try { - const body = await request.json(); + const body = await jsonBody(request); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ScalingError('Provide a policy action.'); if (body.id !== undefined && typeof body.id !== 'string') @@ -36,6 +37,7 @@ export async function POST({ request }: { request: Request }) { } throw new ScalingError('Choose a supported policy action.'); } catch (error) { + if (isHttpError(error, 413)) throw error; return json( { error: (error as Error).message }, { status: error instanceof ScalingError ? error.status : 400 } diff --git a/src/routes/api/betas/+server.ts b/src/routes/api/betas/+server.ts index d6fd6b6..dd85b95 100644 --- a/src/routes/api/betas/+server.ts +++ b/src/routes/api/betas/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { openBeta, BetaError } from '$lib/server/fleet/beta.js'; import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js'; import { db } from '$lib/server/db/index.js'; @@ -56,7 +57,10 @@ export async function GET({ url }: { url: URL }) { // or the legacy form: // { scripts: [{ scriptId, scriptVersionId }], ... } export async function POST({ request }: { request: Request }) { - const body = (await request.json().catch(() => ({}))) as { + const body = (await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return {}; + })) as { artifacts?: Array<{ id?: number; versionId?: number }>; scripts?: Array<{ scriptId?: number; scriptVersionId?: number }>; servers?: string[]; diff --git a/src/routes/api/betas/[id]/promote/+server.ts b/src/routes/api/betas/[id]/promote/+server.ts index 2c64f34..31c91d1 100644 --- a/src/routes/api/betas/[id]/promote/+server.ts +++ b/src/routes/api/betas/[id]/promote/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { promoteBeta, previewBetaPromotion, BetaError } from '$lib/server/fleet/beta.js'; function parseId(raw: string): number | null { @@ -23,7 +24,10 @@ export async function GET({ params }: { params: { id: string } }) { export async function POST({ params, request }: { params: { id: string }; request: Request }) { const id = parseId(params.id); if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 }); - const body = await request.json().catch(() => null); + const body = await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return null; + }); if (typeof body?.previewToken !== 'string') return json( { ok: false, error: 'Review promotion targets before confirming.' }, diff --git a/src/routes/api/betas/[id]/retry/+server.ts b/src/routes/api/betas/[id]/retry/+server.ts index 5396fdb..b695094 100644 --- a/src/routes/api/betas/[id]/retry/+server.ts +++ b/src/routes/api/betas/[id]/retry/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { db } from '$lib/server/db/index.js'; import { emit, type BetaStage } from '$lib/server/pipeline/bus.js'; import { storedPlan } from '$lib/server/fleet/beta-tests.js'; @@ -24,7 +25,10 @@ export async function POST({ params, request }: { params: { id: string }; reques { ok: false, error: 'Use the test plan retry action to collect fresh evidence' }, { status: 409 } ); - const body = (await request.json().catch(() => ({}))) as { server?: string }; + const body = (await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return {}; + })) as { server?: string }; if (!body.server) return json({ ok: false, error: 'server is required' }, { status: 400 }); const row = db() diff --git a/src/routes/api/betas/[id]/revisions/+server.ts b/src/routes/api/betas/[id]/revisions/+server.ts index 0d79fa5..e5f675a 100644 --- a/src/routes/api/betas/[id]/revisions/+server.ts +++ b/src/routes/api/betas/[id]/revisions/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { artifactFormBody, BETA_REVISION_FORM_LIMIT } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { BetaError, reviseBeta, type BetaRevisionUpload } from '$lib/server/fleet/beta.js'; import { ScriptError } from '$lib/server/fleet/scripts.js'; import { kick } from '$lib/server/pipeline/worker.js'; @@ -8,7 +9,7 @@ export async function POST({ params, request }: { params: { id: string }; reques if (!Number.isSafeInteger(id) || id < 1) return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 }); try { - const form = await request.formData(); + const form = await artifactFormBody(request, BETA_REVISION_FORM_LIMIT); const expectedRevision = Number(form.get('expectedRevision')); if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) throw new Error('A reviewed revision is required.'); @@ -35,6 +36,7 @@ export async function POST({ params, request }: { params: { id: string }; reques void kick(); return json({ ok: true, result }); } catch (error) { + if (isHttpError(error)) throw error; return json( { ok: false, error: (error as Error).message }, { status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 } diff --git a/src/routes/api/betas/[id]/test/+server.ts b/src/routes/api/betas/[id]/test/+server.ts index 20dc961..50d8770 100644 --- a/src/routes/api/betas/[id]/test/+server.ts +++ b/src/routes/api/betas/[id]/test/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { releaseRestartRequest } from '$lib/server/provisioning/index.js'; import { db } from '$lib/server/db/index.js'; import { deployBetaServers, getBeta } from '$lib/server/fleet/beta.js'; @@ -27,7 +28,7 @@ export async function POST({ params, request }: { params: { id: string }; reques if (!['running', 'ready-promote'].includes(beta.state)) return json({ error: 'Beta is closed' }, { status: 409 }); try { - const body = await request.json(); + const body = await jsonBody(request); if ( db() .prepare( @@ -148,6 +149,7 @@ export async function POST({ params, request }: { params: { id: string }; reques void kick(); return json({ ok: true }); } catch (error) { + if (isHttpError(error, 413)) throw error; return json({ ok: false, error: (error as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/betas/presets/+server.ts b/src/routes/api/betas/presets/+server.ts index 2114c16..3dbe88d 100644 --- a/src/routes/api/betas/presets/+server.ts +++ b/src/routes/api/betas/presets/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { db } from '$lib/server/db/index.js'; import { builtInPlans, parseBetaPlan } from '$lib/beta-plan.js'; export function GET() { @@ -14,7 +15,7 @@ export function GET() { } export async function POST({ request }: { request: Request }) { try { - const body = await request.json(); + const body = await jsonBody(request); if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80) throw new Error('Preset name must be 1–80 characters'); if (builtInPlans.some((p) => p.name === body.name.trim())) @@ -27,6 +28,7 @@ export async function POST({ request }: { request: Request }) { .run(body.name.trim(), JSON.stringify(plan)); return json({ ok: true }); } catch (error) { + if (isHttpError(error, 413)) throw error; return json({ ok: false, error: (error as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/blueprints/+server.ts b/src/routes/api/blueprints/+server.ts index daf128d..a2e8be9 100644 --- a/src/routes/api/blueprints/+server.ts +++ b/src/routes/api/blueprints/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { listBlueprints, saveBlueprint } from '$lib/server/fleet/launch-recipes.js'; export function GET() { @@ -7,11 +8,12 @@ export function GET() { export async function POST({ request }: { request: Request }) { try { - const body = await request.json(); + const body = await jsonBody(request); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection name and a launch recipe.'); return json({ blueprint: saveBlueprint(body) }, { status: 201 }); } catch (err) { + if (isHttpError(err, 413)) throw err; return json({ error: (err as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/blueprints/[id]/+server.ts b/src/routes/api/blueprints/[id]/+server.ts index f9b05d8..bb9103f 100644 --- a/src/routes/api/blueprints/[id]/+server.ts +++ b/src/routes/api/blueprints/[id]/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { deleteBlueprint, updateBlueprint } from '$lib/server/fleet/launch-recipes.js'; export function DELETE({ params }: { params: { id: string } }) { @@ -9,11 +10,12 @@ export function DELETE({ params }: { params: { id: string } }) { export async function PUT({ params, request }: { params: { id: string }; request: Request }) { try { - const body = await request.json(); + const body = await jsonBody(request); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection name and map selections.'); return json({ blueprint: updateBlueprint(params.id, body) }); } catch (err) { + if (isHttpError(err, 413)) throw err; return json({ error: (err as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/configs/inspect/+server.ts b/src/routes/api/configs/inspect/+server.ts index a10c05e..cb8d6fb 100644 --- a/src/routes/api/configs/inspect/+server.ts +++ b/src/routes/api/configs/inspect/+server.ts @@ -1,5 +1,6 @@ +import { jsonBody } from '$lib/server/request-body.js'; import { getVersionContent } from '$lib/server/fleet/scripts.js'; -import { json } from '@sveltejs/kit'; +import { isHttpError, json } from '@sveltejs/kit'; import { getServer, listServers } from '$lib/server/fleet/servers.js'; import { getBaselineForScope, @@ -90,8 +91,9 @@ export async function GET({ url }: { url: URL }) { export async function POST({ request }: { request: Request }) { let body; try { - body = await request.json(); - } catch { + body = await jsonBody(request); + } catch (cause) { + if (isHttpError(cause, 413)) throw cause; return json({ error: 'Invalid preview request.' }, { status: 400 }); } if ( diff --git a/src/routes/api/deployment-collections/+server.ts b/src/routes/api/deployment-collections/+server.ts index bb50851..c3f6f0e 100644 --- a/src/routes/api/deployment-collections/+server.ts +++ b/src/routes/api/deployment-collections/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { listCollections, queueCollection } from '$lib/server/fleet/collections.js'; import { kickProvisioning } from '$lib/server/pipeline/worker.js'; @@ -8,7 +9,7 @@ export function GET() { export async function POST({ request }: { request: Request }) { try { - const body = await request.json(); + const body = await jsonBody(request); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection preview and its server keys.'); const result = queueCollection(body); @@ -22,6 +23,7 @@ export async function POST({ request }: { request: Request }) { }); return json(result, { status: result.collectionId ? 202 : 200 }); } catch (err) { + if (isHttpError(err, 413)) throw err; return json({ error: (err as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/deployment-collections/preview/+server.ts b/src/routes/api/deployment-collections/preview/+server.ts index 6e7b422..aec0be7 100644 --- a/src/routes/api/deployment-collections/preview/+server.ts +++ b/src/routes/api/deployment-collections/preview/+server.ts @@ -1,11 +1,13 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { previewCollection } from '$lib/server/fleet/collections.js'; export async function POST({ request }: { request: Request }) { try { - const body = await request.json(); + const body = await jsonBody(request); return json(previewCollection(body?.blueprintId, body?.mode, body)); } catch (err) { + if (isHttpError(err, 413)) throw err; return json({ error: (err as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/deployments/house-party/+server.ts b/src/routes/api/deployments/house-party/+server.ts index bb11feb..34de73a 100644 --- a/src/routes/api/deployments/house-party/+server.ts +++ b/src/routes/api/deployments/house-party/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { housePartyTargets, queueHouseParty } from '$lib/server/fleet/house-party.js'; import { kick } from '$lib/server/pipeline/worker.js'; @@ -7,7 +8,10 @@ export function GET() { } export async function POST({ request }: { request: Request }) { - const body = await request.json().catch(() => null); + const body = await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return null; + }); if ( !Array.isArray(body?.servers) || !body.servers.every((name: unknown) => typeof name === 'string') diff --git a/src/routes/api/deployments/preview/+server.ts b/src/routes/api/deployments/preview/+server.ts index e896756..09963f5 100644 --- a/src/routes/api/deployments/preview/+server.ts +++ b/src/routes/api/deployments/preview/+server.ts @@ -1,9 +1,10 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { previewCreation } from '$lib/server/provisioning/index.js'; export async function POST({ request }: { request: Request }) { try { - const body = await request.json(); + const body = await jsonBody(request); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a launch selection.'); if (body.blueprintId !== undefined) @@ -12,6 +13,7 @@ export async function POST({ request }: { request: Request }) { ); return json(previewCreation(body)); } catch (err) { + if (isHttpError(err, 413)) throw err; return json({ error: (err as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/drift/+server.ts b/src/routes/api/drift/+server.ts index 79980dd..3af31e4 100644 --- a/src/routes/api/drift/+server.ts +++ b/src/routes/api/drift/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { previewDrift } from '$lib/server/fleet/drift.js'; // GET returns the current drift plan for the whole fleet without touching disk. @@ -33,7 +34,10 @@ export async function GET() { // Legacy previews remain readable. Watchdog owns all new reconciliation requests. export async function POST({ request }: { request: Request }) { - const body = await request.json().catch(() => null); + const body = await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return null; + }); if ( !body || (body.servers !== undefined && diff --git a/src/routes/api/maintenance/+server.ts b/src/routes/api/maintenance/+server.ts index bdf9d34..93b5c7c 100644 --- a/src/routes/api/maintenance/+server.ts +++ b/src/routes/api/maintenance/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { maintenanceOverview } from '$lib/server/maintenance/index.js'; import { pauseMaintenance, setMaintenancePolicy } from '$lib/server/maintenance/policies.js'; import { watchdogTick } from '$lib/server/maintenance/recovery.js'; @@ -23,7 +24,10 @@ export function GET(event?: { url: URL }) { return json(maintenanceOverview(job ? Number(job) : null)); } export async function POST({ request }: { request: Request }) { - const body = await request.json().catch(() => null); + const body = await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return null; + }); if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ error: 'Invalid request.' }, { status: 400 }); let notice: string; diff --git a/src/routes/api/maintenance/[id]/+server.ts b/src/routes/api/maintenance/[id]/+server.ts index b45a2cb..bfeb110 100644 --- a/src/routes/api/maintenance/[id]/+server.ts +++ b/src/routes/api/maintenance/[id]/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { watchdogDetail, updateWatchdogIncident } from '$lib/server/maintenance/recovery.js'; import { kick } from '$lib/server/pipeline/worker.js'; @@ -9,7 +10,10 @@ export function GET({ params }: { params: { id: string } }) { : json({ error: 'Maintenance finding not found.' }, { status: 404 }); } export async function POST({ params, request }: { params: { id: string }; request: Request }) { - const body = await request.json().catch(() => null); + const body = await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return null; + }); if (!body || !['retry', 'dismiss'].includes(body.action)) return json({ error: 'Choose retry or dismiss.' }, { status: 400 }); const result = updateWatchdogIncident(params.id, body.action); diff --git a/src/routes/api/plugins/github/import/+server.ts b/src/routes/api/plugins/github/import/+server.ts index 203bd67..0a67cd5 100644 --- a/src/routes/api/plugins/github/import/+server.ts +++ b/src/routes/api/plugins/github/import/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { importPluginRelease } from '$lib/server/fleet/plugin-releases.js'; import { GithubReleaseError } from '$lib/server/integrations/github-releases.js'; import { ScriptError } from '$lib/server/fleet/scripts.js'; @@ -6,8 +7,9 @@ import { ScriptError } from '$lib/server/fleet/scripts.js'; export async function POST({ request }: { request: Request }) { let input; try { - input = await request.json(); - } catch { + input = await jsonBody(request); + } catch (cause) { + if (isHttpError(cause, 413)) throw cause; return json({ error: 'Invalid JSON body.' }, { status: 400 }); } if (!input || typeof input !== 'object' || Array.isArray(input)) diff --git a/src/routes/api/plutonium/+server.ts b/src/routes/api/plutonium/+server.ts index 95c46e5..091b425 100644 --- a/src/routes/api/plutonium/+server.ts +++ b/src/routes/api/plutonium/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { validTimeZone } from '$lib/time-zone.js'; import { cancelMaintenance, @@ -15,8 +16,9 @@ export function GET() { export async function POST({ request }: { request: Request }) { let body; try { - body = await request.json(); - } catch { + body = await jsonBody(request); + } catch (cause) { + if (isHttpError(cause, 413)) throw cause; return json({ error: 'Invalid JSON.' }, { status: 400 }); } if (!body || typeof body !== 'object') diff --git a/src/routes/api/provisioning/+server.ts b/src/routes/api/provisioning/+server.ts index 5fb50de..75fb88c 100644 --- a/src/routes/api/provisioning/+server.ts +++ b/src/routes/api/provisioning/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { deploymentListPage } from '$lib/server/provisioning/history.js'; import { requestLifecycle } from '$lib/server/provisioning/index.js'; import { @@ -32,7 +33,10 @@ export function GET({ url }: { url: URL }) { } export async function POST({ request }: { request: Request }) { - const body = await request.json().catch(() => null); + const body = await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return null; + }); if ( !body || typeof body.server !== 'string' || diff --git a/src/routes/api/rebalancing/+server.ts b/src/routes/api/rebalancing/+server.ts index 0690b4d..a74eae1 100644 --- a/src/routes/api/rebalancing/+server.ts +++ b/src/routes/api/rebalancing/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { RebalanceError, previewRebalance, @@ -12,7 +13,7 @@ export function GET() { } export async function POST({ request }: { request: Request }) { try { - const body = await request.json(); + const body = await jsonBody(request); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new RebalanceError('Provide a rebalance action.'); if (body.action === 'preview') return json({ preview: previewRebalance(body.settings) }); @@ -26,6 +27,7 @@ export async function POST({ request }: { request: Request }) { return json({ run: stopRebalance(body.id) }); throw new RebalanceError('Choose a supported rebalance action.'); } catch (error) { + if (isHttpError(error, 413)) throw error; return json( { error: (error as Error).message }, { status: error instanceof RebalanceError ? error.status : 400 } diff --git a/src/routes/api/scripts/+server.ts b/src/routes/api/scripts/+server.ts index f5abecf..1204b86 100644 --- a/src/routes/api/scripts/+server.ts +++ b/src/routes/api/scripts/+server.ts @@ -1,3 +1,4 @@ +import { artifactFormBody } from '$lib/server/request-body.js'; import { json } from '@sveltejs/kit'; import { createScript, @@ -38,7 +39,7 @@ export async function GET({ url }: { url: URL }) { // name, scopeType, scopeValue, notes, releaseVersion, kind (defaults to 'script'), // content (file). Configs may alternatively send `text` as a form field. export async function POST({ request, url }: { request: Request; url: URL }) { - const form = await request.formData(); + const form = await artifactFormBody(request); const name = String(form.get('name') ?? '').trim(); const notes = form.get('notes') ? String(form.get('notes')) : undefined; let scopeType: ScopeType | null; diff --git a/src/routes/api/scripts/[id]/scope/+server.ts b/src/routes/api/scripts/[id]/scope/+server.ts index 7cafde2..598616f 100644 --- a/src/routes/api/scripts/[id]/scope/+server.ts +++ b/src/routes/api/scripts/[id]/scope/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { getScript, setScope, @@ -65,7 +66,10 @@ export async function POST({ params, request }: { params: { id: string }; reques const script = getScript(scriptId); if (!script) return json({ ok: false, error: 'not found' }, { status: 404 }); - const body = (await request.json().catch(() => ({}))) as { + const body = (await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return {}; + })) as { scopeType?: ScopeType | null; scopeValue?: string | null; }; diff --git a/src/routes/api/scripts/[id]/versions/+server.ts b/src/routes/api/scripts/[id]/versions/+server.ts index ad98abb..89cb283 100644 --- a/src/routes/api/scripts/[id]/versions/+server.ts +++ b/src/routes/api/scripts/[id]/versions/+server.ts @@ -1,3 +1,4 @@ +import { artifactFormBody } from '$lib/server/request-body.js'; import { json } from '@sveltejs/kit'; import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js'; import { createHash } from 'node:crypto'; @@ -28,7 +29,7 @@ export async function POST({ params, request }: { params: { id: string }; reques ); } - const form = await request.formData(); + const form = await artifactFormBody(request); const notes = form.get('notes') ? String(form.get('notes')) : undefined; const file = form.get('content'); if (!(file instanceof File)) diff --git a/src/routes/api/scripts/inspect/+server.ts b/src/routes/api/scripts/inspect/+server.ts index 76f8e7a..180c81f 100644 --- a/src/routes/api/scripts/inspect/+server.ts +++ b/src/routes/api/scripts/inspect/+server.ts @@ -1,10 +1,11 @@ +import { artifactFormBody } from '$lib/server/request-body.js'; import { json } from '@sveltejs/kit'; import { createHash } from 'node:crypto'; import { db } from '$lib/server/db/index.js'; // Read-only inspection works on HTTP fleet consoles as well as secure origins. export async function POST({ request }: { request: Request }) { - const form = await request.formData(); + const form = await artifactFormBody(request); const file = form.get('content'); if (!(file instanceof File) || file.size === 0) { return json({ error: 'Choose a non-empty script file.' }, { status: 400 }); diff --git a/src/routes/api/server-keys/+server.ts b/src/routes/api/server-keys/+server.ts index 036cfa9..2bb02c1 100644 --- a/src/routes/api/server-keys/+server.ts +++ b/src/routes/api/server-keys/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { listServerKeys, saveServerKey, removeServerKey } from '$lib/server/fleet/server-keys.js'; export function GET() { @@ -6,7 +7,10 @@ export function GET() { } export async function PUT({ request }: { request: Request }) { - const body = await request.json().catch(() => null); + const body = await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return null; + }); try { const id = saveServerKey(body?.game, body?.plutoKey, body?.label); return json({ ok: true, id }); @@ -16,7 +20,10 @@ export async function PUT({ request }: { request: Request }) { } export async function DELETE({ request }: { request: Request }) { - const body = await request.json().catch(() => null); + const body = await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return null; + }); if (typeof body?.id !== 'string' || !body.id.trim() || body.id.length > 128) return json({ error: 'Invalid key ID' }, { status: 400 }); try { diff --git a/src/routes/api/servers/+server.ts b/src/routes/api/servers/+server.ts index 2b34e3e..d8d6967 100644 --- a/src/routes/api/servers/+server.ts +++ b/src/routes/api/servers/+server.ts @@ -1,6 +1,7 @@ +import { jsonBody } from '$lib/server/request-body.js'; import { fleetSnapshot } from '$lib/server/fleet/snapshot.js'; import { kickProvisioning } from '$lib/server/pipeline/worker.js'; -import { json } from '@sveltejs/kit'; +import { isHttpError, json } from '@sveltejs/kit'; import { queueCreateServer } from '$lib/server/provisioning/index.js'; import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js'; @@ -9,7 +10,10 @@ export function GET() { } export async function POST({ request }: { request: Request }) { - const body = (await request.json().catch(() => ({}))) as { + const body = (await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return {}; + })) as { hostId?: string; libraryId?: string; image?: string; diff --git a/src/routes/api/settings/+server.ts b/src/routes/api/settings/+server.ts index a633cad..313a4fc 100644 --- a/src/routes/api/settings/+server.ts +++ b/src/routes/api/settings/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { validateFleetSettings, concurrencyKeys, retentionKeys } from '$lib/fleet-settings.js'; import { fleetSettings, saveFleetSettings } from '$lib/server/settings.js'; @@ -9,8 +10,9 @@ export function GET() { export async function PUT({ request }: { request: Request }) { let settings; try { - settings = validateFleetSettings(await request.json()); + settings = validateFleetSettings(await jsonBody(request)); } catch (error) { + if (isHttpError(error, 413)) throw error; return json({ error: (error as Error).message }, { status: 400 }); } return json(saveFleetSettings(settings)); @@ -18,7 +20,7 @@ export async function PUT({ request }: { request: Request }) { export async function PATCH({ request }: { request: Request }) { try { - const { section, values } = await request.json(); + const { section, values } = await jsonBody(request); const keys = section === 'Concurrency' ? concurrencyKeys @@ -37,6 +39,7 @@ export async function PATCH({ request }: { request: Request }) { throw new Error('Provide all settings for one section.'); return json(saveFleetSettings({ ...fleetSettings(), ...values })); } catch (error) { + if (isHttpError(error, 413)) throw error; return json({ error: (error as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/settings/retention/+server.ts b/src/routes/api/settings/retention/+server.ts index 49e9104..489929c 100644 --- a/src/routes/api/settings/retention/+server.ts +++ b/src/routes/api/settings/retention/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { retentionPreview, retentionControls, @@ -22,9 +23,10 @@ export function GET() { } export async function POST({ request }: { request: Request }) { try { - setRetentionControl(await request.json()); + setRetentionControl(await jsonBody(request)); return json({ ok: true }); } catch (error) { + if (isHttpError(error, 413)) throw error; return json({ error: (error as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/settings/retention/preview/+server.ts b/src/routes/api/settings/retention/preview/+server.ts index 080c862..2169390 100644 --- a/src/routes/api/settings/retention/preview/+server.ts +++ b/src/routes/api/settings/retention/preview/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { validateFleetSettings } from '$lib/fleet-settings.js'; import { retentionPreview, @@ -8,7 +9,7 @@ import { import { gameFileRetentionPreview } from '$lib/server/game-files/index.js'; export async function POST({ request }: { request: Request }) { try { - const settings = validateFleetSettings(await request.json()); + const settings = validateFleetSettings(await jsonBody(request)); return json( { policies: [ @@ -21,6 +22,7 @@ export async function POST({ request }: { request: Request }) { { headers: { 'Cache-Control': 'no-store' } } ); } catch (error) { + if (isHttpError(error, 413)) throw error; return json({ error: (error as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/telemetry/+server.ts b/src/routes/api/telemetry/+server.ts index 5e30a49..bf7da28 100644 --- a/src/routes/api/telemetry/+server.ts +++ b/src/routes/api/telemetry/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { parseGameEvent, type GameTelemetry } from '$lib/telemetry.js'; import { getServer } from '$lib/server/fleet/servers.js'; import { saveTelemetry } from '$lib/server/fleet/telemetry.js'; @@ -26,8 +27,9 @@ export async function POST({ request }: { request: Request }) { let value: unknown; let malformed = false; try { - value = await request.json(); - } catch { + value = await jsonBody(request); + } catch (cause) { + if (isHttpError(cause, 413)) throw cause; malformed = true; } diff --git a/src/routes/api/watchdog/+server.ts b/src/routes/api/watchdog/+server.ts index b743609..50d5e9a 100644 --- a/src/routes/api/watchdog/+server.ts +++ b/src/routes/api/watchdog/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { watchdogOverview, watchdogTick, setWatchdogEnabled } from '$lib/server/watchdog/index.js'; import { kick } from '$lib/server/pipeline/worker.js'; @@ -6,7 +7,10 @@ export function GET() { return json(watchdogOverview()); } export async function POST({ request }: { request: Request }) { - const body = await request.json().catch(() => null); + const body = await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return null; + }); if ( !body || typeof body !== 'object' || diff --git a/src/routes/api/watchdog/[id]/+server.ts b/src/routes/api/watchdog/[id]/+server.ts index 46fa721..6e75882 100644 --- a/src/routes/api/watchdog/[id]/+server.ts +++ b/src/routes/api/watchdog/[id]/+server.ts @@ -1,4 +1,5 @@ -import { json } from '@sveltejs/kit'; +import { jsonBody } from '$lib/server/request-body.js'; +import { isHttpError, json } from '@sveltejs/kit'; import { watchdogDetail, updateWatchdogIncident } from '$lib/server/watchdog/index.js'; import { kick } from '$lib/server/pipeline/worker.js'; @@ -7,7 +8,10 @@ export function GET({ params }: { params: { id: string } }) { return detail ? json(detail) : json({ error: 'Watchdog incident not found.' }, { status: 404 }); } export async function POST({ params, request }: { params: { id: string }; request: Request }) { - const body = await request.json().catch(() => null); + const body = await jsonBody(request).catch((cause) => { + if (isHttpError(cause, 413)) throw cause; + return null; + }); if (!body || !['retry', 'dismiss'].includes(body.action)) return json({ error: 'Choose retry or dismiss.' }, { status: 400 }); const result = updateWatchdogIncident(params.id, body.action); diff --git a/tests/unit/github-plugin-import.test.ts b/tests/unit/github-plugin-import.test.ts index ee318dd..cafa801 100644 --- a/tests/unit/github-plugin-import.test.ts +++ b/tests/unit/github-plugin-import.test.ts @@ -97,7 +97,7 @@ describe('GitHub release lookup', () => { normal, { ...normal, id: 201, name: 'source.zip' }, { ...normal, id: 202, name: '../evil.dll' }, - { ...normal, id: 203, size: 65 * 1024 * 1024 }, + { ...normal, id: 203, size: 32 * 1024 * 1024 + 1 }, { ...normal, id: 204, state: 'new' } ] }) diff --git a/tests/unit/request-body-routes.test.ts b/tests/unit/request-body-routes.test.ts new file mode 100644 index 0000000..b9b27e9 --- /dev/null +++ b/tests/unit/request-body-routes.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; +import { + JSON_BODY_LIMIT, + ARTIFACT_FORM_LIMIT, + BETA_REVISION_FORM_LIMIT +} from '../../src/lib/server/request-body.js'; +import { MAX_ARTIFACT_BYTES } from '../../src/lib/artifact-limits.js'; + +vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ + kick: vi.fn(), + kickProvisioning: vi.fn() +})); +vi.mock('../../src/lib/server/pipeline/beta-plan-runner.js', () => ({ plannedBetaTick: vi.fn() })); + +type Event = { request: Request; url: URL; params: { id: string } }; +const routes = import.meta.glob Promise>>( + '../../src/routes/api/**/+server.ts' +); +let fixture: Fixture; +let scriptId: number; +let betaId: number; +const token = 'ec_telemetry_0123456789abcdef0123456789abcdef'; + +beforeEach(async () => { + vi.resetModules(); + vi.stubEnv('EC_TELEMETRY_TOKEN', token); + fixture = await newFixture(); + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const { script, version } = createScript({ + name: 'HUD', + targetRelPath: 'hud.gsc', + content: Buffer.from('main(){}') + }); + scriptId = script.id; + betaId = Number( + db() + .prepare( + "INSERT INTO betas (script_id, script_version_id, trigger_kind, state) VALUES (?, ?, 'manual', 'running')" + ) + .run(script.id, version.id).lastInsertRowid + ); +}); +afterEach(async () => { + vi.unstubAllEnvs(); + await fixture.cleanup(); +}); + +function oversizedRequest(method: string, limit: number, declared: boolean) { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }; + if (declared) headers['Content-Length'] = String(limit + 1); + return new Request('http://localhost', { + method, + headers, + body: declared ? '{}' : ' '.repeat(limit + 1) + }); +} + +describe('JSON route size enforcement', () => { + it.each([ + ['autoscaling', 'POST'], + ['betas', 'POST'], + ['betas/[id]/promote', 'POST'], + ['betas/[id]/retry', 'POST'], + ['betas/[id]/test', 'POST'], + ['betas/presets', 'POST'], + ['blueprints', 'POST'], + ['blueprints/[id]', 'PUT'], + ['configs/inspect', 'POST'], + ['deployment-collections', 'POST'], + ['deployment-collections/preview', 'POST'], + ['deployments/house-party', 'POST'], + ['deployments/preview', 'POST'], + ['drift', 'POST'], + ['maintenance', 'POST'], + ['maintenance/[id]', 'POST'], + ['plugins/github/import', 'POST'], + ['plutonium', 'POST'], + ['provisioning', 'POST'], + ['rebalancing', 'POST'], + ['scripts/[id]/scope', 'POST'], + ['server-keys', 'PUT'], + ['server-keys', 'DELETE'], + ['servers', 'POST'], + ['settings', 'PUT'], + ['settings', 'PATCH'], + ['settings/retention', 'POST'], + ['settings/retention/preview', 'POST'], + ['telemetry', 'POST'], + ['watchdog', 'POST'], + ['watchdog/[id]', 'POST'] + ])( + '%s %s preserves HTTP 413 for declared and undeclared oversized bodies', + async (path, method) => { + if (path === 'betas/[id]/test') { + const { db } = await import('../../src/lib/server/db/index.js'); + db() + .prepare('INSERT INTO beta_test_plans (beta_id, plan) VALUES (?, ?)') + .run(betaId, '{}'); + } + const route = await routes[`../../src/routes/api/${path}/+server.ts`](); + for (const declared of [true, false]) { + await expect( + route[method]({ + request: oversizedRequest(method, JSON_BODY_LIMIT, declared), + url: new URL('http://localhost'), + params: { id: String(path.startsWith('betas/') ? betaId : scriptId) } + }) + ).rejects.toMatchObject({ status: 413 }); + } + } + ); + + it('retains bad-request responses for malformed JSON', async () => { + for (const path of ['servers', 'plugins/github/import', 'settings']) { + const method = path === 'settings' ? 'PUT' : 'POST'; + const route = await routes[`../../src/routes/api/${path}/+server.ts`](); + const response = await route[method]({ + request: new Request('http://localhost', { method, body: '{' }), + url: new URL('http://localhost'), + params: { id: '' } + }); + expect(response.status).toBe(400); + } + }); +}); + +describe('artifact upload route limits', () => { + it('preserves the malformed form error on beta revisions', async () => { + const { POST } = await routes['../../src/routes/api/betas/[id]/revisions/+server.ts'](); + await expect( + POST({ + request: new Request('http://localhost', { method: 'POST', body: 'invalid form' }), + url: new URL('http://localhost'), + params: { id: String(betaId) } + }) + ).rejects.toMatchObject({ + status: 400, + body: { message: 'Request body must be valid form data.' } + }); + }); + + it.each([ + 'scripts', + 'configs', + 'plugins', + 'scripts/inspect', + 'scripts/[id]/versions', + 'betas/[id]/revisions' + ])('%s rejects excessive declared bodies before creating versions', async (path) => { + const route = await routes[`../../src/routes/api/${path}/+server.ts`](); + const limit = path.startsWith('betas/') ? BETA_REVISION_FORM_LIMIT : ARTIFACT_FORM_LIMIT; + await expect( + route.POST({ + request: oversizedRequest('POST', limit, true), + url: new URL('http://localhost'), + params: { id: String(scriptId) } + }) + ).rejects.toMatchObject({ status: 413 }); + const { db } = await import('../../src/lib/server/db/index.js'); + expect(db().prepare('SELECT COUNT(*) AS count FROM script_versions').get()).toEqual({ + count: 1 + }); + }); + + it('enforces the artifact limit for direct creation and version writes as well', async () => { + const { createScript, uploadNewVersion, listVersions, getScriptByName } = + await import('../../src/lib/server/fleet/scripts.js'); + const content = Buffer.alloc(MAX_ARTIFACT_BYTES + 1); + expect(() => + createScript({ name: 'Too big', targetRelPath: 'large.dll', kind: 'plugin', content }) + ).toThrow(/32 MiB/); + expect(() => uploadNewVersion(scriptId, content)).toThrow(/32 MiB/); + expect(getScriptByName('Too big')).toBeUndefined(); + expect(listVersions(scriptId)).toHaveLength(1); + }); +}); diff --git a/tests/unit/request-body.test.ts b/tests/unit/request-body.test.ts new file mode 100644 index 0000000..2c1b807 --- /dev/null +++ b/tests/unit/request-body.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + artifactFormBody, + boundedBody, + jsonBody, + BETA_REVISION_FORM_LIMIT +} from '../../src/lib/server/request-body.js'; +import { MAX_ARTIFACT_BYTES } from '../../src/lib/artifact-limits.js'; + +function streamedRequest(chunks: Uint8Array[], headers?: HeadersInit) { + let index = 0; + const pull = vi.fn((controller: ReadableStreamDefaultController) => { + if (index === chunks.length) controller.close(); + else controller.enqueue(chunks[index++]); + }); + const cancel = vi.fn(); + const request = new Request('http://localhost', { + method: 'POST', + headers, + body: new ReadableStream({ pull, cancel }, { highWaterMark: 0 }), + duplex: 'half' + } as RequestInit); + return { request, pull, cancel }; +} + +describe('bounded request bodies', () => { + it('rejects excessive Content-Length before reading any bytes', async () => { + const { request, pull, cancel } = streamedRequest([Buffer.from('{}')], { + 'Content-Length': '11' + }); + await expect(jsonBody(request, 10)).rejects.toMatchObject({ status: 413 }); + expect(pull).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + expect(request.body?.locked).toBe(false); + }); + + it.each([undefined, '1'])( + 'counts actual bytes with Content-Length %s and cancels before the remaining chunks', + async (length) => { + const { request, pull, cancel } = streamedRequest( + [Buffer.from('{"value":'), Buffer.from('"too long"'), Buffer.from('}')], + length ? { 'Content-Length': length } : undefined + ); + await expect(jsonBody(request, 10)).rejects.toMatchObject({ status: 413 }); + expect(pull).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledOnce(); + expect(request.body?.locked).toBe(false); + } + ); + + it('accepts the exact UTF-8 byte limit across tiny chunks', async () => { + const bytes = Buffer.from('{"value":"é🍒"}'); + const chunks = Array.from(bytes, (byte) => Uint8Array.of(byte)); + await expect(jsonBody(streamedRequest(chunks).request, bytes.length)).resolves.toEqual({ + value: 'é🍒' + }); + await expect( + jsonBody(streamedRequest(chunks).request, bytes.length - 1) + ).rejects.toMatchObject({ + status: 413 + }); + }); + + it('preserves bytes across buffer blocks and a partial final block', async () => { + const bytes = Buffer.from('abc'.repeat(50_000)); + const chunks = [bytes.subarray(0, 7), bytes.subarray(7, 70_000), bytes.subarray(70_000)]; + await expect(boundedBody(streamedRequest(chunks).request, bytes.length)).resolves.toEqual( + bytes + ); + }); + + it('keeps malformed and empty JSON distinct from size failures', async () => { + for (const body of ['', '{']) { + await expect( + jsonBody(new Request('http://localhost', { method: 'POST', body })) + ).rejects.toBeInstanceOf(SyntaxError); + } + }); +}); + +describe('bounded artifact forms', () => { + it('accepts exact request limits and rejects overflow before parsing the form', async () => { + const form = new FormData(); + form.set('content', new File(['main(){}'], 'hud.gsc')); + form.set('notes', 'review'); + const encoded = new Response(form); + const headers = encoded.headers; + const bytes = new Uint8Array(await encoded.arrayBuffer()); + const request = () => + streamedRequest([bytes.subarray(0, 5), bytes.subarray(5)], headers).request; + const parsed = await artifactFormBody(request(), bytes.length); + expect(parsed.get('notes')).toBe('review'); + expect(await (parsed.get('content') as File).text()).toBe('main(){}'); + await expect(artifactFormBody(request(), bytes.length - 1)).rejects.toMatchObject({ + status: 413 + }); + await expect( + artifactFormBody(streamedRequest([Buffer.alloc(11)], headers).request, 10) + ).rejects.toMatchObject({ status: 413 }); + }); + + it('accepts a 32 MiB file plus multipart overhead', async () => { + const form = new FormData(); + form.set('content', new File([new Uint8Array(MAX_ARTIFACT_BYTES)], 'plugin.dll')); + const parsed = await artifactFormBody( + new Request('http://localhost', { method: 'POST', body: form }) + ); + expect((parsed.get('content') as File).size).toBe(MAX_ARTIFACT_BYTES); + }); + + it.each(['file', 'text'])( + 'rejects an oversized %s artifact within the overall form limit', + async (kind) => { + const form = new FormData(); + if (kind === 'file') + form.set( + 'content', + new File([new Uint8Array(MAX_ARTIFACT_BYTES + 1)], 'plugin.dll') + ); + else form.set('text', 'é'.repeat(MAX_ARTIFACT_BYTES / 2 + 1)); + await expect( + artifactFormBody(new Request('http://localhost', { method: 'POST', body: form })) + ).rejects.toMatchObject({ + status: 413, + body: { message: 'Artifacts must not exceed 32 MiB.' } + }); + } + ); + + it('bounds the combined beta revision body even with no declared length', async () => { + const chunk = new Uint8Array(1024 * 1024); + const { request, cancel } = streamedRequest(Array(67).fill(chunk), { + 'Content-Type': 'multipart/form-data; boundary=test' + }); + await expect(artifactFormBody(request, BETA_REVISION_FORM_LIMIT)).rejects.toMatchObject({ + status: 413 + }); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('reports malformed multipart bodies as bad requests', async () => { + const request = new Request('http://localhost', { method: 'POST', body: 'invalid form' }); + await expect(artifactFormBody(request)).rejects.toMatchObject({ status: 400 }); + }); +});