Worker: renew the session cookie while in use, capped at 24h from the solve - #80
Conversation
Deploying amnesia-site with
|
| Latest commit: |
64e73a1
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://3f1f9e18.amnesia-site.pages.dev |
| Branch Preview URL: | https://claude-get-78-merged-8d048f.amnesia-site.pages.dev |
… solve A valid cookie with less than half of SESSION_TTL left is re-issued on the same response. The cookie now signs the solve time with the expiry (start.exp.sig), and renewal never passes SESSION_MAX_AGE from that solve. Cookies in the old exp.sig form still verify until they expire and are not renewed.
20fa2bd to
fdd4674
Compare
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: request changes — remove the generated test-local documentation before merging. rule:reads-as-generated
Blocking — generated-writing hygiene
test/worker.test.mjs:177
/** A cookie whose session started \age` seconds ago and has `left` seconds to run. */`
This JSDoc documents a helper local to this test block. First-party strict mode treats a docstring on a test-local helper as a generated-writing tell. It makes the test addition read as patch narration rather than following the surrounding test idiom.
const agedCookie = async (age, left) => {
The relevant required CI checks are green at fdd4674b90a72db2feed7b950af797b004d0e6e6. I reviewed the cookie signing, legacy-cookie compatibility, renewal/cap conditions, cache-path header propagation, and the added regression coverage. I did not run the local test suite.
|
The failing The fix is on the host, not in this PR: a polkit rule letting the runner user start |
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (independent second read; the gating review is posted separately).
Verdict: no blocking issues at 66ee9df. Renewal, the SESSION_MAX_AGE cap and legacy-cookie handling do what the body says, and each reachable boundary has a test that fails without the change. Two low, non-blocking notes below.
Note on head: the dispatch ticket pinned 20fa2bd, but the live head is 66ee9dfee29235585112590663a49900435f048a (fdd4674b plus 66ee9dfe "test: drop the doc comment on a test-local helper"). This read covers the live head.
Findings
Low: a non-numeric SESSION_MAX_AGE breaks every fresh solve, not only renewal
worker/src/index.js:65
const maxAge = parseInt(env.SESSION_MAX_AGE || "86400", 10);worker/src/index.js (buildCookie)
const exp = Math.min(now + ttl, start + maxAge);Scenario: the operator sets SESSION_MAX_AGE = "one day" (or any value that doesn't start with a digit). parseInt gives NaN, and Math.min(now + ttl, NaN) is NaN, so the solve path mints <COOKIE_NAME>=<now>.NaN.<sig>; Max-Age=NaN. verifySession rejects <now>.NaN because of the ^(\d+\.)?\d+$ guard, every search then returns 401, and the SPA loops on Turnstile. SESSION_TTL already had this problem, but this PR adds a second var that can break the solve path, which previously only depended on SESSION_TTL. Only the operator can trigger it, so it doesn't block.
Suggested fix:
const maxAge = parseInt(env.SESSION_MAX_AGE, 10) || 86400;Low (docs): the wrangler.toml comment still describes the old cookie
worker/wrangler.toml:28-29 (context lines in this hunk)
# 6h (was 30min): the cookie is just an HMAC-signed expiry — nothing
# user-identifying — so a longer life cuts re-challenges at negligible risk.The cookie now signs start.exp. docs/privacy-model.md was updated to say "two timestamps" and this comment wasn't.
Suggested fix:
# 6h (was 30min): the cookie is an HMAC over the solve time and expiry,
# nothing user-identifying, so a longer life cuts re-challenges at negligible risk.Boundary ledger (rebuilt from the diff)
| Predicate | Input | Fixed code | Pinned by |
|---|---|---|---|
needsRenewal: exp - now < ttl / 2 |
899 s left, ttl 1800 | renews | "less than half its lifetime left" (all three paths) |
| same | 1800 s left | no set-cookie |
"a valid cookie is proxied…" (validCookie(1800)) |
| same, ttl 21600 | 10799 / 10801 s left | renews / doesn't | "renewal follows SESSION_TTL". Timing only moves 10801 toward 10800, which still gives false, so it isn't flaky |
needsRenewal: exp < start + maxAge |
at the cap | valid, not re-issued | second half of the SESSION_MAX_AGE test. Without the clause, 360 s < 900 would re-issue, so the assertion can fail |
buildCookie: Math.min(now + ttl, start + maxAge) |
23.9 h in | exp = start + 86400, Max-Age=3xx |
SESSION_MAX_AGE test, first half |
buildCookie: start === undefined |
Turnstile solve | start = now, full ttl | existing solve tests plus fuzz sign/verify |
start === null (legacy) |
exp.sig |
verifies, not renewed | legacy test. Without the guard, exp < null + maxAge is still false, so behaviour is the same. The test pins the behaviour that matters |
verifySession regex |
start.exp signed with a legacy HMAC(exp) |
401 | "legacy signature spliced" |
verifySession regex |
<n>.NaN, a.b.c, empty |
false | covered by fuzz forgery contract + regex |
| edge-cache hit | renewing cookie | hit also carries set-cookie |
"an edge-cache hit also renews" (originCalls().length === 1) |
edge-cache put |
renewing request | stored response has only content-type / cache-control, so no cookie reaches the shared cache |
checked by reading worker/src/index.js around the caches.default.put call |
| bridge-IP path | any cookie | authorized before cookie check, no renewal | unchanged path |
Every new assertion fails when renewal is removed or when the cap clause is dropped. I found no assertion that holds either way, apart from the start === null guard noted in the ledger.
Checks against the PR body
- Renewal on search, autocomplete,
/sessionand edge-cache hits: confirmed in the diff and tests. - Signature binds
start.exp, and payload is digits-only: confirmed (verifySessionregex plus HMAC over the full payload). - Legacy
exp.sigstill verifies and is not renewed: confirmed. Max-Ageequals the remaining life:Max-Age=${exp - now}, confirmed.- "56 pass": the CI
npm testlog shows# tests 56 / # pass 56 / # fail 0. The failingkickcheck is the review-dispatch systemd hook (Interactive authentication required), not this code.
What's good
Keeping start inside the signed payload lets the 24 h cap hold across renewals with no server state, and the digits-only regex closes the legacy/new splice. The cached copy at the edge stays free of the cookie.
SECOND READ: READY
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: APPROVED — no blocking issues found at live head 3e12ba13680fe5d86e53ad7e3bc0fc9d766c9b7f.
I reviewed the session-renewal implementation, its boundary and legacy-cookie tests, the fuzz contract, configuration, and accompanying public documentation. Required CI is passing at this head. This re-review also confirms the prior blocking test-local helper doc comment is absent from the live change.
What's good: renewal retains a signed solve time, caps expiry at SESSION_MAX_AGE, retains verification for legacy cookies without renewing them, and exercises the authorization paths plus the edge-cache path.
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (independent second read; the gating review is posted separately).
Verdict: No blocking issues at 3e12ba1. The renewal logic, the cap, and the legacy-cookie path do what the body says, and every reachable boundary I rebuilt from the diff has a test that pins it.
Boundary ledger (rebuilt from the diff)
| Predicate | Input | Behaviour at head | Pinned by |
|---|---|---|---|
needsRenewal: exp - now < ttl / 2 (worker/src/index.js:313) |
just above half-life (10801 s of 21600) | not renewed | renewal follows SESSION_TTL (fresh) |
| same | just below half-life (10799 s) | renewed, Max-Age=21600 |
renewal follows SESSION_TTL (ageing) |
| same | full TTL left (1800 of 1800) | not renewed | a valid cookie is proxied… (changed assertion) |
| same | 899 of 1800 on /search, /autocompleter, /session |
renewed, no siteverify call | …less than half its lifetime left is renewed… |
exp < start + maxAge |
one step under the cap (start = now-86040, 60 s left) | renewed to exactly start + 86400, Max-Age≈360 |
renewal keeps the solve time… (first call) |
| same | at the cap (exp == start + maxAge, under half-life) |
valid, not renewed | renewal keeps the solve time… (second call). This one can fail: without the clause the cookie is re-issued and set-cookie is non-null |
start === null (legacy exp.sig) |
60 s left | verifies, not renewed | a cookie from before renewal (exp.sig)… |
verifySession regex ^(\d+\.)?\d+$ + HMAC over the full payload |
legacy sig spliced under start. |
401 | a legacy signature spliced onto a start time → 401 |
cache-hit branch (...setCookie at :196) |
ageing cookie, cached query | renewed on the hit, origin called once | an edge-cache hit also renews an ageing cookie |
buildCookie Max-Age=${exp - now} |
renewal near the cap | never 0 or negative: renewal needs old exp >= now and exp < start + maxAge, so the new exp > now |
the 23.9 h test's Max-Age=3[0-9]{2} |
| trusted-bridge path | bridge IP with a cookie | cookie not read, not renewed (same as before) | unchanged path |
I also checked the forgery surface. A legacy message (exp, digits only) and a new one (start.exp, contains a dot) can never be the same HMAC input, so a signature can't be reused across the two formats. The tightened regex also rejects the 123abc-style payloads that parseInt used to accept before the HMAC compare.
Findings
info: worker/src/index.js:65: an unparseable SESSION_MAX_AGE breaks every new cookie, including fresh solves
const maxAge = parseInt(env.SESSION_MAX_AGE || "86400", 10);const exp = Math.min(now + ttl, start + maxAge);Scenario: an operator sets SESSION_MAX_AGE = "one day". maxAge becomes NaN, and Math.min(now + ttl, NaN) is NaN, so the Turnstile path issues now.NaN.<sig>; Max-Age=NaN. verifySession then rejects it through the new regex. It fails closed, so the bot gate is not weakened. The cost is that every search needs a fresh solve. The shipped wrangler.toml value is fine and SESSION_TTL already had the same property, so this isn't blocking. It would matter only if the var gets edited by hand.
Suggested fix (optional):
const maxAge = parseInt(env.SESSION_MAX_AGE, 10) || 86400;Claims checked against the diff
- Renewal on search, autocomplete,
/sessionand edge-cache hits: yes, it goes through the sharedsetCookiespread at:166,:196and:217. - Legacy
exp.sigstill verifies and is not renewed: yes (sessionTimesreturnsstart: null,needsRenewalreturns false). Max-Agereflects the actual remaining life: yes (exp - now).- Fuzz contract (solve time carried over,
exp <= start + maxAge, the cookie verifies): present infuzz/session.fuzz.js. The target is guarded bystart + maxAge > now, so it never builds an already-expired cookie.
CI: Validate HTML and Scripts (runs npm test), CodeQL and Scorecard pass. kick fails with Failed to start review-dispatch.service: Interactive authentication required. That is the review-dispatch runner, not this change.
What's good: the renewal decision is a pure helper next to the cookie code, the cap is enforced in buildCookie itself rather than only at the call site, and the at-cap test would catch the most likely regression (renewing forever).
SECOND READ: READY
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (independent second read; the gating review is posted separately).
Verdict: no blocking issues at 616faba. The renewal is bounded, the signature now covers both timestamps, and every new boundary I rebuilt from the diff is pinned by a test that fails without the change.
What I read
The full diff of worker/src/index.js, worker/wrangler.toml, fuzz/session.fuzz.js and test/worker.test.mjs, plus the doc and CHANGELOG hunks. For context I also read the whole handler at head (the edge-cache and origin paths that spread setCookie) and the test helpers (validCookie, cookieValue, nowS). CI at head is green, including Validate HTML and Scripts, which runs npm test, csp-hashes --check and check-readme-links. I did not run anything locally.
Boundary ledger (rebuilt from the diff)
| Predicate | Input | Fixed code | Pinned by |
|---|---|---|---|
verifySession /^(\d+\.)?\d+$/ |
legacy exp.sig |
accepted, HMAC over exp |
"a cookie from before renewal (exp.sig) still verifies" (fails if the ? is dropped) |
| same | start.exp with a legacy sig spliced in |
HMAC over start.exp does not match, so 401 |
"a legacy signature spliced onto a start time → 401" (fails if only exp is signed) |
| same | empty payload, non-digits, 3+ fields | rejected before parse | regex; fuzz forgery contract |
needsRenewal start === null |
legacy cookie | no renewal | legacy test (see note 2) |
exp - now < ttl / 2 |
10799 / 10801 left, TTL 21600 | renew / no renew | "renewal follows SESSION_TTL" |
| same | 899 left, TTL 1800, on /search, /autocompleter, /session |
renewed, Max-Age=1800 exactly |
"less than half its lifetime left is renewed" |
| same | 1800 left | no set-cookie |
the edited "valid cookie is proxied" test |
exp < start + maxAge |
exp equal to the cap | valid, not renewed again | second half of the "keeps the solve time" test |
Math.min(now + ttl, start + maxAge) |
23.9 h into the session | capped at start + 86400, Max-Age about 360 |
"keeps the solve time" (Max-Age=3[0-9]{2}, exp ≈ start + 86400) |
start === undefined in buildCookie |
fresh Turnstile solve | start = now |
existing token-path tests; fuzz sign/verify contract |
| other code path: edge-cache hit | ageing cookie | ...setCookie on the hit response |
"an edge-cache hit also renews" (asserts x-amnesia-cache: hit and one origin call) |
| other code path: bridge IP | any | !authorized && skips the cookie branch, no renewal |
not tested; the behavior is unchanged from before |
Max-Age=${exp - now} can't be zero or negative on renewal: verifySession guarantees exp >= now, and needsRenewal requires exp < start + maxAge, so the new min(...) is always greater than now.
Test assertions per variant
Four of the six new tests fail without the change: renewal on three paths, the edge-cache hit, the cap, and TTL-relative renewal. The legacy and spliced-signature tests also pass on the old code by design. They are compatibility and forgery guards for the new verifySession, and each fails under a specific wrong implementation (named in the ledger). Timing is safe: as the clock moves forward, the remaining life only shrinks. So the 899/10799 cases stay below half, and 10801 would need two seconds of wall time before it crosses over.
Notes (non-blocking)
- low,
worker/src/index.js:65,const maxAge = parseInt(env.SESSION_MAX_AGE || "86400", 10);. A non-numeric value makesMath.min(now + ttl, NaN)returnNaN. Every fresh cookie then becomesstart.NaN.sig, the new regex rejects it, and every search needs a solve."0"is truthy, givesmaxAge = 0, and soMax-Age=0on every solve. Reaching this needs an operator misconfiguration, andSESSION_TTLalready has the same unvalidated parse, so I'm noting it rather than blocking on it. A possible guard:// suggested const maxAgeRaw = parseInt(env.SESSION_MAX_AGE || "86400", 10); const maxAge = Number.isFinite(maxAgeRaw) && maxAgeRaw >= ttl ? maxAgeRaw : 86400;
- info,
worker/src/index.js:311,if (start === null) return false;. No test pins this guard in isolation, because without itexp < null + maxAgecoerces toexp < 86400, which is false for any real epoch. The legacy behavior is still correct and tested. The guard just states the intent. - info, PR body: "it buys up to
SESSION_MAX_AGEwhen the cookie is used at least once everySESSION_TTL". The actual condition is a request during the last half of each cookie's life. A visitor who searches at 0.5 h and again at 6.2 h has a gap under 6 h but still meets a solve. As an upper bound on what one solve buys, the statement holds. The README, architecture doc and CHANGELOG wording that ships is accurate.
What's good
The two formats can't be confused with each other, because each signs exactly the payload string and the new one contains a dot. The renewed cookie carries the signed solve time forward, so replaying an old cookie can never get past start + maxAge. The fuzz contract checks the cap and the carry-over across arbitrary start/maxAge pairs.
SECOND READ: READY
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (independent second read; the gating review is posted separately).
Verdict: no blocking issues at 64e73a1. Renewal, the cap and the legacy path do what the body says, and the new tests fail without the change they pin.
Since my last read (616faba), the only change at this head is merge commit 64e73a1, which brings in main (#82, sponsors). The PR's own diff (8 files, +145/−16) is unchanged. I re-read the whole PR diff anyway.
Boundaries rebuilt from the diff
| Row | Input | What the code does | Pinned by |
|---|---|---|---|
verifySession /^(\d+\.)?\d+$/ |
exp.sig (legacy) |
verifies, HMAC over exp |
"a cookie from before renewal (exp.sig)…" |
| same | start.exp.sig |
verifies, HMAC over start.exp |
renewal tests + fuzz |
| same | legacy sig spliced under start. |
401: HMAC(start.exp) ≠ HMAC(exp) |
"a legacy signature spliced onto a start time → 401" |
| same | a.b.c.sig, .sig, non-digits |
rejected by the regex before any HMAC | fuzz (no forge on arbitrary bytes) |
exp - now < ttl / 2 |
899 / 1800 left, ttl 1800 | renew / no renew | renewal test (all 3 paths) + "a valid cookie is proxied…" (now validCookie(1800)) |
| same | 10799 / 10801 left, ttl 21600 | renew / no renew | "renewal follows SESSION_TTL…" (tells apart a fixed 900 s threshold) |
exp < start + maxAge |
23.9 h in, 60 s left | renewed only to start + 86400, Max-Age=3xx |
cap test, first half |
| same | exp == start + maxAge |
valid (200), not renewed | cap test, second half (fails if the cap check is dropped from needsRenewal, because 360 s left < 900) |
start === null |
legacy cookie with 60 s left | not renewed | legacy test |
buildCookie start === undefined |
token path | start = now |
existing token tests (Max-Age=1800, Max-Age=21600) |
Max-Age=${exp - now} on renewal |
any accepted renewal | ≥ 1: verifySession gives exp ≥ now and needsRenewal gives start + maxAge > exp |
cap test |
| other paths | edge-cache hit | ...setCookie on the hit response |
"an edge-cache hit also renews…" (asserts x-amnesia-cache: hit and one origin call) |
| other paths | bridge bypass | cookie branch skipped (!authorized), no renewal |
existing bridge test (set-cookie null) |
I also checked that the renewed Set-Cookie never reaches the edge cache. The caches.default.put response builds its own headers (content-type, cache-control only) at worker/src/index.js:225-233, so a cookie is never stored under a query key.
I read each new test's assertions against the change. Every renewal, cap and TTL-threshold assertion fails on the base code or with the matching guard removed. The legacy and splice tests also pass on the base code. That is expected, because they are backward-compat and anti-forgery guards, not fix pins.
Notes (non-blocking)
Low: worker/src/index.js, buildCookie. The cap also applies to fresh solves, not only to renewals:
if (start === undefined) start = now;
const exp = Math.min(now + ttl, start + maxAge);If an operator ever sets SESSION_MAX_AGE below SESSION_TTL, a fresh solve gets a cookie of maxAge seconds, and "0" gives Max-Age=0. A non-numeric value makes maxAge NaN, so exp becomes NaN and no cookie verifies. The shipped values (21600 / 86400) can't reach this, and SESSION_TTL already has the same parseInt shape, so this is informational only. If you want the header's description ("cap on a renewed session") to be literally true:
// suggested
const fresh = start === undefined;
if (fresh) start = now;
const exp = fresh ? now + ttl : Math.min(now + ttl, start + maxAge);Info: PR body, Verification. It says "The 6 new or changed tests" and then lists seven, which is right: six new tests plus the changed validCookie(1800) test. Only the count is off.
What's good
- The legacy and new payload formats can't collide as HMAC inputs (
\d+vs\d+\.\d+), so the dual-format verify adds no forgery surface. The splice test pins the one cross-format attack that matters. - Both halves of
needsRenewalhave a test that fails without them. - Docs, CHANGELOG, README and
wrangler.tomlmatch the code. - CI is green at this head (CodeQL, Validate HTML and Scripts, Scorecard, Pages).
Read: the full PR diff, worker/src/index.js and test/worker.test.mjs at head for context (cache path, test helpers), and fuzz/session.fuzz.js. I didn't run the tests; CI is the test signal.
SECOND READ: READY
What
Before: the Worker issued the session cookie once, at the Turnstile solve, for
SESSION_TTL(6 h in production). A visitor still searching when it ran out got a 401 and waited on a fresh solve.After: a valid cookie with less than half of
SESSION_TTLleft is re-issued on the same response (search, autocomplete,/session, and edge-cache hits too). The renewal never passesSESSION_MAX_AGE(new var, 24 h) from the solve that started the session, so one solve still buys a bounded session.How:
start.exp.sig, with the HMAC overstart.exp.startis the solve time and carries over on renewal.verifySessionaccepts only digits in the payload.exp.sig) still verify until they expire and are not renewed, so the deploy doesn't force anyone into an extra solve.needsRenewalandsessionTimesare new helpers.buildCookietakes an optionalstartandmaxAgeand setsMax-Ageto the actual remaining life.SESSION_MAX_AGE = "86400"inworker/wrangler.toml. README,docs/architecture.md,docs/privacy-model.mdand the CHANGELOG are updated.Why
This removes the Turnstile solve at the 6-hour mark for people who keep a tab open and keep searching. The trade-off is on the bot gate: a solve used to buy exactly one
SESSION_TTL, and now it buys up toSESSION_MAX_AGEwhen the cookie is used at least once everySESSION_TTL. Zone rate limits still apply to/search.Verification
npm test: 56 pass. The 7 new or changed tests cover: renewal below half-life on all three authorized paths with no siteverify call; a cookie above half-life is not re-issued; an edge-cache hit renews; renewal followsSESSION_TTL; a 23.9 h session renews only to the 24 h mark and is then not renewed again; a legacyexp.sigcookie still works and isn't renewed; and a legacy signature spliced under a start time gets a 401.fuzz/session.fuzz.jsgains a contract: a renewed cookie keeps its solve time, never expires afterstart + maxAge, and verifies.npm run fuzzran 98,512 inputs in 46 s with no failures.node scripts/csp-hashes.mjs --checkandnode scripts/check-readme-links.mjspass. The page is unchanged.Checklist