From 5a2aa0f96dd34cfd1d2fb3153a559fc8fa868f50 Mon Sep 17 00:00:00 2001 From: poslop Date: Wed, 9 Sep 2026 09:13:07 -0500 Subject: [PATCH] docs: design note for cookie-based preferences and access keys (#30) --- docs/design/cookie-preferences.md | 160 ++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/design/cookie-preferences.md diff --git a/docs/design/cookie-preferences.md b/docs/design/cookie-preferences.md new file mode 100644 index 0000000..74eeae4 --- /dev/null +++ b/docs/design/cookie-preferences.md @@ -0,0 +1,160 @@ +# Design: Cookie-Based Preferences and Access Keys (#30) + +Status: design note — no implementation yet. +Related: #37 (vwr viewer cookie), #34 (HMAC unlock cookie), #26 (creator auto-unlock), #36 (settings gear). + +## Current cookie surface + +| Cookie | Purpose | Lifetime | Flags today | +|---|---|---|---| +| `vwr` | Anonymous viewer id; scopes `/mine` history and burn-after-N per-viewer dedupe; client-sent `vwr` also authorizes delete | 1 year | `HttpOnly`, `SameSite=Lax`, `Path=/` | +| `pw_` | Per-paste password unlock token = HMAC(paste id, PALETTE_UNLOCK_SECRET) | 1 hour | `HttpOnly`, `SameSite=Lax`, `Path=/` | +| `tok_` | One-time deletion-token handoff after create | 60 s | `HttpOnly`, `SameSite=Lax`, `Path=/` | + +The access-key feature is an extension of the `pw_` pattern, not a new mechanism. + +## Part 1: Preference storage + +### What settings + +Only settings the *creator* sets when writing a paste, so the "new paste" form +can pre-fill them: + +- Default language (`lang`) +- Default expiry (`expires_in` / custom expiry) +- Burn-after-N-reads default +- Password-protect-by-default toggle (checkbox pre-checked; the password itself is never stored) +- Default visibility of the "raw" link, if such a toggle exists +- Collapsed/expanded state of the settings gear panel itself + +Never stored in cookies: passwords, access keys for pastes the user hasn't +unlocked, deletion tokens (beyond the existing 60 s `tok_` handoff), anything +typed into the paste body or title fields (existing rule: auto-detect must not +overwrite user-typed content). + +### One cookie, not many + +A single `prefs` cookie holding a compact JSON object: + +``` +prefs={"lang":"go","exp":"1h","burn":0,"pw":1} +``` + +- One cookie avoids the browser per-domain cookie count (typically 50+ per + domain; Chrome 180) eating the budget that per-paste access-key cookies need. +- Per-paste cookies (`pw_`) are inherently name-per-paste and cannot be + consolidated — that's the constraint that makes a single `prefs` cookie + mandatory rather than stylistic. + +### Size limits + +- RFC 6265: user agents SHOULD support at least 4096 bytes per cookie. Keep + `prefs` under 256 bytes of JSON — it holds a handful of short enum values. +- Server behavior: if the cookie is present but oversized/invalid JSON, ignore + it silently and serve defaults. Never reject a request over a bad preference + cookie. +- Validate on the server (allowlist of known values); a cookie is untrusted + input like any header. + +### Flags + +`HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000; Secure` (Secure once the +prod instance serves HTTPS — it will, behind the letsencrypt IngressRoute; +dev on plain HTTP needs the flag conditional on config). + +Preferences are not sensitive, but `HttpOnly` costs nothing and keeps script +from mutating them; `SameSite=Lax` matches the existing cookies. + +## Part 2: Access-key cookies + +### Goal + +"Remember unlocked pastes on this browser" — after entering a password (or +after creating a private paste), subsequent visits skip the unlock form. This +extends `pw_` from a 1-hour session convenience to a durable capability. + +### Design: extend `pw_`, don't invent a new scheme + +The token is already HMAC(paste id, PALETTE_UNLOCK_SECRET) — unforgeable and +per-paste (fix for the #34 bypass). Changes: + +1. **Opt-in checkbox on the unlock form** ("remember on this browser") and a + matching checkbox/note at creation time. Default OFF. Non-consenting + visitors keep the current 1-hour cookie. +2. **Extended lifetime** when opted in: `Max-Age = min(paste expiry, 90 days)`. + The cookie must never outlive the paste — derive the cap from the paste's + `ExpiresAt` at unlock time. Burn-after-N pastes: cap short (e.g. 24 h), + since the paste may burn at any read. +3. **Name collision**: paste ids are fixed-length server-generated, so + `pw_` names stay bounded (~40 bytes each). With the 50-cookies-per- + domain budget, cap remembered pastes at ~30: when minting the 31st, drop + the oldest expired-paste cookies server-side (server knows which ids are + expired/deleted; send expired `Set-Cookie` with `Max-Age=0` to reclaim). +4. **Delete authorization interplay**: today a client-sent `vwr` matching the + paste's ViewerID authorizes delete. Access-key cookies grant *read* + capability only. Do not let a `pw_` cookie authorize deletion — that + would mean cookie theft escalates from "read a paste" to "destroy it". + Delete stays bound to `vwr` or the deletion token. + +### Scoping + +- Keep `Path=/` (paste URLs are `/{id}` at the root; per-paste `Path=/{id}` + would work but saves nothing and complicates cleanup). +- Per-paste scope via the cookie *name* is the existing, tested pattern — + no shared "access key ring" cookie. A consolidated `keys` cookie would + mean one stolen cookie exposes every remembered paste at once. + +## Part 3: Security considerations (honest accounting) + +- **XSS exfiltration**: `HttpOnly` prevents JS from *reading* the cookies, but + not from *using* them — an XSS payload can simply `fetch('/')` and + exfiltrate the content through the page the cookie unlocks. HttpOnly raises + the bar (drive-by script can't dump the jar to an attacker server in one + request), it does not make access-key cookies safe. This is a real + limitation, not a solved problem. Mitigations in order of value: + 1. Fix the stored-XSS class at the source — #34 already allowlisted + content-types on `/raw`; the standing debt items (CSP, X-Frame-Options, + Referrer-Policy) directly reduce cookie-use exfiltration and should land + before or with this feature. + 2. Keep access-key cookies opt-in, so the blast radius is bounded to users + who accepted the tradeoff. +- **Cookie theft = paste access**: anyone holding `pw_` can read that + paste until the cookie or paste expires, from any machine. That is inherent + to capability cookies. Consequences accepted deliberately: pastes here are + ephemeral (1 min–1 yr expiry), passwords are low-stakes share convenience, + and there are no user accounts to compromise. Document this in the UI copy + ("stores unlock access on this browser"). +- **Shared machines**: a remembered cookie defeats the password for the next + user of the browser. The opt-in checkbox with plain-language copy is the + mitigation; do not default it on. +- **Cookie tossing / fixation**: a subdomain attacker could try to force + cookies; palette is a single host, no untrusted subdomains. `SameSite=Lax` + blocks cross-site attachment of the cookies on form posts to unlock + endpoints. +- **Multi-instance / secret rotation**: tokens are HMACs under + `PALETTE_UNLOCK_SECRET`; rotating the secret silently invalidates all + remembered cookies (acceptable — next visit re-prompts). Both dev and prod + k3s instances need the same secret only if sharing a domain, which they do + not. +- **Preferences cookie**: not security-sensitive, but still validate/allowlist + server-side to avoid it becoming an injection sink into templates. + +## Recommendation + +Implement in two small, separately reviewable pieces: + +1. **`prefs` cookie** (do first, low risk): single JSON cookie < 256 bytes, + server-validated allowlist, `HttpOnly; SameSite=Lax; Max-Age=1y`, drives + only form pre-fill. Ship with #36's settings gear. +2. **Extended `pw_` opt-in** (second, security-sensitive): opt-in checkbox, + Max-Age capped by paste expiry (90-day ceiling, 24 h for burn pastes), + oldest-cookie eviction at ~30 pastes, no delete authorization from access + cookies, and land the CSP/X-Frame-Options hardening debt from #34 in the + same or preceding change. UI copy must disclose that the cookie preserves + paste access on the browser. + +Rejected alternatives: single consolidated access-key cookie (aggregate theft +risk, and the per-domain cookie-count argument cuts the other way for keys — +consolidation maximizes what one stolen cookie unlocks); localStorage for +preferences (XSS-readable, no benefit over HttpOnly cookies here); server-side +accounts/session table (out of scope — Palette is deliberately anonymous). -- 2.54.0