Compare commits
2
Commits
v0.3.0
...
07950e27ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07950e27ac | ||
|
|
4f8c5b0c63 |
@@ -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_<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).
|
||||
|
||||
### 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_<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 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_<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).
|
||||
@@ -40,6 +40,12 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, "invalid expires_in")
|
||||
return
|
||||
}
|
||||
// #60: clamp at the API boundary like the pastes API does —
|
||||
// reject zero/negative and durations past the 1-year UI cap.
|
||||
if !store.ValidExpiry(d) {
|
||||
writeErr(w, 400, "expires_in must be between 1 minute and 1 year")
|
||||
return
|
||||
}
|
||||
t := now + int64(d.Seconds())
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user