feat(pages): raise upload limits for large wasm (per-file 16 MiB, total 48 MiB)

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 <noreply@anthropic.com>
main
Elijah Voigt 1 day ago
parent f42ae56bb4
commit f7fa11071d

@ -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.

@ -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`, - **Path traversal:** zip entry names like `../../x`, absolute paths (`/x`,
`C:\x`), backslash separators, or `..` segments anywhere → reject upload. `C:\x`), backslash separators, or `..` segments anywhere → reject upload.
- **Zip bombs:** enforce **before/while extracting**: max compressed upload - **Zip bombs:** enforce **before/while extracting**: max compressed upload
10 MiB (checked via `Content-Length` and while buffering), max total 24 MiB (checked via `Content-Length` and while buffering), max total
uncompressed 25 MiB, max 5 MiB per file, max 300 entries, max path length uncompressed 48 MiB, max 16 MiB per file, max 300 entries, max path length
180 bytes, max depth 10. Counting is done on decompressed bytes as they 180 bytes, max depth 10 (raised from 10/25/5 MiB to accommodate large wasm
stream out, aborting on breach — never trust zip headers alone. 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 - **Weird entries:** directories are skipped, symlinks don't exist for us
(we never touch a filesystem — entries go straight to R2), duplicate entry (we never touch a filesystem — entries go straight to R2), duplicate entry
names → reject, nested zips are stored as opaque files, never recursed. 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). - Upload size caps (§2.2) and a **per-user page quota** (default 50).
- Cloudflare rate-limiting rule on `POST /api/*` and `/auth/*` (infra). - 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 - 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). admin-approved allowlist anyway).
### 2.6 Content-type handling ### 2.6 Content-type handling
@ -248,7 +250,7 @@ infra/*.tf
1. Auth middleware resolves session → user; reject 401/403 (`can_upload`). 1. Auth middleware resolves session → user; reject 401/403 (`can_upload`).
2. Origin check (mutating request). 2. Origin check (mutating request).
3. Validate `?name=` slug (grammar + reserved + quota) → 400/409. 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`. 5. `Content-Type: text/html` → manifest of one file `index.html`.
`application/zip` → extract with the §2.2 limits → manifest of `application/zip` → extract with the §2.2 limits → manifest of
`(path, bytes)`; must contain a root `index.html` (else 400). `(path, bytes)`; must contain a root `index.html` (else 400).

@ -42,15 +42,19 @@ pub struct UploadLimits {
} }
impl Default for UploadLimits { impl Default for UploadLimits {
/// Production limits from design §2.2: 10 MiB max compressed upload, /// Production limits (design §2.2, raised to accommodate large wasm
/// 25 MiB max total uncompressed, 5 MiB max per file, 300 max entries, /// binaries): 24 MiB max compressed upload, 48 MiB max total uncompressed,
/// 180-byte max path length, depth 10. /// 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 { fn default() -> Self {
const MIB: u64 = 1024 * 1024; const MIB: u64 = 1024 * 1024;
Self { Self {
max_compressed: (10 * MIB) as usize, max_compressed: (24 * MIB) as usize,
max_uncompressed_total: 25 * MIB, max_uncompressed_total: 48 * MIB,
max_file: 5 * MIB, max_file: 16 * MIB,
max_entries: 300, max_entries: 300,
max_path_len: 180, max_path_len: 180,
max_depth: 10, max_depth: 10,

@ -35,12 +35,12 @@ use crate::core::slug::validate_slug;
use crate::core::upload::{build_manifest, detect_kind, ManifestEntry, UploadLimits}; use crate::core::upload::{build_manifest, detect_kind, ManifestEntry, UploadLimits};
use crate::db::DbError; 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 /// Mirrors [`UploadLimits::default`]'s `max_compressed`; kept as a separate
/// constant (rather than reading it off a constructed `UploadLimits`) so the /// constant (rather than reading it off a constructed `UploadLimits`) so the
/// `Content-Length` pre-check in [`create_page`] doesn't need to allocate /// `Content-Length` pre-check in [`create_page`] doesn't need to allocate
/// one before the request body is available. /// 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": "<message>"}` JSON response with the given status. /// Builds a `{"error": "<message>"}` JSON response with the given status.
fn json_error(status: StatusCode, message: impl Into<String>) -> Response { fn json_error(status: StatusCode, message: impl Into<String>) -> Response {

@ -866,9 +866,9 @@
nameInput.addEventListener("input", validateNameField); 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. // 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 () { uploadSubmit.addEventListener("click", function () {
var replacing = uploadMode === "replace"; var replacing = uploadMode === "replace";
@ -876,7 +876,7 @@
if (!currentFile || !isValidSlug(name)) return; if (!currentFile || !isValidSlug(name)) return;
var file = currentFile; var file = currentFile;
if (file.size > MAX_UPLOAD_BYTES) { 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; return;
} }
uploadSubmit.disabled = true; uploadSubmit.disabled = true;

Loading…
Cancel
Save