# 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//`. - 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//` 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 routes** — `POST`/`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, `.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 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 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. - **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 × 25 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=` 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=` | can_upload | Raw body upload (html or zip). 201 / 400 / 401 / 403 / 409 / 413 | | 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 `/index.html`; 404 JSON-less page otherwise | Single-file `.html` uploads are stored as `/index.html`. ### 4.2 D1 schema ```sql 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 `/` (e.g. `my-project/index.html`, `my-project/assets/app.js`). Deleting a page lists by prefix `/` 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` ≤ 10 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 `/…`; on failure, best-effort cleanup of written objects and the D1 row → 500. ### 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 `/`; 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. - 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).