Skip to content

fix(core): Stop /health revealing the home path - #309

Merged
shafty023 merged 1 commit into
mainfrom
fix/prd-graph-health-path-leak
Oct 19, 2026
Merged

shafty023 merged 1 commit into
mainfrom
fix/prd-graph-health-path-leak

Conversation

@shafty023

Copy link
Copy Markdown
Collaborator

Purpose

#308 (closedloop-core 1.5.0) meant to stop the prd-dependency-graph refresh helper from revealing the user's home path, and with it their username, to websites. GET /health still did. It returned the helper's script path and pid to any request whose Origin was null or loopback. #308's own threat model says Origin null can come from a sandboxed frame on any website, so any site could read the username.

Reproduction (running 1.5.0 helper)

curl -s -i -H 'Origin: null' http://127.0.0.1:8765/health
Access-Control-Allow-Origin: null
{"ok": true, "script": "/Users/<name>/.claude/plugins/cache/closedloop-ai/closedloop-core/1.5.0/skills/prd-dependency-graph/scripts/refresh_server.py", "pid": ..., "protocol": 2}

Fix

  • Only the local --ensure path (ensure_helper and helper_health, run from Python with no Origin) reads script and pid, to recognize and replace a helper from another install. The page reads only protocol from /health (render_graph.py, unreachable()), to tell an out-of-date helper from a missing one.
  • /health now answers any request that carries an Origin (null, loopback, or any other) with {"ok": true, "protocol": 2} only. CORS is unchanged: null and loopback origins can still read it, other origins cannot.
  • script and pid are added only when there is no Origin and the Host header is 127.0.0.1, localhost or [::1]. The Host check covers DNS rebinding: a rebound page's same-origin GET carries no Origin, but its Host is the attacker's name.

Sweep for other paths sent to a browser

  • POST /v2/refresh from a file page already answers only {ok, written, refresh_id}, with no path (pinned by an existing test).
  • Old token endpoint (/refresh) errors are already generic and name no paths (existing test). The 500 handlers on both endpoints send only ~/.local/share/... as a hint.
  • Page dialogs use prd-graph-helper or ~/.local/bin/prd-graph-helper, never an absolute path (existing test asserts the page has no home path).
  • Fixed here: refresh_file's errors named real.name, the resolved symlink target's file name, which the requester never sent. For example, a guessed /.../guess.html symlinked to <user>-private-notes.html answered <user>-private-notes.html holds no refreshable graph. Errors now use the name the request gave.
  • Left alone: the This graph still refreshes from <config path> message is reachable only through --apply-config on the command line, never over HTTP.

Files

  • plugins/closedloop-core/skills/prd-dependency-graph/scripts/refresh_server.py: /health field gating (_from_local_program), error file names in refresh_file
  • plugins/closedloop-core/tools/python/test_prd_dependency_graph_security.py: new tests (below), replacing the old /health test that checked only protocol and CORS
  • plugins/closedloop-core/tools/python/conftest.py: the helper request function takes an optional host
  • plugins/closedloop-core/skills/prd-dependency-graph/SKILL.md: Security section covers /health, the Host check and error file names
  • plugins/closedloop-core/.claude-plugin/plugin.json, .codex-plugin/plugin.json: 1.5.0 to 1.5.1
  • CHANGELOG.md: closedloop-core v1.5.1, Fixed

Tests

  • /health with Origin null, http://localhost:5173, http://127.0.0.1:8000 and https://evil.example returns exactly {ok, protocol}, with no script directory, pid or real home directory in the body (CORS as before)
  • /health with no Origin returns script and pid
  • /health with no Origin and Host: evil.example:8765 returns no script or pid
  • a refresh of a symlink names the requested file, not the target
  • end to end: a helper from a copied "other install" runs on a free port with a scratch HOME. refresh_server.py --ensure from this install recognizes it from /health, replaces it (old process exits, new pid and script), and a second --ensure reports it already running. Both helpers are stopped in teardown.

Each was checked against the wrong code:

  • helper reverted to origin/main: the 4 origin cases, the rebinding test and the symlink test fail (6 failed)
  • script/pid stripped for every request: the no-Origin test and the --ensure test fail
  • Host check removed: the rebinding test fails

Commands run

uv run ruff check .                                   # All checks passed
uv run pyright                                        # 0 errors, 0 warnings
uv run pytest plugins/                                # 2430 passed, 3 skipped
uv run pytest plugins/closedloop-core/tools/python    # 87 passed
HOME=<scratch> python3 scripts/render_graph.py examples/prd-751.json -o <scratch>/prd-751.html
node scripts/check_graph.cjs <scratch>/prd-751.html ISS-10797 <scratch>/prd-751.png             # ok, no errors
# the same example with a refresh block (graph_id prd-751, scope PRD-751), so the file-refresh and served-page checks run
node scripts/check_graph.cjs <scratch>/prd-751-refreshable.html ISS-10797 <scratch>/shot.png    # ok, served swap ok, zoom kept
# manual: worktree helper on port 8799 with a scratch HOME, then stopped
curl -s -i -H 'Origin: null' http://127.0.0.1:8799/health                    # {"ok": true, "protocol": 2}
curl -s -i -H 'Origin: http://localhost:5173' http://127.0.0.1:8799/health   # {"ok": true, "protocol": 2}
curl -s -i -H 'Origin: https://evil.example' http://127.0.0.1:8799/health    # {"ok": true, "protocol": 2}, no ACAO
curl -s http://127.0.0.1:8799/health                                         # adds script and pid
curl -s -H 'Host: evil.example:8799' http://127.0.0.1:8799/health            # {"ok": true, "protocol": 2}

check_graph.cjs ran with PLAYWRIGHT_CORE set to playwright-core 1.60.0 and CHROME_PATH set to the local Chrome.

- GET /health sent the helper's script path (which holds the
  username) and pid to Origin null, which any website can send
  from a sandboxed frame. Requests with an Origin now get only
  ok and protocol, the fields the page reads
- The script path and pid go only to requests with no Origin
  addressed to a loopback host, which is what --ensure sends; a
  DNS-rebound page sends no Origin on a GET but names its own host
- Refresh errors name the file as the request gave it rather than
  a resolved symlink target
- Security tests for each origin, a rebound Host, the symlink
  error, and --ensure replacing a running helper from another
  install
- SKILL.md threat model covers /health; closedloop-core 1.5.1

Testing: pytest plugins/ (2430 passed), ruff, pyright, check_graph.cjs
on freshly rendered pages, curl against a scratch helper on port 8799,
and each new test checked against a reverted or over-trimmed helper

Risks: A helper older than 1.5.0 that only reports script to browsers
is unaffected; --ensure still falls back to lsof when pid is missing
@shafty023
shafty023 merged commit 8511d07 into main Oct 19, 2026
10 checks passed
@shafty023
shafty023 deleted the fix/prd-graph-health-path-leak branch October 19, 2026 20:44
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