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 .clusterfuzzlite/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# ClusterFuzzLite build image — OpenSSF Scorecard detects fuzzing via this file.
# Uses the OSS-Fuzz JavaScript base builder (Jazzer.js), which provides
# `compile_javascript_fuzzer`. See build.sh for the fuzz targets.
# Digest-pinned (Scorecard Pinned-Dependencies); dependabot's docker ecosystem
# refreshes the pin so it tracks upstream instead of rotting.
FROM gcr.io/oss-fuzz-base/base-builder-javascript@sha256:5d383584d22928edf02a20e8a7b8ce776aecb9e20c76c9b23d2fdbc92858a966
COPY . $SRC/amnesia
WORKDIR $SRC/amnesia
COPY .clusterfuzzlite/build.sh $SRC/build.sh
14 changes: 14 additions & 0 deletions .clusterfuzzlite/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/bash -eu
# Build the Jazzer.js fuzz targets for ClusterFuzzLite / OSS-Fuzz.
# The target exports an ASYNC `fuzz(data)` (the Worker's cookie signing is
# WebCrypto); the invariant is the fail-safe contract at amnesia's auth
# boundary — the signed session cookie never throws on a hostile value and
# never verifies under a secret that didn't sign it (a forged cookie = free,
# un-gated search access past Turnstile). No --sync: that mode fires the
# promises without awaiting them and OOMs instead of fuzzing.
cd "$SRC/amnesia"
npm ci --no-audit --no-fund

for target in session; do
compile_javascript_fuzzer amnesia "fuzz/${target}.fuzz.js"
done
15 changes: 15 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,18 @@ updates:
directory: "/"
schedule:
interval: "weekly"

# npm: the fuzz devDependency (@jazzer.js/core) — the only npm surface in the
# repo (site is static, Worker is zero-dep).
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"

# Keeps the digest-pinned ClusterFuzzLite base image tracking upstream —
# OSS-Fuzz pushes new builder images often, and a stale pin would slowly
# drift the fuzz toolchain away from what OSS-Fuzz actually supports.
- package-ecosystem: "docker"
directory: "/.clusterfuzzlite"
schedule:
interval: "weekly"
42 changes: 42 additions & 0 deletions .github/workflows/cflite.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# ClusterFuzzLite — continuous fuzzing of amnesia's auth boundary: the Worker's
# signed session cookie (client-controlled input that decides whether a request
# skips Turnstile — a forged cookie = free un-gated search access). Runs weekly
# + on demand; a discovered crash fails the job and is uploaded as an artifact.
# Fuzz targets live in ./fuzz, built by .clusterfuzzlite/build.sh (Jazzer.js).
# OpenSSF Scorecard credits the Fuzzing check from the .clusterfuzzlite/ config.
name: ClusterFuzzLite

on:
schedule:
- cron: '37 6 * * 1' # weekly, Monday 06:37 UTC (staggered off the :00 grid)
workflow_dispatch:

permissions: read-all

jobs:
Fuzzing:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# JavaScript (Jazzer.js) has no native sanitizer — OSS-Fuzz rejects
# address/memory/undefined for JS ("cannot be fuzzed with sanitizers"),
# and the action's config rejects `none`. `coverage` is the value real
# JS ClusterFuzzLite projects use with this action version.
sanitizer: [coverage]
steps:
- name: Build fuzzers (${{ matrix.sanitizer }})
id: build
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
with:
language: javascript
sanitizer: ${{ matrix.sanitizer }}
- name: Run fuzzers (${{ matrix.sanitizer }})
id: run
uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
fuzz-seconds: 300
mode: 'batch'
language: javascript
sanitizer: ${{ matrix.sanitizer }}
4 changes: 3 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ on:

permissions:
contents: read
deployments: write

jobs:
deploy:
permissions:
contents: read
deployments: write # wrangler-action records a GitHub deployment
runs-on: [self-hosted, amnesia]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Continuous fuzzing of the API gate's auth boundary (ClusterFuzzLite +
Jazzer.js). `fuzz/session.fuzz.js` pins the Worker's signed-session-cookie
contract — the one input an anonymous internet client fully controls: it
never throws on a hostile cookie value, never verifies a value the operator's
`SESSION_SECRET` didn't sign (forgery = free, un-gated search past Turnstile),
and a `buildCookie` result always round-trips under its own secret and never
under another. The cookie helpers are now named exports beside the Worker's
default export (no runtime change). `cflite.yml` runs weekly; `npm run fuzz`
is the local loop. The target is async (WebCrypto HMAC), so it runs in
Jazzer's async mode — not `--sync`, which fires the promises without awaiting
and OOMs instead of fuzzing. Closes the OpenSSF Scorecard Fuzzing check.
- Live search suggestions: debounced autocomplete dropdown backed by the API
gate's `/autocompleter` endpoint (keyboard navigation, click/tap select).
Best-effort — rides the session cookie and never triggers Turnstile solves.

