Skip to content

Worker: renew the session cookie while in use, capped at 24h from the solve - #80

Merged
askalf merged 5 commits into
mainfrom
claude/get-78-merged-8d048f
Sep 25, 2026
Merged

askalf merged 5 commits into
mainfrom
claude/get-78-merged-8d048f

Conversation

@askalf

@askalf askalf commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

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_TTL left is re-issued on the same response (search, autocomplete, /session, and edge-cache hits too). The renewal never passes SESSION_MAX_AGE (new var, 24 h) from the solve that started the session, so one solve still buys a bounded session.

How:

  • The cookie value is now start.exp.sig, with the HMAC over start.exp. start is the solve time and carries over on renewal. verifySession accepts only digits in the payload.
  • Cookies already in browsers (exp.sig) still verify until they expire and are not renewed, so the deploy doesn't force anyone into an extra solve.
  • needsRenewal and sessionTimes are new helpers. buildCookie takes an optional start and maxAge and sets Max-Age to the actual remaining life.
  • SESSION_MAX_AGE = "86400" in worker/wrangler.toml. README, docs/architecture.md, docs/privacy-model.md and 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 to SESSION_MAX_AGE when the cookie is used at least once every SESSION_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 follows SESSION_TTL; a 23.9 h session renews only to the 24 h mark and is then not renewed again; a legacy exp.sig cookie still works and isn't renewed; and a legacy signature spliced under a start time gets a 401.
  • fuzz/session.fuzz.js gains a contract: a renewed cookie keeps its solve time, never expires after start + maxAge, and verifies. npm run fuzz ran 98,512 inputs in 46 s with no failures.
  • node scripts/csp-hashes.mjs --check and node scripts/check-readme-links.mjs pass. The page is unchanged.

Checklist

  • Tested locally
  • No tracking or analytics added
  • Privacy implications considered (the cookie now also holds the solve time, which is set per session and not per person; the server still keeps no session table)

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Deploying amnesia-site with  Cloudflare Pages  Cloudflare Pages

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

View logs

@github-actions github-actions Bot added documentation Documentation improvements worker Cloudflare worker fuzz Fuzzing and ClusterFuzzLite size/M 50-199 hand-written lines labels Sep 25, 2026
… 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.
@askalf
askalf force-pushed the claude/get-78-merged-8d048f branch from 20fa2bd to fdd4674 Compare September 25, 2026 02:10

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

askalf commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner Author

The failing kick check isn't this PR's. It comes from review-kick.yml (#81, now on main), which runs on the [self-hosted, amnesia] runner. The unit check passed, so review-dispatch.service is on that host, but systemctl start failed with Interactive authentication required: the runner's user has no polkit grant to start the unit.

The fix is on the host, not in this PR: a polkit rule letting the runner user start review-dispatch.service and create the transient review-dispatch-requeue.service. The check isn't one of the required ones, so it doesn't block the merge. Nothing to re-run until the host grant is in place.

@sprayberry-secondread sprayberry-secondread left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, /session and edge-cache hits: confirmed in the diff and tests.
  • Signature binds start.exp, and payload is digits-only: confirmed (verifySession regex plus HMAC over the full payload).
  • Legacy exp.sig still verifies and is not renewed: confirmed.
  • Max-Age equals the remaining life: Max-Age=${exp - now}, confirmed.
  • "56 pass": the CI npm test log shows # tests 56 / # pass 56 / # fail 0. The failing kick check 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 sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sprayberry-secondread left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, /session and edge-cache hits: yes, it goes through the shared setCookie spread at :166, :196 and :217.
  • Legacy exp.sig still verifies and is not renewed: yes (sessionTimes returns start: null, needsRenewal returns false).
  • Max-Age reflects the actual remaining life: yes (exp - now).
  • Fuzz contract (solve time carried over, exp <= start + maxAge, the cookie verifies): present in fuzz/session.fuzz.js. The target is guarded by start + 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 sprayberry-secondread left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. low, worker/src/index.js:65, const maxAge = parseInt(env.SESSION_MAX_AGE || "86400", 10);. A non-numeric value makes Math.min(now + ttl, NaN) return NaN. Every fresh cookie then becomes start.NaN.sig, the new regex rejects it, and every search needs a solve. "0" is truthy, gives maxAge = 0, and so Max-Age=0 on every solve. Reaching this needs an operator misconfiguration, and SESSION_TTL already 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;
  2. info, worker/src/index.js:311, if (start === null) return false;. No test pins this guard in isolation, because without it exp < null + maxAge coerces to exp < 86400, which is false for any real epoch. The legacy behavior is still correct and tested. The guard just states the intent.
  3. info, PR body: "it buys up to SESSION_MAX_AGE when the cookie is used at least once every SESSION_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 sprayberry-secondread left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 needsRenewal have a test that fails without them.
  • Docs, CHANGELOG, README and wrangler.toml match 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

@askalf
askalf merged commit a5db6aa into main Sep 25, 2026
7 checks passed
@askalf
askalf deleted the claude/get-78-merged-8d048f branch September 25, 2026 04:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Documentation improvements fuzz Fuzzing and ClusterFuzzLite size/M 50-199 hand-written lines worker Cloudflare worker

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants