Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion docs/privacy-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
16 changes: 16 additions & 0 deletions fuzz/session.fuzz.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
}
Expand Down
73 changes: 70 additions & 3 deletions test/worker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand All @@ -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 () => {
Expand Down
54 changes: 44 additions & 10 deletions worker/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '*')
Expand All @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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") ||
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading