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

345 lines
19 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 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, `<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 32 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 — worst-case memory is compressed + total uncompressed, still well
under the Worker's ~128 MiB isolate 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) |
| POST | `/api/pages/:slug/aliases` | can_upload **and** (owner or admin) | Add an alias to a page (JSON body `{"alias": "<name>"}`). 201 / 400 / 401 / 403 / 404 / 409 (pages-9lsk) |
| DELETE | `/api/pages/:slug/aliases/:alias` | owner or admin | Remove an alias. 200 / 401 / 403 / 404 (pages-9lsk) |
| GET | `/:slug` | | 301 → `/:slug/` |
| GET | `/:slug/` | | Serve `index.html` of the page (or, if `:slug` is an alias, its canonical page's) |
| 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`.
**Aliases (pages-9lsk).** A page keeps one canonical `slug` and may have up to
10 removable **aliases** — extra long-lived names in the *same* URL namespace.
A name is unique across **both** `pages.slug` and `aliases.alias`; the
handlers enforce that cross-table rule (SQLite can't). Serving is
**serve-direct**: on a canonical page miss the serve path falls back to
alias resolution and serves the canonical page's R2 content under its
`<slug>/` prefix — the alias URL shows the content with no redirect and no
second copy. A real page always wins over an alias of the same name (the
fallback only runs on a page miss). Deleting a page removes its aliases;
`GET /api/pages` returns each page's `aliases[]`.
### 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'))
);
CREATE TABLE aliases ( -- pages-9lsk
alias TEXT PRIMARY KEY, -- alternate name, unique across slugs+aliases (enforced in handlers)
slug TEXT NOT NULL REFERENCES pages(slug), -- canonical page it serves
owner_id INTEGER NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_aliases_slug ON aliases(slug);
```
`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. The `aliases` table was added by
pages-9lsk — `migrations/0003-aliases.sql` is its incremental (idempotent,
`CREATE TABLE IF NOT EXISTS`) migration. `migrations/schema.sql` already has
everything 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. Aliases add **no** R2 objects — an alias request serves
the canonical page's existing `<slug>/…` objects directly (pages-9lsk).
### 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-ytau**
`PUT /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).