Skip to content

fix(gate): bounce cross-site navigations so dsh's Strict cookie is sent - #111

Merged
TecFancy merged 118 commits into
mainfrom
development
Oct 13, 2026
Merged

TecFancy merged 118 commits into
mainfrom
development

Conversation

@TecFancy

Copy link
Copy Markdown
Owner

Problem

Opening dsh web through a link from another site (or another host such as 127.0.0.1) showed "dsh web authentication required; reopen the URL printed by dsh web." Reloading did not help; only a fresh tab did.

A cross-site top-level navigation sends the gate's SameSite=Lax cookie but not dsh's SameSite=Strict session cookie, so dsh answers 401. Chrome keeps the cross-site context on reload, so F5 fails the same way.

Fix

For a request the gate allows, when it is a GET with Sec-Fetch-Site: cross-site and Sec-Fetch-Dest: document, the guard serves a static bounce page whose only script is location.replace(location.href) (pinned by a CSP sha256, no-store, no-referrer, no request data reflected). The retry is same-origin, so the Strict cookie is sent and the bounce cannot loop. Denied requests and WebSocket upgrades are unchanged.

Trade-off (recorded in D29): for cross-site top-level GET navigations, dsh's Strict protection is effectively Lax. Acceptable because the /api RPC is POST-only, host GET routes are read-only, and /?token= still needs the launch token.

Verification

  • npm run verify passes (1060 tests, coverage 92.91% statements).
  • New unit tests (src/gate/guard.cross-site.test.ts) and real-stack integration tests (src/integration.cross-site.test.ts).
  • Browser end to end against the built lib/: cross-site click lands same-origin, F5 stays OK, Back returns to the source page in one step.

