Skip to content

fix(security): one request guard for the dashboard — no write from another site, no other host name - #152

Open
UltimaPhoenix wants to merge 1 commit into
developfrom
fix/dashboard-request-guard
Open

UltimaPhoenix wants to merge 1 commit into
developfrom
fix/dashboard-request-guard

Conversation

@UltimaPhoenix

Copy link
Copy Markdown
Owner

Point 1 of the UI/UX review. Supersedes #129, closed on 1 Nov to go through the plan one point at a time. Same change, rebased on develop; its CHANGELOG entry now follows the Windows and docs entries already under Unreleased.

Summary

While devcoach ui runs, any web page could change the dashboard: several routes that write had no cross-site check — /settings, /settings/notebook, /settings/import, /knowledge*, /groups*, /lessons/:id/star, /lessons/:id/feedback. A page could, for example, rewrite the coaching notebook the agent reads through get_briefing before every lesson, or change the pacing (nudge_every, max_per_day).

The docs share page makes it concrete: once Chrome has been allowed Local network access for the dashboard probe, any script on https://ultimaphoenix.github.io (every Pages site of the account shares that origin) can reach 127.0.0.1 — and a top-level form POST needs no permission at all.

One app.use now runs before every route (refusal in src/web/app.ts):

  • Writes from another site — any method but GET/HEAD/OPTIONS whose Sec-Fetch-Site is not same-origin/none (same-site included: another server on 127.0.0.1) → 403.
  • Another host name — Host other than 127.0.0.1 / localhost / [::1] → 403, reads included. Without it, DNS rebinding makes a foreign page same-origin with the dashboard and walks past the first check (and reads /settings/export, the notebook, the lessons).

Before / after

Slideshow — each screen BEFORE (red band: without this PR), then AFTER (green band: with this PR), with one line saying what changed. It plays by itself; click it to open it full size.

pr-129-slides-light
Dark pr-129-slides-dark

Side by side — BEFORE on the left, AFTER on the right.

A form on another site posts to your dashboard: before 200, the write goes through; after 403 Forbidden.

pr-129-side-1-light
Dark pr-129-side-1-dark

Your notebook right after: before rewritten by the other site; after unchanged.

pr-129-side-2-light
Dark pr-129-side-2-dark

The share page's own import: saved by your own click, before and after.

pr-129-side-3-light
Dark pr-129-side-3-dark

Videos — the first frame is the title; a full red BEFORE or green AFTER card opens every segment, and the band in the same colour stays above the recording until the segment ends.

Light

pr-129-before-after-light.mp4

Dark

pr-129-before-after-dark.mp4

Same script both times, against a sandbox dashboard (screenshot fixture, never a real ~/.devcoach), in Chrome 156 with local network access allowed for the docs site:

the docs share page submits a form to /settings/notebook the share page's own Import
before (develop, 2.7.1) 200 — the notebook is overwritten, no click on the dashboard works
after (this branch) 403 — the notebook is unchanged works: probe ✓, preview, Add to my lessons

Why the share page keeps working

It only reads: GET /ping (+ its OPTIONS preflight, still CORS-locked to the docs origin) and the link to the GET /lessons/import?code= preview. The lesson is saved by the preview's own same-origin POST.

Also

  • The per-route checks on writes are gone (one guard), and with them the import's error=cross redirect, which no devcoach surface can reach. The share route keeps its own check for cross-site GETs.
  • CHANGELOG (Security), docs/reference/privacy.md (The dashboard answers only itself), CLAUDE.md.
  • Suggest releasing 2.7.2 right after merge: this PR describes the issue publicly.

Test plan

  • npm run lint && npm run typecheck && npm run test:cov — 389 tests pass; coverage 94.83 % lines · 93.60 % statements · 96.84 % functions · 84.18 % branches (thresholds 92 / 91 / 95 / 76)
  • New web request guard suite: loops over app.routes (a future write route cannot miss the guard) from cross-site and same-site with the docs Origin — notebook, settings, knowledge unchanged; the share page's reads (/ping, preflight, preview, then the same-origin Add) pass; rebinding names refused for reads and writes; a real socket with a forged Host header
  • With the guard disabled, the three guard tests fail (the share-flow test passes either way, by design)
  • Recorded before/after in light and dark (above)

🤖 Generated with Claude Code

…ther site, no other host name

Several dashboard routes that write had no cross-site check: /settings, /settings/notebook,
/settings/import, /knowledge*, /groups*, star and feedback. While `devcoach ui` ran, any web page
could submit a form to them — e.g. rewrite the notebook the agent reads through get_briefing, or
change the pacing. The docs share page makes it concrete: once Chrome has been allowed local network
access for the probe, any script on that origin can POST to 127.0.0.1 (and a top-level form POST
needs no permission at all).

One `app.use` now runs before every route (`refusal`):
- a method other than GET/HEAD/OPTIONS whose Sec-Fetch-Site is not same-origin/none → 403;
- a Host other than 127.0.0.1 / localhost / [::1] → 403, reads included (DNS rebinding would
  otherwise make a foreign page same-origin with the dashboard and bypass the first check).

The share page keeps working: it only reads (GET /ping + its OPTIONS preflight, the link to the
GET /lessons/import preview); the lesson is saved by the preview's own same-origin POST. The
per-route checks on writes go away, and with them the import's "cross" redirect, which no
devcoach surface can reach. The share route keeps its own check for cross-site GETs.

Tests loop over app.routes so a new write route cannot miss the guard, cover the share page's
reads, the rebinding names, and a real socket with a forged Host header.

This branch has not been deployed

No deployments
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