Repository navigation
Remove a build-output commit, and refuse cross-site writes - #42
Merged
guillaumelauzier merged 3 commits intoSep 15, 2026
Merged
Conversation
Commit 418d7a7 added apps/worker/test-dist-q/ — 79 transpiled .js files, 624 KB — and it merged to main via #41. I used `git add -A`, and this .gitignore read exactly `test-dist/`, so a scratch TypeScript out-dir named `test-dist-q` went through a gap one character wide. Nothing generates them: no tsconfig, npm script, shell script or workflow mentions `test-dist-q`, and the only declared out-dirs are `test-dist` (tsconfig.test.json) and `dist` (packages/worker-runner). Nothing imports them either. There is no production impact — wrangler.toml bundles from `main = "src/index.ts"`, the test suite compiles to test-dist/, eslint runs as `eslint 'src/**/*.ts'`, and the CI gates scan ':(glob)apps/worker/src/**/*.ts'. The cost was scanner noise. There is no CodeQL workflow in this repo: the Analyze checks are GitHub default setup, configured in repo settings, and they scan the whole tree with no path exclusions. So a code-quality bot reviewed generated code on #41 and reported a "useless conditional" at test-dist-q/entities/profile.js:49 — the transpiled retry guard `attempt < 5 && !acquired`, which is fine in the source it came from. That would have recurred on every future PR, and the files would have gone on drifting from src/ and polluting every grep of the repo. The ignore rule is now a glob rather than a literal, so the next scratch out-dir cannot repeat this. Verified it still covers plain `test-dist/`, and that `test-dist-q` was the only committed build output in the tree — packages/worker-runner/dist is untracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
Contributor
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
lead | 0edf923 | Sep 15 2026, 12:14 PM |
accessGuard accepts the CF_Authorization cookie on its own (middleware/access.ts:76-78), so an ambient cookie is enough to authenticate a write. That is the shape CSRF exploits: a page on another origin makes the victim's browser issue the request, and the browser attaches the cookie without the attacker ever seeing it. Nothing in the worker checked Origin, Referer, Sec-Fetch-Site or a CSRF token across 292 mutating handlers (220 .post, 40 .delete, 18 .put, 14 .patch). CORS is not a defence here. It governs whether the attacker can READ the response, not whether the write executes, and the preflight that blocks a JSON body never fires for a "simple request" — a form POST with application/x-www-form-urlencoded. That would be academic if every handler needed a JSON body, but 84 of them parse no body at all: they act on the path alone, so a simple form POST reaches them intact. Among those are POST /api/ops/garbage/:id/purge, which permanently deletes an entity and cascades across facts, rel_edges, channels, entity_roles, entity_history and entity_legacy_map; DELETE /api/projects/:id; DELETE /api/icp/:id; DELETE /api/saved_filters/:id; and POST /api/admin/repair-pipeline. crossSiteGuard refuses a mutating request only on positive evidence that it came from another site: a recognised Origin passes and an unrecognised one is refused; with no Origin, Sec-Fetch-Site decides; with neither, the request is allowed. That last rule is load-bearing rather than a loophole. Non-browser callers send neither header (scripts/provision-cf.mjs, the deploy workflow, external compute runners), and a client that has to supply its own credential is not this threat model — CSRF is about a credential the browser attaches on the attacker's behalf. Refusing an ABSENT header would break those callers and buy nothing, since an attacker who can set headers can set Origin too. Two details that decide whether this is safe to ship: - `same-site` is explicitly allowed. The dashboard is served from aidatasignal.com and calls api.aidatasignal.com — cross-ORIGIN but same-SITE. Treating it as foreign would block every write the dashboard makes. - CORS and the guard now read ONE exported ALLOWED_ORIGINS constant rather than two copies. Two lists is the one realistic way this guard locks the dashboard out of its own API: CORS permits an origin the guard then refuses, and every write 403s until someone reverts. Mounted immediately after accessGuard, which is the whole design: it covers exactly the Access-authenticated surface and leaves alone the two routers mounted above it, which authenticate differently and are called by non-browsers — /api/webhooks/campaigns and /api/compute (per-node HMAC envelope). Safe methods are skipped entirely; there are no state-changing GET handlers in this worker, so reads need no protection and blocking them would only break the dashboard. test/cross_site_guard.test.mjs executes the middleware against the repo's own Hono rather than pattern-matching the source, because what matters is what the router does with a request — this gap was invisible in every file that looked correct on its own. Verified the three attack tests and the mounting assertion all fail with the guard removed. Not determinable from this repository, and stated plainly rather than assumed: whether a browser actually attaches CF_Authorization cross-site depends on that cookie's SameSite attribute, which is Cloudflare Access configuration. If it is None this was live; if Lax or Strict the form-POST vector was already closed. The guard is worth having either way because it does not depend on a setting we cannot see from here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
Left over from an earlier draft that mounted cors() in the test app to assert it and the guard agreed on the origin list. That assertion ended up reading the shared ALLOWED_ORIGINS constant out of index.ts instead, which is stronger — it catches the list being duplicated, not just the two happening to match on one input — and the import stopped being used. Caught by the repo's code-quality scan on the previous push. 1076/1076 still passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA
guillaumelauzier
marked this pull request as ready for review
September 15, 2026 12:22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1076/1076 tests, typecheck clean, lint 0 errors, all three gates green.
Commit 1 —
1476eceRemove 79 compiled files I committed by accidentMy commit
418d7a7(in #41) addedapps/worker/test-dist-q/— 79 transpiled.jsfiles, 624 KB. I usedgit add -A, andapps/worker/.gitignoreread exactlytest-dist/, so a scratch TypeScript out-directory namedtest-dist-qwent through a gap one character wide.No production impact.
wrangler.tomlbundles frommain = "src/index.ts"; the test suite compiles totest-dist/; ESLint runs aseslint 'src/**/*.ts'; the CI gates scan':(glob)apps/worker/src/**/*.ts'. Nothing generates these files and nothing imports them.The cost was scanner noise. There is no CodeQL workflow in this repo — the
Analyzechecks are GitHub default setup, configured in repo settings, scanning the whole tree with no path exclusions. Sogithub-code-quality[bot]reviewed generated code on #41 and filed a finding againsttest-dist-q/entities/profile.js:49("Useless conditional" on the transpiled retry guardattempt < 5 && !acquired, which is fine in the source it came from). That would have recurred on every future PR. Since the scanner config lives in repo settings rather than the tree, removing the files is the only fix available from here.The ignore rule is now a glob. Verified it still covers plain
test-dist/, and thattest-dist-qwas the only committed build output in the tree —packages/worker-runner/distis untracked, so the glob ignores nothing the repo tracks.Commit 2 —
b97a071Refuse cross-site writesaccessGuardaccepts theCF_Authorizationcookie on its own (src/middleware/access.ts:76-78), so an ambient cookie is enough to authenticate a write — the shape CSRF exploits. Nothing checkedOrigin,Referer,Sec-Fetch-Siteor a CSRF token across 292 mutating handlers (220.post, 40.delete, 18.put, 14.patch).CORS is not a defence here. It governs whether the attacker can read the response, not whether the write executes, and the preflight that blocks a JSON body never fires for a simple request — a form POST with
application/x-www-form-urlencoded. That would be academic if every handler needed a JSON body, but 84 of them parse no body at all: they act on the path alone, so a simple form POST reaches them intact. Among those:POST /api/ops/garbage/:id/purge(permanent entity delete, cascading across facts / rel_edges / channels / entity_roles / entity_history / entity_legacy_map),DELETE /api/projects/:id,DELETE /api/icp/:id,DELETE /api/saved_filters/:id,POST /api/admin/repair-pipeline.crossSiteGuardrefuses a mutating request only on positive evidence it came from another site: a recognisedOriginpasses, an unrecognised one is refused; with noOrigin,Sec-Fetch-Sitedecides; with neither, it is allowed.That last rule is load-bearing rather than a loophole. Non-browser callers send neither header (
scripts/provision-cf.mjs, the deploy workflow, external compute runners), and a client that supplies its own credential is not this threat model — CSRF is about a credential the browser attaches on the attacker's behalf. Refusing an absent header would break those callers and buy nothing, since an attacker who can set headers can setOrigintoo.Two details that decide whether this is safe to ship:
same-siteis explicitly allowed. The dashboard is served fromaidatasignal.comand callsapi.aidatasignal.com— cross-origin but same-site. Treating it as foreign would block every write the dashboard makes.ALLOWED_ORIGINS. Two copies of that list is the one realistic way this guard locks the dashboard out of its own API: CORS permits an origin the guard then refuses, and every write 403s until someone reverts.Mounted immediately after
accessGuard— that position is the whole design. It covers exactly the Access-authenticated surface and leaves alone the two routers mounted above it, which authenticate differently and are called by non-browsers:/api/webhooks/campaignsand/api/compute(per-node HMAC envelope). Safe methods are skipped entirely; there are no state-changingGEThandlers in this worker, so reads need no protection and blocking them would only break the dashboard.What I could not determine
Whether a browser actually attaches
CF_Authorizationcross-site depends on that cookie'sSameSiteattribute, which is Cloudflare Access configuration and appears nowhere in this repo. If it isNone, this was live. IfLaxorStrict, the form-POST vector was already closed and this is defence-in-depth. I am not claiming more than the evidence supports — the guard is worth having either way precisely because it does not depend on a setting invisible from here.Verification
test/cross_site_guard.test.mjsexecutes the middleware against the repo's own Hono rather than pattern-matching the source, because what matters is what the router does with a request — this gap was invisible in every file that looked correct on its own. 12 tests: the cross-site form POST, cross-site DELETE,Sec-Fetch-Sitealone, the dashboard's own writes, same-site, same-origin, non-browser callers, reads,/api/computereachability, the mount position, the shared origin list, and noconsole.*(the repo's CI gate forbids it).Verified the three attack tests and the mounting assertion all fail with the guard removed.
Still waiting on you
src/ai/workflows.tsdeclares all 29 Workflow classes as plain classes with arunmethod rather than extendingWorkflowEntrypoint. If that assumption is wrong, every Workflow instance fails on start — which would also mean the profiler batch and the frontier drain silently do nothing. Dispatching one Workflow and telling me whether it runs settles it. I can't check it from here: the egress proxy deniesapi.aidatasignal.comby org policy and the Cloudflare MCP connection is a different account.🤖 Generated with Claude Code
https://claude.ai/code/session_01EgworUXKA6pZzEcUUJXCmA