1
design cookie preferences
fen edited this page 2026-09-09 18:52:14 -05:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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