Skip to content

security: dashboard exposes .wolf/ to the local network by default#16

Open
smk508 wants to merge 1 commit intocytostack:mainfrom
smk508:fix/daemon-bind-loopback
Open

security: dashboard exposes .wolf/ to the local network by default#16
smk508 wants to merge 1 commit intocytostack:mainfrom
smk508:fix/daemon-bind-loopback

Conversation

@smk508
Copy link
Copy Markdown

@smk508 smk508 commented Apr 11, 2026

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.0 by default and has no authentication. That means anyone else on the same network — cafe WiFi, coworking space, hotel, shared office — can just:

curl http://<your-lan-ip>:18791/api/files

and get back the raw contents of cerebrum.md, memory.md, buglog.json, token-ledger.json, and suggestions.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 a trigger_task WebSocket message) to execute any task from the victim's cron-manifest.json. For ai_task actions that's a remote trigger for a full claude -p session 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 init on macOS (Node 22). First thing I checked was lsof -nP -iTCP:18791 -sTCP:LISTEN and 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" to app.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:

  1. openwolf.dashboard.bind config 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.json to opt back in. The daemon logs a WARN at startup when bound to a non-loopback address so the security implication isn't silent.
  2. verifyClient on 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 via ws://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 no Origin header 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.1 explicitly (see cli/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.dashboard because that's where port already lives and dashboard.port is what the daemon actually reads. daemon.port in 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 main and this branch. Environment: macOS (Darwin 25.3.0), Node 22.13.1. Built with pnpm build, then node dist/src/daemon/wolf-daemon.js against a scratch .wolf/.

# Scenario Result
1 Baseline on main: lsof -nP -iTCP:18791 -sTCP:LISTEN after pnpm build TCP *:18791 (LISTEN) — bug confirmed. curl --interface <lan-ip> http://<lan-ip>:18791/api/health returns {"status":"healthy",...} from the LAN interface
2 Fix branch: same lsof TCP 127.0.0.1:18791 (LISTEN) only
3 Dashboard HTML served at http://127.0.0.1:18791/ HTTP 200, index.html body
4 Loopback HTTP: curl http://127.0.0.1:18791/api/health and /api/project Both 200 JSON
5 LAN HTTP: curl --interface <lan-ip> http://<lan-ip>:18791/api/health Connection refused
6 Cross-origin WS: new WebSocket(..., { origin: "http://evil.example" }) and { origin: "http://localhost:8000" } HTTP 401 on both. Daemon log: Rejected WebSocket upgrade: origin=<origin>
7 Same-origin WS: { origin: "http://127.0.0.1:18791" } and { origin: "http://localhost:18791" } Both open
8 Non-browser WS: no origin option Opens
9 Escape hatch: set dashboard.bind: "0.0.0.0", restart lsof shows *:18791; LAN curl returns 200; log contains Dashboard server listening on 0.0.0.0:18791 followed by WARN Dashboard bound to 0.0.0.0 — ... None of these endpoints require authentication.
10 Cron trigger via direct POST: 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 unaffected

Reproduction for row 1 if you want to verify independently: ipconfig getifaddr en0 for the LAN IP, then curl -sv --max-time 3 --interface <lan-ip> http://<lan-ip>:18791/api/health. On main this returns healthy JSON; on this branch it's Connection refused.

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.
@smk508 smk508 changed the title security: bind dashboard to loopback and reject cross-origin WebSocket upgrades security: dashboard exposes .wolf/ to the local network by default Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant