security: dashboard exposes .wolf/ to the local network by default#16
Open
smk508 wants to merge 1 commit intocytostack:mainfrom
Open
security: dashboard exposes .wolf/ to the local network by default#16smk508 wants to merge 1 commit intocytostack:mainfrom
smk508 wants to merge 1 commit intocytostack:mainfrom
Conversation
The dashboard server previously called app.listen(port) with no host argument, binding to 0.0.0.0. Combined with the fact that none of the HTTP or WebSocket endpoints require authentication, this meant the dashboard was reachable from the LAN and cross-origin from any webpage the user visited in a browser. Exposed surface included: - GET /api/files — contents of cerebrum.md, memory.md, buglog.json, token-ledger.json, and suggestions.json. - POST /api/cron/run/:taskId and the WebSocket "trigger_task" handler — both execute cron tasks, including ai_task actions that shell out to claude -p in the project root. This change: 1. Binds the HTTP/WebSocket server to 127.0.0.1 by default. The bind address is read from openwolf.dashboard.bind in .wolf/config.json (new optional field), defaulting to "127.0.0.1" when the field is absent so existing installs become loopback-only on restart. 2. Adds a verifyClient check on the WebSocket upgrade that allows same-origin connections (dashboard loaded from http://127.0.0.1:<port> or http://localhost:<port>) and non-browser clients (no Origin header), while rejecting any other Origin. 3. Logs a warning when the dashboard is bound to a non-loopback address, to make the security implication explicit for anyone who sets bind: "0.0.0.0" on purpose. 4. Documents the new default and the daemon.dashboard.bind opt-in in the README. Users who were intentionally exposing the dashboard to their network will need to set "bind": "0.0.0.0" under openwolf.dashboard in their .wolf/config.json after upgrading.
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.
Hey — I installed OpenWolf a few days ago and really like what it's doing with token tracking and the anatomy map. Spotted something on first install that I think needs attention, patch attached.
The problem
The dashboard server binds to
0.0.0.0by default and has no authentication. That means anyone else on the same network — cafe WiFi, coworking space, hotel, shared office — can just:and get back the raw contents of
cerebrum.md,memory.md,buglog.json,token-ledger.json, andsuggestions.json. Those files accumulate everything OpenWolf learns about your codebase over time, so whatever the tool has seen is readable by anyone on the subnet.The same attacker can
POST /api/cron/run/:taskId(or send atrigger_taskWebSocket message) to execute any task from the victim'scron-manifest.json. Forai_taskactions that's a remote trigger for a fullclaude -psession in the project root — a Claude Code run with tool access to the repo, git, and whatever credentials the shell holds.I saw this on a fresh
openwolf initon macOS (Node 22). First thing I checked waslsof -nP -iTCP:18791 -sTCP:LISTENand the daemon was on*:18791, bound to every interface, no config flag to opt out.The fix
The real fix is one argument: pass
"127.0.0.1"toapp.listen()so the dashboard binds to loopback instead of the wildcard. That's the headline change —src/daemon/wolf-daemon.ts:190, one extra string.Two small things alongside it:
openwolf.dashboard.bindconfig option, default"127.0.0.1"— anyone who was intentionally exposing the dashboard to their LAN can set it to"0.0.0.0"in.wolf/config.jsonto opt back in. The daemon logs aWARNat startup when bound to a non-loopback address so the security implication isn't silent.verifyClienton the WebSocket upgrade — a defense-in-depth hardening so a webpage the user happens to visit in a browser can't drive the daemon cross-origin viaws://127.0.0.1:18791(the REST routes are already protected by same-origin policy on non-GET, but WebSocket upgrades ignore it). Same-origin requests and non-browser clients with noOriginheader stay allowed.No auth tokens, no shared secrets, no dashboard-client changes, no migration for the common case. Existing installs become loopback-only on next restart; the CLI callers already target
127.0.0.1explicitly (seecli/cron-cmd.ts,cli/dashboard.ts,cli/daemon-cmd.ts) so nothing regresses for them.One scope question
I put the new option under
openwolf.dashboardbecause that's whereportalready lives anddashboard.portis what the daemon actually reads.daemon.portin the current config looks like dead code. Happy to rename or move the field if you'd rather have it somewhere else.Testing
Verified against both
mainand this branch. Environment: macOS (Darwin 25.3.0), Node 22.13.1. Built withpnpm build, thennode dist/src/daemon/wolf-daemon.jsagainst a scratch.wolf/.main:lsof -nP -iTCP:18791 -sTCP:LISTENafterpnpm buildTCP *:18791 (LISTEN)— bug confirmed.curl --interface <lan-ip> http://<lan-ip>:18791/api/healthreturns{"status":"healthy",...}from the LAN interfacelsofTCP 127.0.0.1:18791 (LISTEN)onlyhttp://127.0.0.1:18791/curl http://127.0.0.1:18791/api/healthand/api/projectcurl --interface <lan-ip> http://<lan-ip>:18791/api/healthConnection refusednew WebSocket(..., { origin: "http://evil.example" })and{ origin: "http://localhost:8000" }Rejected WebSocket upgrade: origin=<origin>{ origin: "http://127.0.0.1:18791" }and{ origin: "http://localhost:18791" }originoptiondashboard.bind: "0.0.0.0", restartlsofshows*:18791; LANcurlreturns 200; log containsDashboard server listening on 0.0.0.0:18791followed byWARN Dashboard bound to 0.0.0.0 — ... None of these endpoints require authentication.curl -X POST http://127.0.0.1:18791/api/cron/run/<id>from loopback{"status":"ok","task_id":"<id>"}; daemon log:Executing task: ...— local triggering unaffectedReproduction for row 1 if you want to verify independently:
ipconfig getifaddr en0for the LAN IP, thencurl -sv --max-time 3 --interface <lan-ip> http://<lan-ip>:18791/api/health. Onmainthis returns healthy JSON; on this branch it'sConnection refused.