From f7fa11071d08215e57149bace2223d006fe40bca Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Tue, 21 Jul 2026 09:36:56 -0700 Subject: [PATCH] feat(pages): raise upload limits for large wasm (per-file 16 MiB, total 48 MiB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5 MiB per-file cap rejected wasm binaries larger than that ("exceeds the maximum per-file size of 5242880 bytes"). Raise the coherent limit set to fit large wasm while staying well under the Worker's ~128 MiB memory ceiling (the body is buffered and decompressed in memory): - max_file: 5 -> 16 MiB - max_uncompressed_total: 25 -> 48 MiB - max_compressed / MAX_UPLOAD_BYTES / axum body cap: 10 -> 24 MiB - max_entries / max_path_len / max_depth: unchanged Updates UploadLimits::default, routes::api::MAX_UPLOAD_BYTES (the DefaultBodyLimit layer follows it), the frontend client-side pre-check and message, and the design doc §2.2 numbers. Bean: pages-ufk2. Co-Authored-By: Claude Opus 4.8 --- ...d-limits-per-file-16-mib-total-48-mib-b.md | 27 +++++++++++++++++++ pages/docs/plans/2026-07-12-pages-design.md | 14 +++++----- pages/src/core/upload.rs | 16 ++++++----- pages/src/routes/api.rs | 4 +-- pages/static/index.html | 6 ++--- 5 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 pages/.beans/pages-ufk2--raise-upload-limits-per-file-16-mib-total-48-mib-b.md diff --git a/pages/.beans/pages-ufk2--raise-upload-limits-per-file-16-mib-total-48-mib-b.md b/pages/.beans/pages-ufk2--raise-upload-limits-per-file-16-mib-total-48-mib-b.md new file mode 100644 index 0000000..18ced3c --- /dev/null +++ b/pages/.beans/pages-ufk2--raise-upload-limits-per-file-16-mib-total-48-mib-b.md @@ -0,0 +1,27 @@ +--- +# pages-ufk2 +title: 'Raise upload limits: per-file 16 MiB, total 48 MiB, body 24 MiB' +status: completed +type: task +priority: high +created_at: 2026-07-21T16:35:01Z +updated_at: 2026-07-21T16:36:48Z +--- + +User's wasm binary (bin_bg.wasm) exceeds the 5 MiB per-file cap ("exceeds the maximum per-file size of 5242880 bytes"). Moderate bump chosen to fit large wasm while staying well under the Worker's ~128 MiB memory ceiling (body is buffered + decompressed in memory). + +New coherent limit set: +- max_file: 5 -> 16 MiB +- max_uncompressed_total: 25 -> 48 MiB +- max_compressed (== MAX_UPLOAD_BYTES / body cap): 10 -> 24 MiB +- max_entries / max_path_len / max_depth: unchanged + +Spots: +- core/upload.rs UploadLimits::default() (values + doc comment) +- routes/api.rs MAX_UPLOAD_BYTES const (+ comment); DefaultBodyLimit in routes/mod.rs follows it automatically +- static/index.html client-side MAX_UPLOAD_BYTES pre-check (line ~871) + +Then: validate (all six), commit, push, deploy. + +## Summary of Changes +Raised default upload limits to fit large wasm: max_file 5 to 16 MiB, max_uncompressed_total 25 to 48 MiB, max_compressed / MAX_UPLOAD_BYTES / axum body cap 10 to 24 MiB (max_entries/path_len/depth unchanged). Updated UploadLimits::default, routes::api::MAX_UPLOAD_BYTES, the frontend client-side pre-check + message, and design doc §2.2 numbers. Still well under the Worker ~128 MiB memory ceiling. Validation green: 196 unit + 20 doctests. diff --git a/pages/docs/plans/2026-07-12-pages-design.md b/pages/docs/plans/2026-07-12-pages-design.md index ab84a3c..501440d 100644 --- a/pages/docs/plans/2026-07-12-pages-design.md +++ b/pages/docs/plans/2026-07-12-pages-design.md @@ -74,10 +74,12 @@ so it should be granted even more sparingly than a single-page grant. - **Path traversal:** zip entry names like `../../x`, absolute paths (`/x`, `C:\x`), backslash separators, or `..` segments anywhere → reject upload. - **Zip bombs:** enforce **before/while extracting**: max compressed upload - 10 MiB (checked via `Content-Length` and while buffering), max total - uncompressed 25 MiB, max 5 MiB per file, max 300 entries, max path length - 180 bytes, max depth 10. Counting is done on decompressed bytes as they - stream out, aborting on breach — never trust zip headers alone. + 24 MiB (checked via `Content-Length` and while buffering), max total + uncompressed 48 MiB, max 16 MiB per file, max 300 entries, max path length + 180 bytes, max depth 10 (raised from 10/25/5 MiB to accommodate large wasm + binaries, still well under the Worker's ~128 MiB memory ceiling). Counting + is done on decompressed bytes as they stream out, aborting on breach — + never trust zip headers alone. - **Weird entries:** directories are skipped, symlinks don't exist for us (we never touch a filesystem — entries go straight to R2), duplicate entry names → reject, nested zips are stored as opaque files, never recursed. @@ -116,7 +118,7 @@ so it should be granted even more sparingly than a single-page grant. - Upload size caps (§2.2) and a **per-user page quota** (default 50). - Cloudflare rate-limiting rule on `POST /api/*` and `/auth/*` (infra). - R2/D1 are pay-per-use with generous free tiers; caps above keep worst-case - storage bounded (50 pages × 25 MiB per user, and uploads are gated by an + storage bounded (50 pages × 48 MiB per user, and uploads are gated by an admin-approved allowlist anyway). ### 2.6 Content-type handling @@ -248,7 +250,7 @@ infra/*.tf 1. Auth middleware resolves session → user; reject 401/403 (`can_upload`). 2. Origin check (mutating request). 3. Validate `?name=` slug (grammar + reserved + quota) → 400/409. -4. Enforce `Content-Length` ≤ 10 MiB, buffer body with a hard cap → 413. +4. Enforce `Content-Length` ≤ 24 MiB, buffer body with a hard cap → 413. 5. `Content-Type: text/html` → manifest of one file `index.html`. `application/zip` → extract with the §2.2 limits → manifest of `(path, bytes)`; must contain a root `index.html` (else 400). diff --git a/pages/src/core/upload.rs b/pages/src/core/upload.rs index 538fc90..2410057 100644 --- a/pages/src/core/upload.rs +++ b/pages/src/core/upload.rs @@ -42,15 +42,19 @@ pub struct UploadLimits { } impl Default for UploadLimits { - /// Production limits from design §2.2: 10 MiB max compressed upload, - /// 25 MiB max total uncompressed, 5 MiB max per file, 300 max entries, - /// 180-byte max path length, depth 10. + /// Production limits (design §2.2, raised to accommodate large wasm + /// binaries): 24 MiB max compressed upload, 48 MiB max total uncompressed, + /// 16 MiB max per file, 300 max entries, 180-byte max path length, + /// depth 10. The sizes stay well under the Worker's ~128 MiB memory + /// ceiling — the body is buffered and decompressed in memory — and + /// `max_compressed` must stay in lockstep with + /// [`MAX_UPLOAD_BYTES`](crate::routes::api::MAX_UPLOAD_BYTES). fn default() -> Self { const MIB: u64 = 1024 * 1024; Self { - max_compressed: (10 * MIB) as usize, - max_uncompressed_total: 25 * MIB, - max_file: 5 * MIB, + max_compressed: (24 * MIB) as usize, + max_uncompressed_total: 48 * MIB, + max_file: 16 * MIB, max_entries: 300, max_path_len: 180, max_depth: 10, diff --git a/pages/src/routes/api.rs b/pages/src/routes/api.rs index b82aaca..2300fd0 100644 --- a/pages/src/routes/api.rs +++ b/pages/src/routes/api.rs @@ -35,12 +35,12 @@ use crate::core::slug::validate_slug; use crate::core::upload::{build_manifest, detect_kind, ManifestEntry, UploadLimits}; use crate::db::DbError; -/// Maximum accepted raw upload size — 10 MiB (design §2.2, §4.6 step 4). +/// Maximum accepted raw upload size — 24 MiB (design §2.2, §4.6 step 4). /// Mirrors [`UploadLimits::default`]'s `max_compressed`; kept as a separate /// constant (rather than reading it off a constructed `UploadLimits`) so the /// `Content-Length` pre-check in [`create_page`] doesn't need to allocate /// one before the request body is available. -pub(crate) const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024; +pub(crate) const MAX_UPLOAD_BYTES: u64 = 24 * 1024 * 1024; /// Builds a `{"error": ""}` JSON response with the given status. fn json_error(status: StatusCode, message: impl Into) -> Response { diff --git a/pages/static/index.html b/pages/static/index.html index 72aa875..b3aa60b 100644 --- a/pages/static/index.html +++ b/pages/static/index.html @@ -866,9 +866,9 @@ nameInput.addEventListener("input", validateNameField); - // Mirrors the server's 10 MiB compressed-upload cap (design §2.2) so an + // Mirrors the server's 24 MiB compressed-upload cap (design §2.2) so an // oversize file gets a friendly message instead of a raw HTTP 413. - var MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + var MAX_UPLOAD_BYTES = 24 * 1024 * 1024; uploadSubmit.addEventListener("click", function () { var replacing = uploadMode === "replace"; @@ -876,7 +876,7 @@ if (!currentFile || !isValidSlug(name)) return; var file = currentFile; if (file.size > MAX_UPLOAD_BYTES) { - showBanner(uploadBanner, "error", "File is too large — the limit is 10 MiB."); + showBanner(uploadBanner, "error", "File is too large — the limit is 24 MiB."); return; } uploadSubmit.disabled = true;