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.runand see all live pages. - Authenticated + authorized users upload a single
.htmlfile or a.zipof static assets; the content is served atpages.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):
- 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 withoutallow-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. - HttpOnly session cookie — not readable from JS even if sandbox were bypassed.
- Origin check on all mutating API routes —
POST/PATCH/DELETErequire anOriginheader equal to the configuredBASE_URL. Sandboxed documents sendOrigin: 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-Lengthand 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).
stateis 32 random bytes, stored in a short-lived (10 min)HttpOnlycookie and compared at the callback — CSRF protection for the OAuth flow itself. - Sessions: 32 random bytes (hex) issued as
__Host-sessioncookie withHttpOnly; 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_uploadgates uploads (default off — new OAuth users can log in but not publish until an admin approves);users.is_admingates delete-any-page and user management; page owners can delete their own pages. Admin bootstrap: theADMIN_GITHUB_LOGINWorker 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: nosniffon every response.- Only
.html/.htmare ever served astext/html.
2.7 Secrets
GITHUB_CLIENT_SECRET,SESSION_SECRET(reserved for future signed values) viawrangler 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 bindingPAGES. - 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
- Auth middleware resolves session → user; reject 401/403 (
can_upload). - Origin check (mutating request).
- Validate
?name=slug (grammar + reserved + quota) → 400/409. - Enforce
Content-Length≤ 24 MiB, buffer body with a hard cap → 413. Content-Type: text/html→ manifest of one fileindex.html.application/zip→ extract with the §2.2 limits → manifest of(path, bytes); must contain a rootindex.html(else 400).- Insert D1
pagesrow (PK conflict → 409, nothing written to R2 yet). puteach 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".
- Auth → 401;
can_upload→ 403 (a revoked uploader cannot keep pushing content to pages they already own); Origin check → 403. get_page(slug)→ 404 if unknown; owner-or-admin → 403 (same rule as delete:core::origin::can_replace).- Size caps /
Content-Type/ manifest validation — identical to §4.6 steps 4-5, shared with the create path viaroutes::api::manifest_from_request. - List the existing
<slug>/R2 keys, thenputevery new manifest entry, then delete the old keys the new manifest didn't overwrite. update_page_statsrefreshesfile_count/total_bytesonly —owner_id,trusted, andcreated_atare 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-ytau —PUT /api/pages/:slugreplaces 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).