A blank session (New Session, no input/response yet) has current set in the session snapshot AND a hidden session header, so both sign-out entries were suppressed there. The hero shell.overlay entry now shows when the current session is undefined OR still blank (byId[id].blank), hiding only for a real non-blank session (header button takes over).
Replace the stale sidebar-foot screenshots with two real captures from production: the blank new-session page (floating top-right sign-out, dsh workspace collapsed) and a real English conversation (sign-out right of the Session log). Update README.md and README.zh.md to show the new top-right placement; remove the obsolete logout-sidebar-*.png files.
dsh plugin add installs the package into the profile's node_modules; the dsh-auth bin is not added to PATH, so README and deployment docs now call it through the profile (pnpm --dir ... exec dsh-auth) and gain a Troubleshooting section. Fixes #44.
…sh-Proxy deny-list (#47)

* feat(proxy): authenticated local proxy bin

* feat(guard): X-Dsh-Proxy deny-list for host-native methods

* docs: local proxy and deny-list deployment sections (zh+en) with systemd example

* test(proxy): cover CLI parsing and upgrade header/token branches

* docs: README local-proxy section and English design doc
dsh only auto-discovers project skills from .agents/skills/ (and
.dsh/skills/); the skills/ directory was never picked up despite the
AGENTS.md claim. Move both skills there and fix the description.
- scripts/run-gates.mjs (shared from the framework): named scenarios
  derived from the verify chain; auto-detects the change surface and
  runs the minimal evidence set, fail-closed to the full set
- pre-push runs npm run gates -- --base @{u} instead of only type-check
- CI: split the monolithic check job into hygiene/quality/build
  parallel jobs (no path filters - full coverage always)
- docs/development.md: document gates and the new pre-push
# Conflicts:
#	README.zh.md
#	lib/client.js
#	lib/client.js.map
#	lib/client/index.d.ts
#	lib/client/index.d.ts.map
#	lib/proxy-headers.d.ts
#	lib/proxy-headers.d.ts.map
#	lib/proxy-headers.js
#	lib/proxy-headers.js.map
#	lib/proxy.d.ts.map
#	lib/proxy.js
#	lib/proxy.js.map
#	src/client/index.tsx
#	src/client/logout-action.test.tsx
#	src/proxy-headers.test.ts
#	src/proxy-headers.ts
#	src/proxy.ts
refactor: layered src with barrels + framework verify gates + decision records
* feat: self-service password change in the settings panel (P1)

Adds POST /auth/password (password mode only, 4th exact route) so a signed-in
user can rotate their own password from a new "Account" settings section,
plus the CLI equivalents and the store hardening they both need.

Server:
- password-policy.ts: >=14 chars, upper/lower/digit/special, maxLength 256,
  non-equal to the old password (length -> classes -> old hash, cheapest first)
- password-change.ts: frozen order (405 -> 415/413 -> session 401 -> own rate
  bucket 429 -> users read 503 -> constant-time current check 401 -> TOTP 401
  -> policy 400 -> hash -> locked CAS write 503 -> revoke every session
  including the current one -> clear cookie -> 200 {"ok":true})
- files are written before sessions are revoked; a failed write never revokes
- TOTP shares the login replay guard singleton (same-window reuse = 401
  invalid_totp, documented); every TOTP failure counts toward the change bucket
- success clears must_change_password; CLI user passwd deliberately does not

Store:
- users.yaml mutations go through mutateUsersFile: separate users.yaml.lock
  (owner token + compare-and-unlink, stale/corrupt takeover), locked
  read-modify-write, pre-write mtime/size CAS with <=3 reruns, .bak, and the
  last-admin invariant; CLI (incl. user totp) shares this single entry point

CLI: user passwd (reads two lines in one readline session, refuses --password),
user role <name> <admin|user>, user add --admin, list shows (admin)

Client: settings.section page (id dsh-auth-gate-account, order 500) with a
four-field form, submission state machine, a11y (autocomplete, aria-live,
aria-invalid) and --dsw-* tokens only

Docs: D22/D23 bilingual ADRs, decisions.md, impl-m2 (3 -> 4 exact routes),
impl-m3 (CLI amendments), README/deployment notes incl. known residuals

Verified: npm run verify (10 gates) green, real-HTTP integration tests,
TOTP real-stack tests, and a 15/15 Playwright E2E against an isolated instance

* feat: leave the panel for the login page after a password change

P1.1 follow-ups on top of 8204ce8 (no change to the frozen
`POST /auth/password` contract):

- success keeps its copy for 2.5s, then the device is sent back with
  `location.replace("/auth/login?next=%2F&notice=password-changed")`;
  the button becomes "Sign in again" and takes focus; the pending
  redirect lives in module scope, so the host closing the panel (or the
  section unmounting) no longer cancels it
- `GET /auth/login` maps a whitelisted `notice` key to a compiled-in
  copy in its own `role="status"` slot (password card only: the TOTP
  page, 401/429 pages and every POST re-render stay untouched)
- settings nav identity: label -> "账号安全 / Account security",
  order 500 -> 900, section id unchanged (the host's 0.1.7 cloud-account
  section owns `id: "account"`)
- the plugin now draws its own shield+keyhole mark inside the panel
  (host `settings.section` has no icon option; we do not patch the
  host's nav DOM)
- README (zh/en) screenshots, ADR D24 with migration conditions, and
  source-level pins for the cross-half literals

* test: pass a file URL to --import so the real-CLI smoke test runs on Windows

Node treats a bare `C:\…` specifier as scheme `c:` and aborts with
ERR_UNSUPPORTED_ESM_URL_SCHEME, so both real-subprocess cases failed on
windows-latest while Linux stayed green. The entry script keeps taking a
path (Node converts it itself; a URL there trips the resolve hook).

* feat: mark our settings row and leave for the login page at once

D24.1, after the owner reviewed PR #91:

- success navigates to /auth/login?notice=password-changed as soon as the 200
  arrives (no 2.5 s grace period); the success panel and its "Sign in again"
  button stay as the fallback when navigation is refused, and a refused
  navigation can no longer be reported as a failed change
- account-nav-icon.ts: a temporary DOM stopgap that replaces the host gear in
  our nav row only (label match plus a direct-child svg), self-heals across host
  re-renders, observes childList plus the inline style attribute, and restores
  the row on dispose. It is the one piece of host-DOM coupling; it is deleted
  once upstream ships a settings.section icon option (migration table in D24.1)
- account-redirect.ts shrinks to the URL constant plus redirectToLogin()
- README assets (nav-rail shot), ADR D24.1 in both languages, decisions index

Evidence: npm run verify exit 0 (76 files / 605 tests, client bundle 45.1 KiB);
isolated instance 14/14 with a per-frame rAF probe (the first frame that shows
the row already carries our shield, zero gear frames) and
responseEnd -> navigationStart = 0 ms; grok two rounds, both must-fixes closed.

* docs: split README images by locale and make the two READMEs mirror each other

D24.2, after the owner reviewed the bilingual READMEs:

- screenshots are locale-suffixed (*.en.png / *.zh.png) and re-shot in the light
  theme: the dashboard, the settings nav rail and the change-password panel now
  exist in both languages; the server-rendered login / TOTP / post-change cards
  stay a single shared asset because the plugin renders them in English
- both READMEs are rebuilt on one skeleton (15 sections): pitch, maintenance
  commitment, contents, what it does, what it does NOT do, quick start,
  screenshots, configuration, CLI, bundled skill, troubleshooting, deployment,
  local proxy, requirements, limitations, development, license
- the full limitation list (mechanisms + ADR references) moves to
  docs/deployed/known-limitations.{md,_zh.md}; the Chinese "safe by default"
  bullet regains the behaviour details the English one always had
- new gate scripts/verify-readme-parity.mjs (npm run readme:parity, wired into
  npm run verify): section count, per-section shape (list items, code fences,
  h3), image count + existence + locale discipline, link targets with language
  suffixes collapsed, and the contents list against the heading slugs

Evidence: npm run verify exit 0 (docs 105 files, README parity 15 sections /
6 images / 11 link targets, 76 test files / 605 tests, bundle 45.1 KiB).

* docs: use one English screenshot set and rewrite the Chinese README

D24.3, after the owner reviewed the bilingual READMEs:

- screenshots are English-only: both READMEs reference the same light-theme
  assets, the four *.zh.png files are deleted (including the archived
  fallback-state shot), and the parity gate now requires identical image sets
  and rejects any *.zh.png under docs/demo/
- README.zh.md is rewritten in native Chinese technical prose instead of a
  sentence-by-sentence translation (same skeleton, same facts, same links);
  the literal transfers are gone (version corridors, fix regressions, login
  card, issued sessions, orthogonal, tunneling, slot index)
- reviewed by grok-4.6 in two rounds: 28 Chinese must-fixes + 13 English
  suggestions + 12 fact checks; all applied except two items with stated
  reasons (keep the term 垫片 with a stopgap gloss; keep the maintenance
  commitment sentence, only the dash becomes a comma)
- the parity gate also compares table rows and column counts now, which
  immediately caught the Chinese configuration table missing the
  clientIpHeader and trustedProxyCidrs rows
- fact-checked against the implementation, fixed on both sides: the proxy
  strips Secure from Set-Cookie, login and the self-service change have
  separate lockout buckets, a failed revoke is logged at error level, and
  user disable drops issued sessions through the periodic sweep
  (revokeSweepMs, 5 s by default) instead of leaving them until the TTL
- package.json: the description now names DeepSeek Harness and the
  self-service password change, plus the deepseek-harness keyword (the
  registry text changes on the next publish)

npm run verify: 12 gates green (parity 15 sections / 6 images / 11 link
targets, docs 105 files, 605 tests).
…#94)

Adds the admin plane to the password login mode and the restricted session
a reset forces on the victim.

Admin plane (password mode only; token mode still registers three routes):
- GET /auth/users lists local users (name, role, disabled, totpEnabled,
  mustChangePassword) in dictionary order, never hashes, salts, scrypt
  parameters or session counts, with cache-control: no-store
- POST /auth/users/password enforces one documented order: method,
  content type and size, Origin, session, admin role re-read from
  users.yaml per request, restricted-session refusal, rate-limit bucket,
  target validation, policy, conditional TOTP, read-modify-write under
  the users lock, revocation of the target's sessions (never the actor),
  then {ok, sessionsRevoked}
- reset refuses a self-reset before the TOTP step, and a failed revoke
  still answers 200 while the password change is already durable

Restricted session:
- a must_change_password login issues kind "password-change-only"
  (15 minutes, no renewal) and drops `next` plus the launch-token bridge
- the gate trusts session.kind alone, never the live yaml flag: clearing
  the flag while a revoke fails cannot promote the cookie to a full session
- the allow set is GET|POST /auth/password, GET /auth/status and
  POST /auth/logout; GET /auth/login redirects to the password page,
  POST /auth/login is 403, the host plane is denied with a navigation-aware
  decision (302 for documents, 401 for API clients, refused WS upgrade)

Origin and audit:
- checkRequestOrigin guards the two authenticated POSTs using
  Sec-Fetch-Site: same-origin or an exact match against the configured
  publicHost, and never falls back to Host; Origin: null, subdomains and
  same-site are refused, and /auth/login stays out of scope by design
- navigation detection uses Sec-Fetch-Mode/Dest rather than Accept
  substrings, and Location stays an in-site relative path
- audit.user.password_reset and .denied carry a fixed ten-key payload; an
  unauthenticated actor is always info and target is clipped to 64
  characters, so anonymous requests cannot raise error-level alerts or
  inject unbounded text into the audit log
- the CLI gains `user enable`, so an operator can undo a lockout without
  hand-editing users.yaml

Docs: ADR D25 in en and zh, the impl-m2 admin contract (including the
TLS-terminating-proxy requirement that publicHost carries a scheme),
known limitations, and both READMEs. The admin-plane material is sliced out
of impl-m2 into a new impl-m2-admin pair so the frozen spec stays under the
50 KiB documentation red line.
Ship the client half of the P2 admin plane: the Settings panel's Account
security page now lists local users and resets another user's password, for
an administrator on a full session only. No server code is touched.

- render gate: role=admin, disabled=false, sessionKind=full and a string name,
  all read from the additive /auth/status fields. Anything else (older server,
  restricted session, disabled admin, partial payload) renders no block and
  issues zero /auth/users requests: the panel never probes the admin API to
  find out whether it may use it.
- list: server order, name/role/disabled/must-change/two-factor badges, the
  actor's own row marked, and a target picker that excludes the actor
- reset form: new password twice with autocomplete=new-password, a code field
  rendered only when the acting admin has TOTP, success clears every field in
  place and re-fetches the list so the must-change badge stays truthful; a
  failed session revocation is an assertive alert, not a green success
- failure mapping is a total function over (status, error) with a generic
  fallback for the text/plain 413/415/503 responses, and 200 {ok:true} is the
  only accepted success shape (a proxy page or {ok:false} is reported as a
  failure instead of wiping the on-screen password)
- fixes from both review tracks: React StrictMode double-mount dropped every
  submit result and hung the button, res.ok accepted any 2xx, the t-less
  fallback dictionary lacked the admin keys, an unknown totpEnabled flag
  omitted the code, and Enter inside the target select submitted the form

Docs: impl-m2-admin 4.5c/5c plus the D25 slices that now live there,
known-limitations, both READMEs and two English screenshots.
dsh 0.2.0's plugin gate reads only @deepseek-ai/dsh* peerDependencies (not engines.dsh);
an undeclared 0.2.x host is refused at install and silently skipped at boot (login gate
fail-open). Declare the corridor in both places, move the dev/test host cohort to 0.2.0-rc2,
pin it with the gate's own semver predicate, and make the entry-coverage probe assert
302 -> /auth/login. No runtime source change. Evidence: 968 tests green, isolated no-exemption
boot, 67/67 guarded entries denied; grok-4.6 review (2 rounds) in docs/reviews.

Full detail: #96
… obsoleted

The Account security panel shot predates the user management block (#95), so it ended at the
Change password button while the README right below it says an administrator sees the block
below that form: the image and the prose disagreed. Retaken in the isolated dsh 0.2.0-rc.2
instance (English UI, publicHost dsh.example.com, no test port in frame) as a single
full-panel shot carrying the form, the user list and the reset form.

- account-change-password.en.png: whole panel, administrator view (752x1387)
- admin-reset-success.en.png: the block after a reset, now on the 0.2.x host shell
- account-nav-icon.en.png: 0.2.x nav rail, same 188px width as before
- login-password-changed.png: host is dsh.example.com like the other two login cards;
  the 127.0.0.1:3086 test port is gone
- delete admin-users-panel.en.png: the full-panel shot already covers its content

Docs only: src/, lib/ and the published runtime behaviour are untouched.
release-please builds the CHANGELOG entry and the GitHub Release body from the subject line of
each commit on main, so a promotion PR squashed into one commit yields exactly one entry (and
docs/chore commits never appear). Spell out the two ways out - promote one change per PR, or
curate the release page after the tag - and mark the release body as the only sanctioned
hand-edit, since CHANGELOG.md itself stays generated.
release-please renders a trailing (#n) as the discussion link in the generated changelog entry,
and a batched promotion gets exactly one entry, so that link is the only context a reader can
follow. Guard against a title that already carries the number.
The package declared no icon, so the plugin manager drew its own default
artwork for this row. The harness reads a relative `icon` path from the
exported package.json, admits five extensions under 256 KiB that stay inside
the package after realpath, and inlines the file as a data URL; the manager
renders it in an <img> and falls back to its own artwork, silently, whenever
any of that is wrong.

- icon.svg: the account row's shield and keyhole, redrawn as a filled badge so
  it reads at the 30px row and 36px card sizes. Colours are explicit on
  purpose: a data URL in an <img> inherits neither currentColor nor the theme.
- package.json: declare the icon, and ship it in `files`.
- src/shared/manifest-icon.test.ts: the four ways this fails quietly, as red
  tests (absolute path, unknown extension, outside the package or over the cap,
  missing from `files`, and a file that decodes but draws nothing).
The entry guard compared `import.meta.url`, which is the real file, with
`process.argv[1]`, which is the symlink the package manager puts in `bin/`.
For a packaged install the two strings never matched, so `main()` was never
called, and `dsh-auth-proxy` exited 0 while printing nothing: no listening
socket, no error, no clue for the user.

Resolve the argv path with `realpathSync` before comparing. A path that does
not resolve now counts as "not this entry" instead of throwing, because the
guard runs on module import, so an importer (a test runner, or `node -e`)
must not crash over its own argv.

The dsh-auth CLI resolved symlinked entries in 0.6.1; this bin arrived in
0.8.0 without that resolution.
#102)

The manager falls back to the package name because this package declares no
display metadata. The harness resolves `<package>/locale/en.json` (required
before any scanning happens), reads `meta.title` and `meta.description` from it
and from every other `<language>.json` beside it, and falls back to the package
name and description for whichever language is missing. The reader goes through
the package's exports map, so an unexported locale path is reported to it as
missing, and `files` decides what npm actually publishes.

- locale/en.json ("Auth Gate") and locale/zh.json ("登录门禁"), each with a
  one-line summary.
- package.json: export and publish `./locale/*.json`.
- src/shared/manifest-locale.test.ts: the ways this fails quietly as red tests
  (no en.json, a filename that is not a language id, an empty title or
  description, an unexported or unpublished locale directory).
)

* fix(token): bridge the dsh launch token after a token-mode sign-in

* fix(token): sanitize the bridged location, share the fallback rule, and lock the assembly seam

* test(token): close the coverage gaps the second review pass listed
The wrapServer disposer used to clear the route tables and write back a
snapshot taken at wrap time. Reloading dsh-auth-gate alone (a config
change in the Plugins page) therefore dropped routes other plugins had
registered after the wrap, resurrected routes whose owner had been
disposed, and made that owner's next load throw "duplicate exact route".

Unwrap now swaps an entry back to its original handler only while it
still holds a guard this wrap produced (tracked in per-wrap WeakMaps).
Deleted entries stay deleted, foreign replacements are left alone, and
post-wrap routes survive with the guard removed.

Adds a real cordis + webserver reload repro and per-entry unit tests,
records the change as decision D27 (superseding impl-m1 D6's snapshot
clause), and updates the code-review skill rule that asked for an exact
pre-wrap restore.
`engines.dsh` and the `@deepseek-ai/dsh-storage-domain` peer both end in
`>=0.2.0-rc.1` now; the three caret alternatives stay as the record of the
host lines that were actually verified. A hand-added alternative always
lags a dsh release, and a rejected bundle is skipped silently, so the login
gate would disappear rather than fail loudly. The guard test now asserts the
floor covers every later host version, and the readme, specs and D27 record
the trade-off. No source change (D27).
…rollback)

Brings in the guard unwrap rollback fix (#107) and the 0.17.1 release. That
fix's decision record already holds D27, so the corridor-floor record written
on 2026-10-07 is renumbered D28 and its references follow.
A top-level GET that arrives cross-site (Sec-Fetch-Site: cross-site,
Sec-Fetch-Dest: document) carries the gate's Lax cookie but not dsh's
SameSite=Strict session cookie, so dsh answered 401 and a reload kept the
cross-site context. For requests the gate allows, serve a static bounce page
that re-navigates via location.replace(location.href); the retry is
same-origin, so the Strict cookie is sent and the bounce cannot loop.
@changeset-bot

changeset-bot Bot commented Oct 13, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 0eb3eed

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@TecFancy
TecFancy merged commit ea84e6b into main Oct 13, 2026
12 checks passed
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