Files
palette/docs/design/cookie-preferences.md
T
poslop 07950e27ac
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
docs: design note for cookie-based preferences and access keys (#30)
2026-09-09 09:13:07 -05:00

7.9 KiB
Raw Blame History

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

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_<id> Per-paste password unlock token = HMAC(paste id, PALETTE_UNLOCK_SECRET) 1 hour HttpOnly, SameSite=Lax, Path=/
tok_<id> One-time deletion-token handoff after create 60 s HttpOnly, SameSite=Lax, Path=/

The access-key feature is an extension of the pw_<id> 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).

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_<id>) 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_<id> from a 1-hour session convenience to a durable capability.

Design: extend pw_<id>, 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_<id> 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_<id> 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('/<paste-id>') 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_<id> 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 min1 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_<id> 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).