You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vibed/pages/docs/plans/2026-07-12-pages-design.md

17 KiB

pages.elijah.run — Design & Security Analysis

Date: 2026-07-12 Status: Approved for v0 implementation (branch pages-v0)

1. What it is

A micro static-site host running entirely on Cloudflare:

  • Anyone can browse pages.elijah.run and see all live pages.
  • Authenticated + authorized users upload a single .html file or a .zip of static assets; the content is served at pages.elijah.run/<slug>/.
  • Admins can delete any page and grant/revoke upload permission for users.
  • Auth is GitHub OAuth (free, no infrastructure of our own).

Components: one Cloudflare Worker (Rust → wasm via workers-rs), one R2 bucket (page content), one D1 database (users, sessions, page metadata).

2. Security analysis

2.1 Hosted pages share the origin with the management app (critical)

User-uploaded HTML served from pages.elijah.run/<slug>/ is same-origin with the upload/admin UI at pages.elijah.run/. Untrusted JS on the same origin could otherwise make credentialed fetch("/api/…") calls (delete pages, admin actions) — the session cookie would be attached automatically.

Mitigations (layered):

  1. CSP sandbox — primary. Every response serving user content carries Content-Security-Policy: sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock — critically without allow-same-origin. The browser gives the document an opaque origin: no cookies are sent on its requests, no localStorage/IndexedDB, no same-origin DOM access. The session cookie is therefore never attached to requests initiated by hosted pages.
  2. HttpOnly session cookie — not readable from JS even if sandbox were bypassed.
  3. Origin check on all mutating API routesPOST/PATCH/DELETE require an Origin header equal to the configured BASE_URL. Sandboxed documents send Origin: null.

Accepted residual risks: hosted pages can still phish (render a fake login form), display arbitrary content under the elijah.run name, and cannot use cookies/localStorage themselves (documented limitation of hosting here). A stronger isolation (per-page subdomains, <slug>.pages.elijah.run) is the v1 upgrade path if these limits bite.

Trust escape hatch (v0.1, pages-shqc). Some hosted pages are legitimately local-first apps (a habit tracker, a notes app) that need localStorage to be useful at all — the CSP sandbox above denies that categorically. An admin can mark either a specific page (pages.trusted) or a user (users.trusted) as trusted; a page's effective trust is pages.trusted OR <owner's> users.trusted, computed at serve time via a D1 join (src/db.rs::PageRow::effective_trust). A trusted page is served with every header above except Content-Security-Policy — no sandbox, so the document keeps its real pages.elijah.run origin.

This is a deliberate, explicit downgrade of the §2.1 mitigation for that one page, not a separate isolation mechanism: a trusted page runs same-origin with the management app and therefore can act as any visitor of pages.elijah.run, including a logged-in admin — it can issue credentialed fetch("/api/…") calls (delete pages, change permissions, grant/revoke trust on other pages) using whatever session cookie happens to be present in the browser that loads it. Trusting a page is therefore equivalent to giving its author the same access as anyone who ever visits that page while signed in. Only content authored by trusted humans should ever be marked trusted — never grant it to satisfy a feature request from an unknown or unvetted uploader. Marking a user trusted (rather than a single page) extends this to every page they upload, present and future, so it should be granted even more sparingly than a single-page grant.

2.2 Malicious archives

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

2.3 Slug (page name) abuse

  • Strict slug grammar: ^[a-z0-9][a-z0-9-]{0,62}$ (lowercase alphanumerics and hyphens, no leading hyphen, ≤ 63 chars). Uploaded names are lower-cased and validated, never "cleaned up" silently beyond that.
  • Reserved names rejected to avoid route collisions and confusion: api, auth, admin, assets, static, login, logout, oauth, favicon.ico, robots.txt, sitemap.xml, index.html, www, health, status, well-known, cdn-cgi (Cloudflare-internal path prefix).
  • Uniqueness enforced by the D1 primary key; duplicate → HTTP 409 with a JSON error so the UI can prompt for a new name.

