Skip to content

fix(migrate): require a per-run token and a known Host, and escape quotes in the review page - #696

Merged
cubehouse merged 3 commits into
mainfrom
fix/migrate-tool-auth
Nov 1, 2026
Merged

cubehouse merged 3 commits into
mainfrom
fix/migrate-tool-auth

Conversation

@cubehouse

@cubehouse cubehouse commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Summary

The migration review server (npm run migrate -- <park>) commits entity id changes with the operator's wiki credential, and it listens on every interface so it can be reviewed from another machine. It answered anyone:

  • A machine on the network could POST /api/mappings/:i to pair and confirm, then POST /api/commit.
  • /api/commit ignores its request body, so a plain cross-site POST from any page open in the operator's browser could start a commit. No preflight, no CORS needed.
  • The review page also built HTML from park and wiki names and ids with an escape that left double quotes alone, then used the result inside quoted attributes (value="..." data-name="..."). A name containing a quote could add an event handler. The page is same-origin with the commit endpoint.
request
  migrateAccessGuard (src/tools/migrate/access.ts)
    Host / Origin name this machine, MIGRATE_HOST or MIGRATE_ALLOWED_HOSTS?  no -> 403
    GET /?token=<run token>   -> Set-Cookie (HttpOnly, SameSite=Strict), 302 /
    cookie matches the token? no -> 401
  express.json, routes
listen(port, '0.0.0.0')     # unchanged
  • Per-run token, printed in the address the tool already prints. Compared in constant time. Reviewing from another machine still works.
  • MIGRATE_ALLOWED_HOSTS (comma separated hostnames, no ports) for opening the page by an address other than MIGRATE_HOST or the machine name. The 403 says so. Documented in the header of src/tools/migrate/index.ts.
  • esc() is now plain string work that also escapes " and '. The row and progress-line templates moved into renderRowHtml and renderProgressLine, and the progress line (ids were unescaped) and the entity type now go through esc().
  • startMigrationServer is split into createMigrationApp (no listen) and the listener, so the access rules are testable. The default bind is unchanged.
  • The Host and Origin check from fix(web-admin): listen on loopback and refuse foreign Host and Origin headers #693 is now refuseForeignHostOrOrigin, shared by both servers.
  • The web admin CORS policy now validates the origin in its own callback instead of reflecting it (origin: true), so it no longer depends on the check running before it. Behaviour is unchanged.

Evidence

  • Before: POST /api/commit from any host or origin was served. After: the real startup code on a spare port, probed with curl:
no token, page                          -> 401
no token, POST /api/commit              -> 401
token URL                               -> 302 (cookie set)
cookie, page / api/mappings             -> 200 / 200
cookie, POST /api/commit (no wiki conf) -> 400 (handler reached)
cookie, foreign Host                    -> 403
cookie, foreign Origin, POST commit     -> 403
  • New tests: access.test.ts (token handshake, cookie flags, wrong token and cookie, foreign Host and Origin, a pairing change that must not apply without the cookie, no CORS headers) and uiEscaping.test.ts (the page script run in a vm context; hostile names and ids go through the dropdown, row and progress-line builders).
  • Mutation check, each fails at least one test: no cookie check, token compare always true, no Host and Origin check, guard not mounted, cookie without SameSite=Strict, cookie without HttpOnly, esc() keeping quotes, an unescaped progress id, an unescaped entity type.
  • Full suite: 189 files, 4194 tests pass. npm run typecheck is clean.

Merge Danger

Door: two-way. Revert the commit.

Blast Radius: people who run npm run migrate. Old bookmarked addresses stop working because the token changes every run: open the address the tool prints. Opening the page by an IP address or a name other than MIGRATE_HOST or the machine name needs MIGRATE_ALLOWED_HOSTS. The page script is covered by string-level tests, not a browser, so give the review page one look in a browser after merging.

🤖 Generated with Claude Code

…otes in the review page

The migration review server commits entity id changes with the operator's wiki
credential and listens on every interface, but answered anyone: a machine on
the network could pair and commit, and a web page open in the operator's
browser could start a commit with a plain cross-site POST.

- Refuse a request whose Host or Origin does not name this machine, the name
  the tool prints, or MIGRATE_ALLOWED_HOSTS.
- Make a random token for each run. The printed address carries it once; the
  server swaps it for a SameSite=Strict HttpOnly cookie and redirects to a
  clean URL. Every other request needs the cookie.
- The review page built HTML from park and wiki names and ids with an escape
  that left double quotes alone, then used the result inside quoted attributes.
  A name containing a quote could add an event handler, and the page can start
  a commit. Escape quotes, escape the fields that were not escaped at all, and
  move the row and progress templates into functions the tests can call.
- Split startMigrationServer into createMigrationApp (no listen) and the
  listener so the access rules are testable. The default bind is unchanged.
- Share the Host and Origin check with the web admin server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Comment thread src/webServerSecurity.ts Fixed
Moving the Host and Origin check into its own function meant the CORS policy
reflected any origin and relied on a check elsewhere. Validate in the origin
callback as well so the policy stands on its own. Also match the script tag
case-insensitively when the escaping test reads the review page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…regex

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@cubehouse
cubehouse merged commit 9aff98a into main Nov 1, 2026
4 checks passed
@cubehouse
cubehouse deleted the fix/migrate-tool-auth branch November 1, 2026 15:13
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.

2 participants