fix(security): close the CSRF gap on the 15 unguarded mutating routes - #29
Closed
lightcloud00 wants to merge 1 commit into
Closed
fix(security): close the CSRF gap on the 15 unguarded mutating routes#29lightcloud00 wants to merge 1 commit into
lightcloud00 wants to merge 1 commit into
Conversation
Studio already ships requireJsonContentType(), but only 33 of the 48 route
files that declare a POST/PUT/PATCH/DELETE handler call a CSRF guard. The
other 15 had none — including the three that spawn processes (start-agent,
start-hermes, systemd-control) and the one that downloads remote content and
writes executable skill files to disk (skills/install). Any page the user
visited while signed in could drive those endpoints.
The obvious fix — require application/json everywhere — breaks real callers.
Several of these mutations legitimately send no Content-Type (bodiless
DELETEs, action POSTs like /api/hermes-jobs/{id}?action=pause,
/api/crews/templates/{id}, /api/mcp/reload), and /api/hermes-proxy forwards
whatever it is given, multipart included. Forcing JSON would 415 the app's
own UI, so the callers were checked before choosing an approach.
So add rejectCrossSiteMutation(), which checks the initiator instead:
browsers set Sec-Fetch-Site on every request and page JavaScript cannot forge
it, so cross-site/same-site reliably identify a foreign origin. Non-browser
clients (curl, the CLI, scripts) send no Sec-Fetch-* header at all, and an
absent value is allowed — command-line use is unaffected.
Rate limits go on the six routes where a request is expensive rather than
merely noisy: each one starts a process, writes to disk, or pulls remote code.
Also add a test that walks src/routes/api and fails the build when a mutating
route ships without a guard. The suite was green throughout this gap because
nothing asserted which routes call one. It checks the call form with comments
stripped, so a commented-out guard fails rather than passing a substring
match, and asserts the guard's actual behaviour — 403 for cross-site and
same-site, pass-through for same-origin, absent, and safe methods — since
coverage alone proves only that a route mentions a guard, not that it works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@lightcloud00 is attempting to deploy a commit to the Joerg Peetz's projects Team on Vercel. A member of the Team first needs to authorize it. |
Author
|
Closing this — we're continuing this work on our own repo (lightcloud00/Hermes-Studio) rather than upstream. Nothing here is abandoned; the branch and all its commits are preserved there. Thanks for the project, and apologies for the churn in your PR queue. |
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.
The gap
Studio already ships a
requireJsonContentType()helper — but it is applied to only 33 of the 48 route files that declare aPOST/PUT/PATCH/DELETEhandler. The remaining 15 had no CSRF defense at all:start-agent.ts,start-hermes.ts,systemd-control.tsskills/install.tsskills/uninstall.tsmcp/reload.tshermes-proxy/$.tshermes-jobs*.ts,hermes-runs.ts,hermes-config.ts,crews/templates/$id.ts,skills/settings.ts,oauth.*.tsAny page a signed-in user visited could drive these.
Why not just require JSON everywhere
That was the first thing I tried, and it breaks real callers. Several of these mutations legitimately send no
Content-Type:DELETEs,/api/hermes-jobs/{id}?action=pause,/api/crews/templates/{id},/api/mcp/reload,/api/hermes-proxy, which forwards whatever it is given, multipart included.I audited the actual call sites before choosing; forcing JSON would have 415'd the app's own UI.
The approach
New
rejectCrossSiteMutation()insrc/server/rate-limit.tschecks the initiator instead. Browsers setSec-Fetch-Siteon every request and page JavaScript cannot forge it, socross-site/same-sitereliably mean a foreign origin started it.Non-browser clients (curl, the CLI, scripts) send no
Sec-Fetch-*header at all, and an absent value is allowed — so command-line use is unaffected. This needs zero client changes.Rate limits are added to the six routes where a request is genuinely expensive rather than merely noisy — each starts a process, writes to disk, or pulls remote code.
The test
The suite was green through all of this, because nothing asserted which routes call a guard. Added
src/test/csrf-coverage.test.ts, which walkssrc/routes/apiand fails the build when a mutating route ships without one.It deliberately avoids being the kind of test that only looks like it works:
cross-siteandsame-site, pass-through forsame-origin, absent header, and safe methods — because coverage alone proves only that a route mentions a guard, not that it works.Both directions were verified by hand: removing a guard, and commenting one out, each fail the test and name the offending file.
Verification
vitest run→ 199 passed (193 before, +6 behavioural).tsc --noEmit→ unchanged at the pre-existing 4-error baseline; nothing new introduced.Note on conflicts
This overlaps #25 on
mcp/reload.tsandskills/install.ts, on the same lines. The two are independent otherwise — happy to rebase this on whichever you merge first, just say the word.🤖 Generated with Claude Code