2.4 Authentication & sessions

  • GitHub OAuth (authorization-code flow). state is 32 random bytes, stored in a short-lived (10 min) HttpOnly cookie and compared at the callback — CSRF protection for the OAuth flow itself.
  • Sessions: 32 random bytes (hex) issued as __Host-session cookie with HttpOnly; Secure; SameSite=Lax; Path=/; only the SHA-256 hash is stored in D1 (a D1 read leak doesn't yield usable tokens). 30-day expiry, checked server-side; logout deletes the row.
  • CSRF on the API: SameSite=Lax (cross-site POSTs don't carry the cookie) + the Origin check from §2.1.
  • Authorization model: users.can_upload gates uploads (default off — new OAuth users can log in but not publish until an admin approves); users.is_admin gates delete-any-page and user management; page owners can delete their own pages. Admin bootstrap: the ADMIN_GITHUB_LOGIN Worker var — a user logging in with that GitHub login is auto-promoted.

2.5 Resource abuse / denial-of-wallet

  • 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 × 48 MiB per user, and uploads are gated by an admin-approved allowlist anyway).

2.6 Content-type handling

  • MIME types derived only from file extension via an allowlist map; unknown extensions are served as application/octet-stream.
  • X-Content-Type-Options: nosniff on every response.
  • Only .html/.htm are ever served as text/html.

2.7 Secrets

  • GITHUB_CLIENT_SECRET, SESSION_SECRET (reserved for future signed values) via wrangler secret put; never in the repo. Local dev uses .dev.vars / .env (git-ignored).

3. Implementation issues identified

Issue Resolution
SQLx doesn't run on Workers (repo default data pattern) Use worker crate D1 bindings directly; documented deviation in pages/CLAUDE.md.
workers-rs JS types are !Send, axum wants Send handlers Follow the proven quotesdb pattern: build the axum Router per-request inside #[event(fetch)], share state via Arc with unsafe impl Send + Sync (sound: Workers wasm is single-threaded).
Multipart parsing in wasm is fiddly Upload is a raw request body: POST /api/pages?name=<slug> with Content-Type: application/zip or text/html. The browser fetch(file) does this natively.
Workers free tier: 10 ms CPU/request Deflate of a 10 MiB zip can exceed this. Limits are conservative; if uploads hit CPU kills, the $5/mo paid tier (30 s CPU) is the fallback. Serving is I/O-bound and unaffected.
Entropy on wasm32 getrandom with the wasm_js feature (same versions/pattern as quotesdb, which builds without extra RUSTFLAGS).
Repo default frontend is Yew/Trunk v0 ships a single static index.html (inline CSS/JS) embedded with include_str! — one file, no extra build pipeline. Yew rewrite is possible later without API changes. Documented deviation.
Native testability of a wasm-only worker Crate is cdylib + rlib; all policy logic (slugs, zip validation, MIME, cookies/auth helpers) lives in pure modules compiled and unit-tested natively; the wasm-only glue (cfg(target_arch = "wasm32")) stays thin.
Deploying module Workers via Terraform provider Same as quotesdb: wrangler deploy owns the script + routes; OpenTofu owns R2 bucket, D1 database, DNS, rate limits.

4. Design

4.1 HTTP surface

Method Path Auth Description
GET / Management UI (embedded static HTML)
GET /auth/login Redirect to GitHub OAuth (sets state cookie)
GET /auth/callback Exchange code, upsert user, create session
POST /auth/logout session Delete session, clear cookie
GET /api/me session Current user (login, can_upload, is_admin)
GET /api/pages List pages (slug, owner login, created_at, size)
POST /api/pages?name=<slug> can_upload Raw body upload (html or zip). 201 / 400 / 401 / 403 / 409 / 413
PUT /api/pages/:slug can_upload and (owner or admin) Replace an existing page's content in place — same raw body shape as POST. 200 / 400 / 401 / 403 / 404 / 413 (pages-ytau, §4.6b)
DELETE /api/pages/:slug owner or admin Delete page (D1 row + R2 objects)
GET /api/users admin List users
PATCH /api/users/:id admin Set can_upload and/or trusted (JSON body {"can_upload"?: bool, "trusted"?: bool}, at least one required — pages-shqc)
PATCH /api/pages/:slug admin Set a page's own trusted flag (JSON body {"trusted": bool} — pages-shqc)
GET /:slug 301 → /:slug/
GET /:slug/ Serve index.html of the page
GET /:slug/*path Serve asset; directory-style paths fall back to <path>/index.html; 404 JSON-less page otherwise

Single-file .html uploads are stored as <slug>/index.html.

4.2 D1 schema

CREATE TABLE users (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  github_id  INTEGER NOT NULL UNIQUE,
  login      TEXT    NOT NULL,
  avatar_url TEXT,
  can_upload INTEGER NOT NULL DEFAULT 0,
  is_admin   INTEGER NOT NULL DEFAULT 0,
  trusted    INTEGER NOT NULL DEFAULT 0,  -- pages-shqc, §2.1 addendum
  created_at TEXT    NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE sessions (
  token_hash TEXT PRIMARY KEY,          -- SHA-256 hex of the cookie token
  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at TEXT    NOT NULL,
  created_at TEXT    NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE pages (
  slug        TEXT PRIMARY KEY,
  owner_id    INTEGER NOT NULL REFERENCES users(id),
  file_count  INTEGER NOT NULL,
  total_bytes INTEGER NOT NULL,
  trusted     INTEGER NOT NULL DEFAULT 0,  -- pages-shqc, §2.1 addendum
  created_at  TEXT    NOT NULL DEFAULT (datetime('now'))
);

trusted on both tables was added by pages-shqc (§2.1 addendum) after v0 shipped — migrations/0002-trust.sql is the incremental ALTER TABLE for the already-live production database; migrations/schema.sql above already has both columns for fresh installs.

4.3 R2 layout

<slug>/<relative-path> (e.g. my-project/index.html, my-project/assets/app.js). Deleting a page lists by prefix <slug>/ and deletes all objects.

4.4 Bindings & configuration (wrangler.toml)

  • D1 binding DB, R2 binding PAGES.
  • Vars: BASE_URL (https://pages.elijah.run), GITHUB_CLIENT_ID, ADMIN_GITHUB_LOGIN.
  • Secrets: GITHUB_CLIENT_SECRET.

4.5 Crate layout (single crate pages, cdylib + rlib)

src/
├── lib.rs          # module wiring + #[event(fetch)] (wasm-only)
├── core/
│   ├── mod.rs
│   ├── slug.rs     # slug validation + reserved names        [native tests]
│   ├── mime.rs     # extension → MIME allowlist              [native tests]
│   ├── upload.rs   # zip/html validation & manifest building [native tests]
│   └── headers.rs  # security header constants (CSP sandbox…)[native tests]
├── auth.rs         # OAuth URLs, state/session token & cookie helpers
│                   # (pure parts native-tested; GitHub calls wasm-only)
├── db.rs           # D1 queries (wasm-only)
├── routes/         # axum handlers (wasm-only)
│   ├── mod.rs      # router construction
│   ├── auth.rs
│   ├── api.rs
│   └── serve.rs    # page serving from R2
└── tests.rs        # unit test module wiring
static/index.html   # management UI (embedded via include_str!)
migrations/schema.sql
infra/*.tf

4.6 Upload pipeline

  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 ≤ 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).
  6. Insert D1 pages row (PK conflict → 409, nothing written to R2 yet).
  7. put each file to R2 under <slug>/…; on failure, best-effort cleanup of written objects and the D1 row → 500.

4.6b Replace pipeline (pages-ytau, added after v0)

PUT /api/pages/:slug replaces an existing page's content, retiring the §5 scope cut "no page overwrite/update: delete + re-upload".

  1. Auth → 401; can_upload → 403 (a revoked uploader cannot keep pushing content to pages they already own); Origin check → 403.
  2. get_page(slug) → 404 if unknown; owner-or-admin → 403 (same rule as delete: core::origin::can_replace).
  3. Size caps / Content-Type / manifest validation — identical to §4.6 steps 4-5, shared with the create path via routes::api::manifest_from_request.
  4. List the existing <slug>/ R2 keys, then put every new manifest entry, then delete the old keys the new manifest didn't overwrite.
  5. update_page_stats refreshes file_count/total_bytes only — owner_id, trusted, and created_at are preserved, so an admin-granted trust survives a re-upload and an admin replacing another user's page does not take ownership of it.

Unlike §4.6, this is not rollback-able: R2 has no multi-object transaction and the previous bytes are not kept anywhere, so once the first put lands the page is already a mix of versions. The ordering above bounds the damage — writing new content before deleting anything means a viewer during the window sees old-or-new per file rather than a blank page, and stale-key deletion is best-effort after the new content is durably written (a failure there only orphans an object, which the next replace or a delete sweeps up). A put failure mid-way returns 500 and leaves D1 untouched; the fix is to re-upload, which is idempotent. See routes::api::replace_page.

Content is cached for 5 minutes (§4.7's Cache-Control), so a replaced page can serve its previous version to a cached client for up to that long.

4.7 Serving pipeline

GET /:slug[/*path] → reserved/invalid slug → 404. Otherwise a D1 get_page lookup happens before any R2 access (pages-shqc): unknown slug → 404 without touching R2; a known page's effective_trust (§2.1 addendum) is read once and used for every response built from that point on. Then R2 get on <slug>/<path or index.html>; on miss try <…>/index.html. Response headers: correct MIME, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cache-Control: public, max-age=300, and Access-Control-Allow-Origin: * (assets must load for opaque-origin documents) on every page response; the §2.1 CSP sandbox is added on top of those four unless the page is effectively trusted (§2.1 addendum), in which case it's omitted entirely. The redirect (/:slug/:slug/) and the shared 404 body always carry the sandbox unconditionally — no D1 lookup happens before either of those two short-circuit paths. See src/core/headers.rs::user_content_headers (pure, natively-tested) and src/routes/serve.rs (the wasm-only caller).

5. v0 scope cuts (explicit)

  • No page overwrite/update: delete + re-upload. Retired by pages-ytauPUT /api/pages/:slug replaces content in place (§4.6b).
  • No per-page subdomains (see §2.1 residual risk).
  • No OpenAPI spec generation (repo TODO is unresolved; endpoints documented here and in README).
  • No Yew frontend; single embedded HTML file.
  • Admin UI is minimal (user list with checkbox, delete buttons on pages).