From 8deb94260ddd710da31a60680cf2ba698756ad8a Mon Sep 17 00:00:00 2001
From: Thomas Sprayberry <263217947+askalf@users.noreply.github.com>
Date: Fri, 25 Sep 2026 20:44:02 -0400
Subject: [PATCH 1/6] ci: cflite.yml is the coverage report; ClusterFuzzLite
cannot fuzz JavaScript
---
.github/workflows/cflite.yml | 25 ++++++++++++-------------
1 file changed, 12 insertions(+), 13 deletions(-)
diff --git a/.github/workflows/cflite.yml b/.github/workflows/cflite.yml
index e634d78..96527f6 100644
--- a/.github/workflows/cflite.yml
+++ b/.github/workflows/cflite.yml
@@ -1,12 +1,12 @@
-# ClusterFuzzLite — continuous fuzzing of browser-bridge's trust boundary:
-# the CDP proxy's pure request guards (token extraction, the DNS-rebinding
-# Host gate, secret stripping before anything is forwarded to Chromium) and
-# the UA pool fed by client-controlled ?session= ids. 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
+# ClusterFuzzLite COVERAGE REPORT of the fuzz targets in ./fuzz. This action version cannot fuzz
+# JavaScript: the OSS-Fuzz builder rejects every sanitizer for JS ("JavaScript projects cannot be
+# fuzzed with sanitizers"), the action's config rejects `none`, and `coverage` selects its
+# coverage-report runner, not the fuzzer (oss-fuzz infra/cifuzz/config_utils.py forces
+# mode=coverage for it). Proven 2026-09-26 on plumbline runs 36204398437 (address) and
+# 36204619235 (none). Every run of this workflow builds the targets, replays the corpus for a
+# few seconds and uploads a coverage report; the fuzzing itself is fuzz.yml (Jazzer.js).
+# The .clusterfuzzlite/ config stays: OpenSSF Scorecard credits the Fuzzing check from it.
+name: ClusterFuzzLite coverage
on:
schedule:
@@ -17,15 +17,14 @@ permissions: read-all
jobs:
Fuzzing:
+ name: Coverage report (${{ matrix.sanitizer }})
runs-on: ubuntu-latest
timeout-minutes: 20
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.
+ # `coverage` is the only value this action accepts for JavaScript that builds at all, and it
+ # means: report, not fuzz (see the header). The fuzzing runs in fuzz.yml.
sanitizer: [coverage]
steps:
- name: Build fuzzers (${{ matrix.sanitizer }})
From 76cfd30c98b6387e65fd36717d6f2735229f768e Mon Sep 17 00:00:00 2001
From: Thomas Sprayberry <263217947+askalf@users.noreply.github.com>
Date: Fri, 25 Sep 2026 20:44:04 -0400
Subject: [PATCH 2/6] fuzz: the Jazzer.js runner takes corpus and crash dirs
for CI and local use
---
fuzz/run.mjs | 46 ++++++++++++++++++++++++++++++----------------
1 file changed, 30 insertions(+), 16 deletions(-)
diff --git a/fuzz/run.mjs b/fuzz/run.mjs
index e41cf75..4932c30 100644
--- a/fuzz/run.mjs
+++ b/fuzz/run.mjs
@@ -1,27 +1,41 @@
-// `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 runtime
-// .mjs modules directly (no build step). Override the per-target budget with
-// FUZZ_SECONDS (default 30).
+// `node fuzz/run.mjs` (also `npm run fuzz`) runs every Jazzer.js target in ./fuzz. This is the
+// fuzzer CI runs (.github/workflows/fuzz.yml) and the local repro loop. Environment:
+// FUZZ_SECONDS per-target budget in seconds (default 30)
+// FUZZ_CORPUS_DIR root of per-target corpus dirs, created on demand; libFuzzer reads its
+// seeds from
/ and saves every interesting input there, so a
+// corpus that persists between runs keeps getting deeper. Unset: no corpus.
+// FUZZ_ARTIFACT_DIR where a crashing input is written, created on demand. Unset: the cwd.
import { spawnSync } from 'node:child_process';
-import { readdirSync } from 'node:fs';
-import { fileURLToPath } from 'node:url';
+import { mkdirSync, readdirSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
+import { fileURLToPath } from 'node:url';
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.
+const corpusRoot = process.env.FUZZ_CORPUS_DIR || '';
+const artifactDir = process.env.FUZZ_ARTIFACT_DIR || '';
+// Run Jazzer's JS CLI directly under `node`: no .cmd wrapper, no shell, so a space in the repo
+// path cannot break the invocation.
const jazzerCli = createRequire(import.meta.url).resolve('@jazzer.js/core/dist/cli.js');
+if (artifactDir) mkdirSync(artifactDir, { recursive: true });
for (const t of targets) {
- console.log(`\n=== fuzzing ${t} (${secs}s) ===`);
- const r = spawnSync(
- process.execPath,
- [jazzerCli, `fuzz/${t.replace(/\.js$/, '')}`, '--sync', '--', `-max_total_time=${secs}`],
- { stdio: 'inherit' },
- );
- if (r.status !== 0) process.exit(r.status || 1);
+ const name = t.replace(/\.fuzz\.js$/, '');
+ // The targets are synchronous (they never return a promise), so Jazzer runs in --sync mode.
+ const args = [jazzerCli, `fuzz/${name}.fuzz`, '--sync'];
+ if (corpusRoot) {
+ const corpus = path.join(corpusRoot, name);
+ mkdirSync(corpus, { recursive: true });
+ args.push(corpus);
+ }
+ args.push('--', `-max_total_time=${secs}`, '-print_final_stats=1');
+ if (artifactDir) args.push(`-artifact_prefix=${artifactDir}${path.sep}`);
+ console.log(`\n=== fuzzing ${name} (${secs}s) ===`);
+ const r = spawnSync(process.execPath, args, { stdio: 'inherit' });
+ if (r.status !== 0) {
+ console.error(`\n${name}: jazzer exited with ${r.status ?? r.signal}; a reproducing input is in ${artifactDir || 'the working directory'}`);
+ process.exit(r.status || 1);
+ }
}
From cbcdfb1459ef268cdf511dd5540991381e027777 Mon Sep 17 00:00:00 2001
From: Thomas Sprayberry <263217947+askalf@users.noreply.github.com>
Date: Fri, 25 Sep 2026 20:44:07 -0400
Subject: [PATCH 3/6] ci: fuzz.yml fuzzes every target with Jazzer.js, corpus
persisted, crashes uploaded
---
.github/workflows/fuzz.yml | 80 ++++++++++++++++++++++++++++++++++++++
1 file changed, 80 insertions(+)
create mode 100644 .github/workflows/fuzz.yml
diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
new file mode 100644
index 0000000..fa79739
--- /dev/null
+++ b/.github/workflows/fuzz.yml
@@ -0,0 +1,80 @@
+# Fuzzing of browser-bridge's trust boundary with Jazzer.js (libFuzzer): every target in ./fuzz runs
+# for FUZZ_SECONDS against a corpus that persists between runs through the Actions cache. A
+# crash, an uncaught throw or a timeout fails the job and the reproducing input is uploaded as
+# an artifact. This workflow is the fuzzer; cflite.yml is only the coverage report, because
+# ClusterFuzzLite's action cannot fuzz JavaScript at all: it rejects every sanitizer for JS,
+# rejects `none`, and `coverage` selects its report runner (proven 2026-09-26 on plumbline runs
+# 36204398437 and 36204619235). Weekly, on demand, and on pull requests that touch the targets
+# or the code under them, with a short budget there.
+name: Fuzz
+
+on:
+ schedule:
+ - cron: '57 6 * * 1' # weekly, 06:57 UTC, after the coverage report's slot
+ workflow_dispatch:
+ inputs:
+ seconds:
+ description: 'Seconds per target'
+ required: false
+ default: '300'
+ pull_request:
+ paths:
+ - 'fuzz/**'
+ - 'scripts/**'
+ - 'policy/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - '.github/workflows/fuzz.yml'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: fuzz-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ fuzz:
+ name: Fuzz (Jazzer.js)
+ # Never from a fork: the schedule is inherited by every fork and means nothing there.
+ if: github.repository == 'askalf/browser-bridge'
+ runs-on: ubuntu-latest
+ timeout-minutes: 40
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 22
+ - name: Install dependencies
+ run: npm ci --no-audit --no-fund
+ # The newest saved corpus, whichever run saved it; the save below writes a fresh key each
+ # run so the restore-keys prefix always finds the latest.
+ - name: Restore the corpus
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: fuzz/corpus
+ key: fuzz-corpus-${{ github.run_id }}
+ restore-keys: fuzz-corpus-
+ - name: Fuzz every target
+ env:
+ FUZZ_SECONDS: ${{ github.event_name == 'pull_request' && '60' || inputs.seconds || '300' }}
+ FUZZ_CORPUS_DIR: fuzz/corpus
+ FUZZ_ARTIFACT_DIR: fuzz/crashes
+ run: node fuzz/run.mjs
+ - name: Upload the crashing input
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: fuzz-crashes-${{ github.run_id }}
+ path: fuzz/crashes
+ if-no-files-found: ignore
+ # Only runs on the default branch feed the shared corpus; a pull request's cache is scoped
+ # to its branch anyway.
+ - name: Save the corpus
+ if: always() && github.event_name != 'pull_request'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: fuzz/corpus
+ key: fuzz-corpus-${{ github.run_id }}
From 913961c56984676ebd6a846c9db703115b38c25a Mon Sep 17 00:00:00 2001
From: Thomas Sprayberry <263217947+askalf@users.noreply.github.com>
Date: Fri, 25 Sep 2026 20:44:10 -0400
Subject: [PATCH 4/6] ci: fleet-status lists the Fuzz workflow (it runs on pull
requests)
---
.github/workflows/fleet-status.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/fleet-status.yml b/.github/workflows/fleet-status.yml
index 20e5448..74ca784 100644
--- a/.github/workflows/fleet-status.yml
+++ b/.github/workflows/fleet-status.yml
@@ -15,7 +15,7 @@ on:
issue_comment:
types: [created, edited, deleted]
workflow_run:
- workflows: [actionlint, build, CodeQL, 'fieldpass ci', hygiene, labels, stealth-score, 'PR triage', Redline]
+ workflows: [actionlint, build, CodeQL, 'fieldpass ci', hygiene, labels, stealth-score, 'PR triage', Redline, Fuzz]
types: [completed]
permissions: {}
From bee51833eb789acea5dd86ac0023c4efc25129a3 Mon Sep 17 00:00:00 2001
From: Thomas Sprayberry <263217947+askalf@users.noreply.github.com>
Date: Fri, 25 Sep 2026 20:44:12 -0400
Subject: [PATCH 5/6] gitignore: the fuzz corpus and crash dirs
---
.gitignore | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.gitignore b/.gitignore
index 201129f..61901a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@ node_modules/
.DS_Store
.env
.env.*
+# fuzz.yml / fuzz/run.mjs working dirs
+fuzz/corpus/
+fuzz/crashes/
From 1e875db2be87b13c8b1a745f6fa0ef0dd2e21a5a Mon Sep 17 00:00:00 2001
From: askalf <263217947+askalf@users.noreply.github.com>
Date: Sat, 26 Sep 2026 00:46:42 +0000
Subject: [PATCH 6/6] ci(fuzz): trigger on the modules the targets import, not
scripts/ and policy/
---
.github/workflows/fuzz.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
index fa79739..1c0c187 100644
--- a/.github/workflows/fuzz.yml
+++ b/.github/workflows/fuzz.yml
@@ -20,8 +20,8 @@ on:
pull_request:
paths:
- 'fuzz/**'
- - 'scripts/**'
- - 'policy/**'
+ - 'cdp-proxy.mjs'
+ - 'ua.mjs'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/fuzz.yml'