|
|
# 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).
|