Repository navigation
fix(gate): bounce cross-site navigations so dsh's Strict cookie is sent - #111
Merged
Merged
Conversation
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
… into verify chain and CI
…tructure docs and README
refactor: layered src with barrels + framework verify gates + decision records
… accidental main commits)
…lay guard, user totp CLI)
* 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¬ice=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).
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.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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=Laxcookie but not dsh'sSameSite=Strictsession 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
GETwithSec-Fetch-Site: cross-siteandSec-Fetch-Dest: document, the guard serves a static bounce page whose only script islocation.replace(location.href)(pinned by a CSP sha256,no-store,no-referrer, no request data reflected). The retry issame-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
/apiRPC is POST-only, host GET routes are read-only, and/?token=still needs the launch token.Verification
npm run verifypasses (1060 tests, coverage 92.91% statements).src/gate/guard.cross-site.test.ts) and real-stack integration tests (src/integration.cross-site.test.ts).lib/: cross-site click lands same-origin, F5 stays OK, Back returns to the source page in one step.