### Changed
- `deploy.yml` workflow token drops to read-only at the top level; the
`deployments: write` scope moves to the single deploy job. Closes the
Scorecard Token-Permissions finding. No behavior change.

### Security
- Validate result/image URLs against an http(s) scheme allowlist before rendering
links, blocking `javascript:`/`data:` injection from poisoned upstream results.
Expand Down
30 changes: 30 additions & 0 deletions fuzz/run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// `npm run fuzz` — run every Jazzer.js target in ./fuzz for a short burst.
// Continuous fuzzing is done in CI by ClusterFuzzLite (.github/workflows/
// cflite.yml); this is the fast local repro loop. Targets import the Worker
// source directly (no build step). Override the per-target budget with
// FUZZ_SECONDS (default 30).
import { spawnSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import path from 'node:path';

const dir = path.dirname(fileURLToPath(import.meta.url));
const targets = readdirSync(dir).filter((f) => f.endsWith('.fuzz.js')).sort();
const secs = process.env.FUZZ_SECONDS || '30';
// Run Jazzer's JS CLI directly under `node` — no .cmd wrapper, no shell, so a
// space in the repo path can't break the invocation. The targets export an
// ASYNC fuzz(data) (the Worker's cookie signing is WebCrypto), so we run in
// Jazzer's async mode — NOT --sync, which fires the promises without awaiting
// them and floods pending crypto ops into an OOM rather than fuzzing.
const jazzerCli = createRequire(import.meta.url).resolve('@jazzer.js/core/dist/cli.js');

for (const t of targets) {
console.log(`\n=== fuzzing ${t} (${secs}s) ===`);
const r = spawnSync(
process.execPath,
[jazzerCli, `fuzz/${t.replace(/\.js$/, '')}`, '--', `-max_total_time=${secs}`],
{ stdio: 'inherit' },
);
if (r.status !== 0) process.exit(r.status || 1);
}
65 changes: 65 additions & 0 deletions fuzz/session.fuzz.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Fuzz the amnesia Worker's auth boundary — the session cookie is
// client-controlled input that decides whether a request skips Turnstile, so
// its forgery-resistance is the whole security story. Contracts pinned:
// - verifySession never throws on any cookie value, always returns a boolean,
// and NEVER accepts a value the operator's SESSION_SECRET didn't sign
// (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);
// - timingSafeEqual never throws and only returns true for equal strings;
// - readCookie never throws parsing a hostile Cookie header.
import {
buildCookie,
verifySession,
timingSafeEqual,
readCookie,
COOKIE_NAME,
} from '../worker/src/index.js';

const SECRET = 'fuzz-session-secret';
const OTHER = 'a-different-secret';

export async function fuzz(data) {
const s = data.toString('utf8');

// Arbitrary cookie value must never throw and never verify under the wrong
// secret. (An attacker submits arbitrary bytes as the cookie.)
const v1 = await verifySession(s, SECRET);
if (typeof v1 !== 'boolean') throw new Error('verifySession returned a non-boolean');

// A genuinely-signed, unexpired cookie MUST verify under its secret and MUST
// NOT verify under a different one. ttl derived from the input, floored to a
// positive value so the cookie isn't born expired.
const ttl = (data.length % 3600) + 60;
const cookieHeader = await buildCookie(SECRET, ttl);
const value = cookieHeader.slice(COOKIE_NAME.length + 1, cookieHeader.indexOf(';'));
if (!(await verifySession(value, SECRET))) {
throw new Error('a freshly-signed cookie failed to verify under its own secret');
}
if (await verifySession(value, OTHER)) {
throw new Error('a cookie verified under the WRONG secret — signing is forgeable');
}

// Splicing the fuzz bytes onto the real signature must not forge a pass.
const dot = value.lastIndexOf('.');
if (dot > 0) {
const forged = s + value.slice(dot); // attacker-chosen expiry + real sig
if (await verifySession(forged, SECRET)) {
throw new Error('spliced-expiry cookie forged a valid session');
}
}

if (typeof timingSafeEqual(s, value) !== 'boolean') {
throw new Error('timingSafeEqual returned a non-boolean');
}
if (timingSafeEqual(s, s) !== true) {
throw new Error('timingSafeEqual said a string is unequal to itself');
}

// readCookie over a hostile Cookie header must never throw.
const req = { headers: { get: (h) => (h === 'cookie' ? s : null) } };
const got = readCookie(req, COOKIE_NAME);
if (got !== null && typeof got !== 'string') {
throw new Error('readCookie returned neither string nor null');
}
}
Loading
Loading