diff --git a/CHANGELOG.md b/CHANGELOG.md index 90bc3fb..da7c7fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `api.amnesia.tax` during parse; the self-host image strips it. - Docs said the session cookie lasts 30 minutes; the Worker issues 6 hours. +### Changed +- **The session cookie renews while in use.** A valid cookie with less than + half of `SESSION_TTL` left is re-issued on the same response, so someone + searching across the 6-hour mark no longer hits a Turnstile solve there. + The cookie now signs the solve's time with its expiry (`start.exp.sig`), and + renewal never passes `SESSION_MAX_AGE` (24 h) from that solve, so one solve + still buys a bounded session. Cookies issued before this (`exp.sig`) keep + working until they expire and are not renewed. + ### Security - The gate's Turnstile bypass for the operator's verification bridge listed a dynamic residential IP from a relay retired on 2026-09-12. Only the box's diff --git a/README.md b/README.md index 0f3029e..2d635fc 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Amnesia doesn't protect against a global adversary watching both ends, a comprom ## How it's built -- **A bot gate that isn't a CAPTCHA per search.** A Cloudflare Worker verifies one Turnstile solve and issues an HMAC-signed session cookie good for 6 hours; searches after that never see a challenge. The gate fails **closed** when its secrets are missing. [Architecture](docs/architecture.md) +- **A bot gate that isn't a CAPTCHA per search.** A Cloudflare Worker verifies one Turnstile solve and issues an HMAC-signed session cookie good for 6 hours, renewed while you keep searching up to 24 hours from the solve; searches in that window never see a challenge. The gate fails **closed** when its secrets are missing. [Architecture](docs/architecture.md) - **No way around the gate.** A WAF rule answers 403 to any request on the backend hostname without the gate's secret header, and zone rate limits cover `/search` on both hosts. - **One way out: the VPN.** SearXNG has no network interface of its own. It runs inside Gluetun's network namespace, and Gluetun drops all traffic while the tunnel is down, so a VPN outage takes search down rather than leaking your queries out the host's own IP. The container runs with no Linux capabilities, a read-only root, `no-new-privileges`, a memory cap and a digest-pinned image. - **One HTML file, CSP by hash.** About 45 KB with no framework and no build step, and fonts are self-hosted. Web search makes no third-party request except the Turnstile challenge; image search also loads each thumbnail from its own host, which is the one place your IP reaches anyone but Cloudflare. The Content-Security-Policy allows the page's one script and one style by SHA-256 hash, generated from the HTML by [a script](scripts/csp-hashes.mjs) that CI re-runs on every change. diff --git a/docs/architecture.md b/docs/architecture.md index 22efec7..0634050 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ flowchart LR ``` - **Front end** — [`src/amnesia-search.html`](../src/amnesia-search.html), 44 KB, self-contained. Pre-warms the session cookie on page load so the first search never waits on Turnstile; on a 401 it solves once and retries. Autocomplete is best-effort and never triggers a challenge. -- **API gate** — [`worker/src/index.js`](../worker/src/index.js). Authorizes (cookie, else token, else 401), proxies `/search` and `/autocompleter` to the origin with the secret header, and stores successful answers at the edge under a key built from the normalized query and sorted params. Clients always receive `no-store`; the edge copy's own `cache-control` governs its lifetime. +- **API gate** — [`worker/src/index.js`](../worker/src/index.js). Authorizes (cookie, else token, else 401), renews a cookie past half its life up to `SESSION_MAX_AGE` from its solve, proxies `/search` and `/autocompleter` to the origin with the secret header, and stores successful answers at the edge under a key built from the normalized query and sorted params. Clients always receive `no-store`; the edge copy's own `cache-control` governs its lifetime. - **Origin lock** — the backend hostname answers only to the Worker. WAF returns 403 without the secret header; zone rate limits cover `/search` on both hosts. - **Backend** — one SearXNG container, no result cache, no Redis or Valkey, no nginx. Fewer components holding a query is the design goal, not a shortcut. Every enabled engine has a 3 s timeout and no retries, and a client's `timeout_limit` is capped at 5 s: healthy engines answer well under 1.5 s, and a flaky one is bounded rather than waited on. Per-engine timing is on the host at `127.0.0.1:8081/stats`. diff --git a/docs/privacy-model.md b/docs/privacy-model.md index 9062559..ce195dc 100644 --- a/docs/privacy-model.md +++ b/docs/privacy-model.md @@ -12,7 +12,7 @@ A privacy claim is only as good as its threat model. This is the full path a que |---|---|---| | **You (browser)** | Everything. Theme preference lives in `localStorage`. | Nothing is sent to us. No history, no account. | | **Cloudflare edge** (Pages, Worker, Tunnel) | Your IP and the query in plaintext. Cloudflare terminates TLS, so the Worker reads `?q=` to proxy it. | An **edge cache entry** keyed on the normalized query text, for **3 minutes** (`/search`) or **6 hours** (`/autocompleter`). The key never includes a cookie, token, or IP. Cloudflare's own edge logging is governed by [Cloudflare's policies](https://www.cloudflare.com/privacypolicy/), not by this repo. | -| **The session cookie** | Nothing. It is an HMAC over a timestamp under the operator's secret — it identifies a *session*, not a person. | 6 hours (`SESSION_TTL` in [`worker/wrangler.toml`](../worker/wrangler.toml)), in your browser. The server keeps no session table. | +| **The session cookie** | Nothing. It is an HMAC over two timestamps (the Turnstile solve and the expiry) under the operator's secret — it identifies a *session*, not a person. | 6 hours (`SESSION_TTL` in [`worker/wrangler.toml`](../worker/wrangler.toml)), renewed on use with less than half left, never past 24 hours from the solve (`SESSION_MAX_AGE`), in your browser. The server keeps no session table. | | **SearXNG backend** | The query, arriving with the gate's user agent and no client IP. | Nothing. No result cache, no Redis, no access log. | | **VPN provider** (ProtonVPN) | Encrypted traffic leaving the backend for the engines. | Per ProtonVPN's policy; the tunnel carries no query in plaintext. | | **Search engines** (Brave, Bing, DuckDuckGo, …) | The query and the VPN exit IP. | Whatever each engine retains for a datacenter IP with no cookies. They never see your IP. | diff --git a/fuzz/session.fuzz.js b/fuzz/session.fuzz.js index 3a15ee7..cfc407a 100644 --- a/fuzz/session.fuzz.js +++ b/fuzz/session.fuzz.js @@ -6,11 +6,14 @@ // (forgery = free search access); // - a cookie freshly minted by buildCookie always verifies under the same // secret and never under a different one (sign/verify agree); +// - a renewed cookie keeps its solve time and never expires later than +// start + maxAge, whatever start the fuzzer picks; // - timingSafeEqual never throws and only returns true for equal strings; // - readCookie never throws parsing a hostile Cookie header. import { buildCookie, verifySession, + sessionTimes, timingSafeEqual, readCookie, COOKIE_NAME, @@ -49,6 +52,19 @@ export async function fuzz(data) { } } + // Renewal from an older solve: the cap holds and the solve time carries over. + const now = Math.floor(Date.now() / 1000); + const maxAge = (data.length % 86400) + 3600; + const start = now - (data.length > 0 ? data[0] * 300 : 0); + if (start + maxAge > now) { + const renewed = await buildCookie(SECRET, ttl, start, maxAge); + const rv = renewed.slice(COOKIE_NAME.length + 1, renewed.indexOf(';')); + const t = sessionTimes(rv); + if (t.start !== start) throw new Error('renewal lost the solve time'); + if (t.exp > start + maxAge) throw new Error('renewal extended a session past its cap'); + if (!(await verifySession(rv, SECRET))) throw new Error('a renewed cookie failed to verify'); + } + if (typeof timingSafeEqual(s, value) !== 'boolean') { throw new Error('timingSafeEqual returned a non-boolean'); } diff --git a/test/worker.test.mjs b/test/worker.test.mjs index 85bbd60..f8ea236 100644 --- a/test/worker.test.mjs +++ b/test/worker.test.mjs @@ -8,7 +8,7 @@ import { test, describe, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import worker, { buildCookie, hmac, verifySession, COOKIE_NAME } from '../worker/src/index.js'; +import worker, { buildCookie, hmac, verifySession, sessionTimes, COOKIE_NAME } from '../worker/src/index.js'; const SITE = 'https://amnesia.tax'; const API = 'https://api.amnesia.tax'; @@ -140,7 +140,7 @@ describe('session cookie', () => { }); test('a valid cookie is proxied to ORIGIN_HOST with the gate header', async () => { - const r = await call('/search?q=hello&format=json', { headers: { cookie: await validCookie() } }); + const r = await call('/search?q=hello&format=json', { headers: { cookie: await validCookie(1800) } }); assert.equal(r.status, 200); const [c] = originCalls(); assert.ok(c, 'origin was called'); @@ -151,7 +151,74 @@ describe('session cookie', () => { assert.equal(c.init.headers.get('cookie'), null, 'the client cookie is not forwarded'); assert.equal(verifyCalls().length, 0, 'a cookie skips Turnstile'); assert.equal(r.header('cache-control'), 'no-store'); - assert.equal(r.header('set-cookie'), null, 'a cookie-authorized request is not re-issued one'); + assert.equal(r.header('set-cookie'), null, 'a cookie with more than half its lifetime left is not re-issued'); + }); + + test('a cookie with less than half its lifetime left is renewed for a full SESSION_TTL', async () => { + for (const path of ['/search?q=x', '/autocompleter?q=x', '/session']) { + const r = await call(path, { headers: { cookie: await validCookie(899) } }); + assert.equal(r.status, 200, path); + const sc = r.header('set-cookie'); + assert.ok(sc, `${path} renews the cookie`); + assert.match(sc, /Max-Age=1800;/); + const { exp } = sessionTimes(cookieValue(sc)); + assert.ok(Math.abs(exp - (nowS() + 1800)) <= 2, 'the new expiry is a full SESSION_TTL out'); + assert.equal(await verifySession(cookieValue(sc), ENV.SESSION_SECRET), true); + } + assert.equal(verifyCalls().length, 0, 'renewal needs no Turnstile solve'); + }); + + test('an edge-cache hit also renews an ageing cookie', async () => { + await call('/search?q=cached', { headers: { cookie: await validCookie(1800) } }); + const r = await call('/search?q=cached', { headers: { cookie: await validCookie(60) } }); + assert.equal(r.header('x-amnesia-cache'), 'hit'); + assert.ok(r.header('set-cookie')); + assert.equal(originCalls().length, 1); + }); + + const agedCookie = async (age, left) => { + const start = String(nowS() - age); + const exp = String(nowS() + left); + return `${COOKIE_NAME}=${start}.${exp}.${await hmac(ENV.SESSION_SECRET, `${start}.${exp}`)}`; + }; + + test('renewal keeps the solve time, so a session never outlives SESSION_MAX_AGE', async () => { + const env = { ...ENV, SESSION_MAX_AGE: '86400' }; + // 23.9 h in: renewed, but only up to the 24 h mark. + const r = await call('/search?q=x', { env, headers: { cookie: await agedCookie(86040, 60) } }); + const sc = r.header('set-cookie'); + assert.ok(sc); + const { start, exp } = sessionTimes(cookieValue(sc)); + assert.ok(Math.abs(start - (nowS() - 86040)) <= 2, 'the solve time carries over'); + assert.ok(Math.abs(exp - (start + 86400)) <= 2, 'capped at start + SESSION_MAX_AGE'); + assert.match(sc, /Max-Age=3[0-9]{2};/); + // At the cap: still valid, not renewed again. + const capped = await call('/search?q=y', { env, headers: { cookie: `${COOKIE_NAME}=${cookieValue(sc)}` } }); + assert.equal(capped.status, 200); + assert.equal(capped.header('set-cookie'), null); + }); + + test('a cookie from before renewal (exp.sig) still verifies but is not renewed', async () => { + const exp = String(nowS() + 60); + const legacy = `${COOKIE_NAME}=${exp}.${await hmac(ENV.SESSION_SECRET, exp)}`; + const r = await call('/search?q=x', { headers: { cookie: legacy } }); + assert.equal(r.status, 200); + assert.equal(r.header('set-cookie'), null); + }); + + test('a legacy signature spliced onto a start time → 401', async () => { + const exp = String(nowS() + 60); + const sig = await hmac(ENV.SESSION_SECRET, exp); + const r = await call('/search?q=x', { headers: { cookie: `${COOKIE_NAME}=${nowS()}.${exp}.${sig}` } }); + assert.equal(r.status, 401); + }); + + test('renewal follows SESSION_TTL, not a fixed half hour', async () => { + const env = { ...ENV, SESSION_TTL: '21600' }; + const fresh = await call('/search?q=x', { env, headers: { cookie: await validCookie(10801) } }); + assert.equal(fresh.header('set-cookie'), null); + const ageing = await call('/search?q=y', { env, headers: { cookie: await validCookie(10799) } }); + assert.match(ageing.header('set-cookie'), /Max-Age=21600;/); }); test('the cookie is found among other cookies', async () => { diff --git a/worker/src/index.js b/worker/src/index.js index 0e0c044..f3b5d94 100644 --- a/worker/src/index.js +++ b/worker/src/index.js @@ -31,6 +31,12 @@ * Auth precedence: valid session cookie → allow (no Turnstile). Else a valid * `cf-turnstile-token` → allow AND (re)issue the cookie. Else 401. * + * Renewal: a valid cookie with less than half of SESSION_TTL left is re-issued + * on the same response, so a visitor who keeps searching doesn't meet the + * Turnstile solve when the cookie would have run out. The cookie carries the + * time of the solve that started it, and renewal never extends it past + * SESSION_MAX_AGE from then: one solve buys at most that long. + * * Cross-site cookie: page origin is amnesia.tax, cookie host is api.amnesia.tax, * so the cookie is SameSite=None; Secure and the SPA fetches with * credentials:'include'. CORS therefore echoes the specific origin (never '*') @@ -43,6 +49,7 @@ * ORIGIN_HOST (var) — base URL of the SearXNG origin behind the tunnel * ALLOWED_ORIGIN (var) — SPA origin allowed for CORS (https://amnesia.tax) * SESSION_TTL (var) — cookie lifetime in seconds (default 1800) + * SESSION_MAX_AGE (var) — cap on a renewed session, from its solve (default 86400) */ const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; @@ -55,6 +62,7 @@ export default { const url = new URL(request.url); const allowedOrigin = env.ALLOWED_ORIGIN || "https://amnesia.tax"; const ttl = parseInt(env.SESSION_TTL || "1800", 10); + const maxAge = parseInt(env.SESSION_MAX_AGE || "86400", 10); // Fail closed if signing/verification secrets are missing. Without // SESSION_SECRET, hmac() would sign cookies with an empty key — forgeable by @@ -99,6 +107,7 @@ export default { // --- Authorize: trusted-bridge bypass, else session cookie, else token -- let authorized = false; let issueCookie = false; + let sessionStart; // a renewal keeps the solve time it started from // Trusted bridge bypass. The headless front-end-verification bridge runs on // the platform box and can't solve Turnstile from that datacenter IP. Allow @@ -114,6 +123,11 @@ export default { const cookie = readCookie(request, COOKIE_NAME); if (!authorized && cookie && (await verifySession(cookie, env.SESSION_SECRET))) { authorized = true; // valid, unexpired session — skip Turnstile + const { start, exp } = sessionTimes(cookie); + if (needsRenewal(start, exp, ttl, maxAge)) { + issueCookie = true; + sessionStart = start; + } } else if (!authorized) { const token = request.headers.get("cf-turnstile-token") || @@ -144,7 +158,7 @@ export default { } const setCookie = issueCookie - ? { "set-cookie": await buildCookie(env.SESSION_SECRET, ttl) } + ? { "set-cookie": await buildCookie(env.SESSION_SECRET, ttl, sessionStart, maxAge) } : {}; // Pre-warm endpoint: just establish the session, no search. @@ -244,7 +258,10 @@ async function siteverify(token, secret, ip) { } } -// ---- Signed session cookie (HMAC-SHA256 over expiry) --------------------- +// ---- Signed session cookie (HMAC-SHA256 over start and expiry) ---------- +// Value: `start.exp.sig`, sig = HMAC(`start.exp`); start is the Turnstile +// solve's time. Cookies from before renewal are `exp.sig`: they still verify +// until they expire, and are not renewed. // The cookie helpers below are exported for the fuzz targets in /fuzz — the // cookie value is client-controlled input guarding auth, so its // forgery-resistance contract is machine-checked there. Named exports beside @@ -261,24 +278,41 @@ export async function hmac(secret, msg) { return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); } -export async function buildCookie(secret, ttl) { - const exp = Math.floor(Date.now() / 1000) + ttl; - const sig = await hmac(secret, String(exp)); - const value = `${exp}.${sig}`; - return `${COOKIE_NAME}=${value}; Max-Age=${ttl}; Path=/; HttpOnly; Secure; SameSite=None`; +export async function buildCookie(secret, ttl, start, maxAge = Infinity) { + const now = Math.floor(Date.now() / 1000); + if (start === undefined) start = now; + const exp = Math.min(now + ttl, start + maxAge); + const payload = `${start}.${exp}`; + const sig = await hmac(secret, payload); + return `${COOKIE_NAME}=${payload}.${sig}; Max-Age=${exp - now}; Path=/; HttpOnly; Secure; SameSite=None`; } export async function verifySession(value, secret) { const dot = value.lastIndexOf("."); if (dot < 0) return false; - const exp = value.slice(0, dot); + const payload = value.slice(0, dot); const sig = value.slice(dot + 1); - const expNum = parseInt(exp, 10); + if (!/^(\d+\.)?\d+$/.test(payload)) return false; + const expNum = parseInt(payload.slice(payload.lastIndexOf(".") + 1), 10); if (!expNum || expNum < Math.floor(Date.now() / 1000)) return false; // expired - const expected = await hmac(secret, exp); + const expected = await hmac(secret, payload); return timingSafeEqual(sig, expected); } +// Only called on a value verifySession accepted. start is null for a cookie +// from before renewal. +export function sessionTimes(value) { + const parts = value.split("."); + if (parts.length === 2) return { start: null, exp: parseInt(parts[0], 10) }; + return { start: parseInt(parts[0], 10), exp: parseInt(parts[1], 10) }; +} + +export function needsRenewal(start, exp, ttl, maxAge) { + if (start === null) return false; + const now = Math.floor(Date.now() / 1000); + return exp - now < ttl / 2 && exp < start + maxAge; +} + export function timingSafeEqual(a, b) { if (a.length !== b.length) return false; let diff = 0; diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 61952c4..2c31fb8 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -28,6 +28,9 @@ ORIGIN_HOST = "https://search-origin.amnesia.tax" # 6h (was 30min): the cookie is just an HMAC-signed expiry — nothing # user-identifying — so a longer life cuts re-challenges at negligible risk. SESSION_TTL = "21600" +# A cookie with under half its life left is renewed on the next request, but +# never past this many seconds from the Turnstile solve that started it: 24h. +SESSION_MAX_AGE = "86400" # Egress IP of the trusted front-end-verification bridge. Requests from this # IP skip Turnstile (the headless bridge can't solve it) but still pass # through the gate, the origin lock and the zone rate limit. Comma-separated;