dsh-webui-oauth 0.6.5 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,137 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.6.7
6
+
7
+ 安全修复:路由闸门由**黑名单**改为**默认拒绝的白名单**。此前只包 `/api` 与
8
+ `/plugins`,第三方插件注册的读接口完全裸奔。
9
+
10
+ ### 修复:未登录可读任意第三方插件的路由
11
+
12
+ - **现象(实测)**:未登录、不带任何 Cookie,以下端点直接 200:
13
+ - `/dsh-550c-boot/update` —— 版本 / 镜像指纹
14
+ - `/dsh-any-background/font` —— 用户上传字体(12,079,480 B)
15
+ - `/dsh-any-background/wallpaper` —— 用户壁纸
16
+ 与此同时写端点(`/font/upload` 等)因为走 connection RPC 通道反而 401。
17
+ 即典型的「**写保护、读裸奔**」。
18
+ - **原因**:webServer 的匹配顺序是「exact 优先,其次最长前缀」。闸门原来只包装
19
+ `/api` 与 `/plugins` 两条前缀,第三方插件注册的 `/dsh-any-background`、
20
+ `/dsh-550c-boot/update` 等比本插件的 `""` 前缀更具体,会抢在闸门之前命中,
21
+ 于是完全绕过鉴权。黑名单永远追不上「下一个装上的插件」。
22
+ - **修复**:改为**默认拒绝**。安装闸门时枚举并包装 webServer 上的**全部**路由:
23
+ - 所有 `exact` 路由 → 401(白名单/自管端点除外)
24
+ - 所有 `prefix` 路由 → 401(仅本插件自己的 `""` 传输闸门除外,它负责 302 到登录页)
25
+ - **所有** WebSocket upgrade → 一律拒绝(不再只认 `/api/remote.mux`)
26
+ - `fallback` 处理器 → 同样要求会话
27
+ - 并且接管 `exact/prefixes/upgrades` 三张路由表的 `set`,**注册那一刻就上闸门**,
28
+ 消除「新装插件到下一次重扫(最长 10s)之间」的裸奔窗口。
29
+
30
+ ### 公开白名单(能匿名访问的全部路径)
31
+
32
+ `/dsh-webui-oauth/login`、`/setup`、`/oidc/login`、`/oidc/callback`、
33
+ `/oauth/callback`(账号平台 OAuth 回跳,自带 state 常量时间比对)。
34
+
35
+ 其余 `/dsh-webui-oauth/*` 端点(status / audit / configure / disable / logout /
36
+ oidc/bind / oidc/unbind / oidc/logout)保持「各 handler 自己 checkRequest」,
37
+ 不套通用闸门,避免改变其 JSON 错误语义。
38
+
39
+ ### 新增:部署方可追加公开路径
40
+
41
+ 机器调用的端点(例如签名校验的 GitHub webhook)需要匿名可达。在 dsh settings 里声明:
42
+
43
+ ```yaml
44
+ remote-web-ui:
45
+ publicPaths:
46
+ - /api/webhooks/github # 精确路径
47
+ - /api/webhooks/* # 前缀(结尾 /*)
48
+ ```
49
+
50
+ 未声明的路径一律拒绝。配置热更新后下一次扫描(≤10s)生效,无需重启。
51
+
52
+ ### 可观测性
53
+
54
+ `/dsh-webui-oauth/status` 的 `gate` 现在回显实际保护范围,便于运维核对,
55
+ 而不是只看到一句「闸门已开」:
56
+
57
+ ```json
58
+ {
59
+ "gate": {
60
+ "ok": true,
61
+ "mode": "deny-by-default",
62
+ "stats": {
63
+ "wrappedExact": 8, "wrappedPrefix": 8,
64
+ "wrappedUpgrade": 1, "wrappedFallback": 1,
65
+ "publicPaths": ["/dsh-webui-oauth/login", "..."],
66
+ "exemptSelfGated": ["/dsh-webui-oauth/status", "..."]
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ ### 升级注意
73
+
74
+ - 升级后**第三方插件的所有路由默认需要登录**。如果某个端点确实要匿名可达
75
+ (webhook、回调),请按上面的 `publicPaths` 显式声明,否则会 401。
76
+ - 登录页、OIDC 往返、应用根的行为不变;实测登录 → 进应用 → 各插件功能均正常。
77
+
78
+ ### 测试
79
+
80
+ - 新增 `test/gate-default-deny.test.mjs`(53 项):锁定默认拒绝、白名单例外、
81
+ 晚注册路由无窗口期、upgrade 与 fallback 覆盖、`undo` 完全可逆。
82
+ - 真实实例端到端验证(dsh + `dsh-550c-boot` + `dsh-any-background` + 本插件):
83
+ 匿名访问上述三个端点由 200 变为 **401**,登录后仍 200/404(正常业务语义)。
84
+ - 全量 17 个测试文件、434 项断言通过。
85
+
86
+ ## 0.6.6
87
+
88
+ 对 **OIDC 授权跳转**做一次全量验证(固定路径 / 反代 / 浏览器动态三路),
89
+ 并把结论固化成回归测试。本版**没有行为变更**,只补测试与文档。
90
+
91
+ ### 验证:三条跳转路径在真实服务器上全部走通
92
+
93
+ 用一台真实 IdP(HTTPS + 自签 CA)、一台反向代理(复刻 Caddy 的两种 Host 改写模式)
94
+ 和一个真实 dsh 实例,端到端跑完「发起授权 → IdP → 回调 → 换 token → 建会话 → 跳回应用」,
95
+ 共 56 项断言:
96
+
97
+ - **固定路径跳转**(配置 `redirectBase`,20 项):`redirect_uri` 恒为
98
+ `redirectBase + /dsh-webui-oauth/oidc/callback`。浏览器上报异源 origin、
99
+ base 里夹带路径、反代改写 Host 都带不偏它。绑定与登录两条路径结束时均为 302,
100
+ 回执分别是 `oidcbound=1` 与 launch token。
101
+ - **反代跳转**(17 项):`X-Forwarded-Host` / RFC 7239 `Forwarded: host=` 优先于
102
+ 被改写的 Host;反代什么都不下发时按回环判定,回退配置的确定性 origin,
103
+ 回跳**绝不指向内部上游地址**;https 对外地址不被降级为 http。
104
+ - **浏览器动态跳转**(19 项):`redirectBase` 留空时采信登录页上报的
105
+ `location.origin`;经直通反代时端到端回到浏览器看到的地址(含会话不丢)。
106
+ 开关关闭或两者皆缺时 **fail-closed**。非法值全部被拒:
107
+ `javascript:`、带 path/query/hash/userinfo、以及含 CRLF 的注入尝试。
108
+
109
+ ### 澄清:绑定路径为什么不下发新会话 Cookie
110
+
111
+ 绑定流程结束时只清一次性 state Cookie、**不再下发会话 Cookie**——这是刻意的:
112
+ 绑定发生在管理员已登录之后,用的是同一个会话;重新下发会话等于无谓地轮换凭据。
113
+ 登录路径则相反,必须下发新会话。两者的差异在测试里显式锁定,避免被"顺手统一"掉。
114
+
115
+ ### 修复:测试偶发失败(曾导致 v0.6.6 发布中断)
116
+
117
+ - **现象**:`test/oidc.test.mjs` 的「坏签名」用例偶发 FAIL
118
+ (`got null want "bad-signature"`),本地约 2% 概率触发,表现为 CI 上
119
+ "Verify tests pass before publish" 失败、npm publish 被跳过。
120
+ - **原因**:该用例用 `token.slice(0,-2) + 'aa'` 篡改签名末两位。但 RS256 签名是
121
+ 256 字节,base64url 编码后 342 字符 = 2052 bit,而数据只需 2048 bit——
122
+ **末位字符的低 4 位是填充位,解码时被丢弃**。于是当末位落在 `{A,Q,g,w}` 时
123
+ (实测撞到 `aQ` → `aa`),解码出的签名字节与原值**完全一致**,签名压根没被改动,
124
+ 验签自然通过。这是测试的前提错误,不是验签逻辑的问题。
125
+ - **修复**:改为翻转末位字符,并**校验解码后的字节确实发生变化**,否则换下一个候选;
126
+ 另加一条断言锁死"篡改必须改变字节",防止该用例退化成假阳性。
127
+ 修后连续 400 次运行零失败(修前约 2% 失败率)。
128
+
129
+ ### 测试
130
+
131
+ - 新增 `test/oidc-redirect-matrix.test.mjs`(37 项):把上述三路决策固化为
132
+ 无需外部依赖的单元回归。已确认能抓出回归——去掉 `isValidOrigin` 的路径校验后
133
+ 有 2 项立刻 FAIL。
134
+ - 全量 16 个测试文件、381 项断言通过。
135
+
5
136
  ## 0.6.5
6
137
 
7
138
  补全 0.6.4 遗漏的路径:**SSO 登录成功后同样停在 JSON 页面上**。
package/README.en.md CHANGED
@@ -84,9 +84,15 @@ Authentication is enforced in four layers, all implemented by **wrapping the web
84
84
  | WebUI resources (index.html, /assets/*, SPA routes) | Plugin registers a `prefix ''` catch-all route; after session validation it hands off to frontend-static | 302 → login page |
85
85
  | Plugin bundles (/plugins/*) | Wraps the `/plugins` prefix route handler at runtime | 401 |
86
86
  | /api RPC surface | Wraps the `/api` prefix route handler at runtime | 401 |
87
- | WebSocket (`/api/remote.mux`; legacy cores `/api/events.mux`, `/api/events.host`) | Wraps the upgrade route handlers at runtime | 401 upgrade rejected |
87
+ | **Any third-party plugin route** (exact / prefix) | Deny by default: every registered route is enumerated and wrapped, and each route table's `set` is intercepted so new routes are gated at registration time | 401 |
88
+ | WebSocket (**all** upgrade routes) | Every upgrade handler is wrapped at runtime | 401 upgrade rejected |
89
+ | fallback (requests claimed by no named route) | The fallback handler is wrapped too | 401 |
88
90
 
89
- - **No core patching**: a DSH upgrade never overwrites patches and never leaves `/api` exposed after an upgrade. Every startup re-wraps the route tables, with a 2s→10s rescan loop that catches late-registered routes. **On v0.1.2-alpha.2+ the event-stream WebSocket lives at `/api/remote.mux` (registered by dsh-api-gateway); the candidate list adapts automatically, so the plugin no longer falsely reports "upgrade route missing".**
91
+ > **Since 0.6.7 the gate is a deny-by-default allowlist.** Previously only `/api` and `/plugins` were wrapped, but the webServer matches "exact first, then longest prefix" — a third-party route such as `/dsh-any-background/font` or `/dsh-550c-boot/update` is more specific than this plugin's `""` prefix and therefore won the match, bypassing the gate entirely (measured: anonymous requests read user fonts, wallpapers and image fingerprints with 200, while write endpoints returned 401 because they go through the RPC channel — "write-protected, read-naked"). A denylist can never keep up with the next plugin you install; a default-deny allowlist cannot miss.
92
+ >
93
+ > **The complete anonymous allowlist**: `/dsh-webui-oauth/login`, `/setup`, `/oidc/login`, `/oidc/callback`, `/oauth/callback`. Operators can extend it via `remote-web-ui.publicPaths` (see below).
94
+
95
+ - **No core patching**: a DSH upgrade never overwrites patches and never leaves `/api` exposed after an upgrade. Every startup re-wraps the route tables, with a 2s→10s rescan loop that catches late-registered routes; the `set` methods of `exact/prefixes/upgrades` are also intercepted, so **a newly registered route is gated the moment it is registered** — there is no longer a window between "plugin installed" and "next rescan". **On v0.1.2-alpha.2+ the event-stream WebSocket lives at `/api/remote.mux` (registered by dsh-api-gateway); the candidate list adapts automatically, so the plugin no longer falsely reports "upgrade route missing".**
90
96
  - **Fail-closed**: if the expected routes are missing (DSH internals changed so wrapping can't apply), `setup`/`configure` **refuse to enable authentication** and the problem is reported both in the host log and on the settings page — better unusable than "login enabled with an unprotected /api".
91
97
  - **Cooperation with the core's own browser auth (v0.1.2-alpha.2+)**: that core version ships launch-token exchange with an origin-bound signed cookie (`dsh-auth-*`) guarding `/` and `/api`. After a successful plugin login the browser is automatically sent to the core's token-bearing root URL so the core cookie exchange runs too; `/api` requests pass through **unchanged** after the plugin session check (no more Host/Origin rewrite, which would break the core's cookie/Host binding). Both gates apply: a browser must hold the plugin session cookie **and** the core cookie.
92
98
  - **Privileged methods behind a reverse proxy / LAN (legacy cores ≤ alpha.1)**: after the session check the plugin hands authenticated requests to the core in a "loopback shape", so the core's **loopback-pinned privileged methods** (settings/credentials/agentPreset/llm.discoverModels) work in proxied deployments — the session-cookie gate is a strictly stronger identity proof than the Host-header heuristic it replaces.
@@ -100,6 +106,14 @@ Authentication is enforced in four layers, all implemented by **wrapping the web
100
106
  ```
101
107
  - `trustBrowserOrigin` defaults to `true` when omitted (legacy behavior); an explicit `false` makes both sites **ignore** the browser-influenced origin and use `publicBaseUrl`. With no `publicBaseUrl` either, the token redirect falls back to the request Host / loopback, and OIDC fails closed with an error.
102
108
  - The old `oidc.trustBrowserOrigin` is still accepted: when the settings section does not set the switch explicitly, it is used as the fallback, so existing deployments migrate smoothly.
109
+ - **Extra public paths (0.6.7, the companion switch for deny-by-default)**: endpoints called by machines that cannot carry a browser session (GitHub webhooks, third-party OAuth callbacks) must be declared explicitly, otherwise the gate answers 401. Same settings section:
110
+ ```yaml
111
+ remote-web-ui:
112
+ publicPaths:
113
+ - /api/webhooks/github # exact match
114
+ - /api/webhooks/* # prefix match (only a trailing /*)
115
+ ```
116
+ Only absolute paths starting with `/` and free of whitespace/control characters are accepted; anything undeclared is denied. Changes take effect on the next scan (≤10s) without a restart. **The built-in allowlist contains only the five paths required for login / the OIDC round trip** — do not add business endpoints unless they verify a signature or state themselves.
103
117
  - **Host-rewriting reverse proxies**: when the proxy rewrites `Host` to a loopback address (`127.0.0.1:3080`, `::1`, `localhost`, `0.0.0.0`) and sends nothing else, the plugin **cannot** recover the browser-side address from the request, so it treats the address as proxy-rewritten and uses the configured `publicBaseUrl` directly (the legacy "declare the public address in settings.yaml" advice was ineffective in that shape, because `publicBaseUrl` was only trusted on an exact Host match). If the proxy can send the original host, `X-Forwarded-Host` (or RFC 7239 `Forwarded: host=`) is preferred over the request Host for building the redirect.
104
118
  - **Optional OIDC SSO (0.4.0; identity binding required since 0.6.0)**: standards-based authorization-code + PKCE with a **confidential client (client_secret required)**, security-benchmarked against Logto. **Configuring OIDC does not mean anyone can log in**: you must first bind one IdP identity. Until then OIDC login is refused outright and the login page hides the SSO button (see the next bullet). Once bound, the login page shows an "SSO single sign-on" button; after IdP authentication the plugin creates a local session keyed by the IdP `sub` (reusing the session TTL and persistence).
105
119
  - **OIDC identity binding / unbinding (0.6.0)**: binding = log in with the local account, then click "绑定 OIDC 身份" under **Settings → Authentication → OIDC identity binding** and complete one IdP authorization; the verified `sub` is stored in the credentials file (`boundSub`), and **only that identity** can then log in via SSO. Unbinding = click "解除绑定" (current password required); once cleared, SSO login immediately reverts to "refuse everything". The settings page only ever shows a masked sub (e.g. `su*****ce`). A credentials file without `boundSub` counts as unbound, so upgrading never opens access. The **redirect_uri base is set by `oidc.redirectBase`**; by default the browser is trusted (it trusts the front-end `location.origin` — under a host-rewriting reverse proxy the browser's public address is correct), ****constrained by the unifed switch above****; when disabled only the configured `redirectBase` is used; if neither is available it fails closed with an error. id_token signatures support **RSA (RS256/384/512, PS256/384/512), EC (ES256/384/512) and EdDSA (Ed25519)**, with iss/aud/exp/iat/nbf/nonce and JWKS(kid) validation (see "Signature algorithm support" below). Logout is the simple variant: it clears the local session only.
@@ -337,6 +351,36 @@ Preconditions: **no** `remote-web-ui.publicBaseUrl` configured, `trustBrowserOri
337
351
  Note that `publicBaseUrl` is **only honoured when its authority matches the request Host** (to prevent an open redirect). When Host has already been rewritten to loopback the two do not match, so **`publicBaseUrl` cannot rescue this case either** — you must use the first option. This is an easy misconfiguration, hence documenting it here.
338
352
 
339
353
  You also need `--trusted-host <public host:port>`, otherwise a desktop browser hitting `/api` directly gets 403 (see "Known limits").
354
+ ### D. The three OIDC redirect paths, measured end-to-end (0.6.6)
355
+
356
+ Using a real IdP (HTTPS with a self-signed CA), a real reverse proxy (both Host-rewriting
357
+ modes) and a real dsh instance, the full round trip — "start authorization → IdP →
358
+ callback → token exchange → local session → back to the app" — was run end to end.
359
+ All 56 assertions pass. The table below lists the measured values for `redirect_uri`
360
+ and for the post-callback redirect target:
361
+
362
+ | Path | Key config | redirect_uri seen by the IdP | Target after callback |
363
+ | --- | --- | --- | --- |
364
+ | Fixed path | `redirectBase=http://127.0.0.1:14080` | `http://127.0.0.1:14080/dsh-webui-oauth/oidc/callback` | `http://127.0.0.1:14080/?…&oidcbound=1` ✅ |
365
+ | Fixed path + hostile origin | same, with `base=https://evil.example.com` | still the configured 14080 ✅ (not swayed) | same ✅ |
366
+ | Reverse proxy (Host rewritten + XFH) | same, via 14080 | the configured 14080 ✅ | 14080 (the browser-side address) ✅ |
367
+ | Reverse proxy (original Host kept) | same, via 14081 | the configured 14080 ✅ | 14080, **never the internal 13081** ✅ |
368
+ | Browser-dynamic | `redirectBase` empty + `trustBrowserOrigin=true` | the origin reported by the browser ✅ | back to that origin ✅ |
369
+ | Dynamic + pass-through proxy | same, via 14081 | `http://127.0.0.1:14081/dsh-webui-oauth/oidc/callback` ✅ | `http://127.0.0.1:14081/?…` (session preserved) ✅ |
370
+ | Dynamic but switch off | `trustBrowserOrigin=false`, no `redirectBase` | **no authorization started**, `oidc-base-unresolvable` ✅ | — |
371
+
372
+ Rejected inputs (no authorization started, no injection):
373
+ `javascript:alert(1)`, origins carrying a path / query / hash / userinfo, and
374
+ CRLF-injection attempts such as `https://evil.test/\r\nX-Injected: 1`.
375
+
376
+ **Why the bind path sends no session cookie**: on completion the bind flow only clears
377
+ the one-shot state cookie. That is deliberate — binding happens *after* the administrator
378
+ is already signed in and reuses the same session, so re-issuing one would rotate
379
+ credentials for no reason. The login path is the opposite and must send a new session
380
+ (the response carries two `Set-Cookie` headers). This difference is locked down by tests.
381
+
382
+ Reproduce with `test/oidc-redirect-matrix.test.mjs` (dependency-free unit regression,
383
+ wired into `npm test`).
340
384
 
341
385
  ## Known limits
342
386
 
package/README.md CHANGED
@@ -84,9 +84,15 @@ DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认
84
84
  | WebUI 资源(index.html、/assets/*、SPA 路由) | 插件注册 `prefix ''` 兜底路由,校验会话后转交 frontend-static | 302 → 登录页 |
85
85
  | 插件 bundle(/plugins/*) | 运行时包装 `/plugins` 前缀路由 handler | 401 |
86
86
  | /api RPC 接口 | 运行时包装 `/api` 前缀路由 handler | 401 |
87
- | WebSocket(`/api/remote.mux`;旧核心 `/api/events.mux`、`/api/events.host`) | 运行时包装 upgrade 路由 handler | 401 拒绝升级 |
87
+ | **第三方插件的任意路由**(exact / prefix) | 默认拒绝:安装时枚举并包装**全部**路由,且接管路由表 `set` 在注册瞬间上闸门 | 401 |
88
+ | WebSocket(**所有** upgrade 路由) | 运行时包装每个 upgrade handler | 401 拒绝升级 |
89
+ | fallback(未被具名路由认领的请求) | 运行时包装 fallback handler | 401 |
88
90
 
89
- - **不修改核心包**:dsh 升级不会覆盖补丁、不会产生「升级后 /api 裸奔」的窗口。插件每次启动对路由表重做包装,并用 2s→10s 重扫捕获晚注册路由。**v0.1.2-alpha.2 及更新核心的事件流 WebSocket 位于 `/api/remote.mux`(由 dsh-api-gateway 注册),候选列表自动适配,不再误报「upgrade 路由缺失」。**
91
+ > **0.6.7 起为「默认拒绝」白名单模型。** 此前只包 `/api` 与 `/plugins`,而 webServer 按「exact 优先、其次最长前缀」匹配——第三方插件注册的 `/dsh-any-background/font`、`/dsh-550c-boot/update` 等更具体的路由会抢在本插件的 `""` 前缀之前命中,完全绕过鉴权(实测未登录即 200 读出用户字体、壁纸与镜像指纹,而写端点因走 RPC 通道反而 401)。黑名单追不上新装的插件,白名单不会漏。
92
+ >
93
+ > **能匿名访问的完整清单**:`/dsh-webui-oauth/login`、`/setup`、`/oidc/login`、`/oidc/callback`、`/oauth/callback`。部署方可用 `remote-web-ui.publicPaths` 追加(见下)。
94
+
95
+ - **不修改核心包**:dsh 升级不会覆盖补丁、不会产生「升级后 /api 裸奔」的窗口。插件每次启动对路由表重做包装,并用 2s→10s 重扫捕获晚注册路由;同时对 `exact/prefixes/upgrades` 三张表接管 `set`,**新路由注册的那一刻就上闸门**,不再有「装好插件到下一次重扫之间」的裸奔窗口。**v0.1.2-alpha.2 及更新核心的事件流 WebSocket 位于 `/api/remote.mux`(由 dsh-api-gateway 注册),候选列表自动适配,不再误报「upgrade 路由缺失」。**
90
96
  - **fail-closed**:若预期路由缺失(dsh 内部结构变化导致包装不上),`setup`/`configure` 会**拒绝启用认证**,并在宿主日志与设置页同时报错——宁可不可用,不可「开了登录却裸奔 /api」。
91
97
  - **与核心自带浏览器认证(v0.1.2-alpha.2+)协作**:该版本核心自带 launch-token 交换的签名 Cookie(`dsh-auth-*`)认证 `/` 与 `/api`。插件登录成功后自动把浏览器引导到核心的带 token 根 URL 完成核心 Cookie 交换;`/api` 请求在插件会话校验后**原样转交**给核心(不再改写 Host/Origin,避免破坏核心 Cookie 与 Host 绑定)。两者叠加:浏览器须同时持有插件会话 Cookie 与核心 Cookie。
92
98
  - **反代/局域网旧核心下的特权方法**(≤alpha.1):已认证请求由插件在会话校验后以「回环形状」转交核心,使核心中**回环钉死的特权方法**(settings/credentials/agentPreset/llm.discoverModels)在反代部署下可用——会话 Cookie 闸门是比 Host 启发式更强的身份证明。
@@ -111,6 +117,16 @@ DSH WebUI 身份认证插件(持久化插件)。在「设置 → 身份认
111
117
 
112
118
  第 2 行是**动态跳转不可用时的兜底**,不是第二个开关:Host 被改写成回环时,服务端从请求里**拿不到**任何浏览器侧地址,开关即使开着也没有可用值,只能回退 `publicBaseUrl`(该值来自 `settings.yaml`,运维可控、非攻击者可控,不构成开放重定向)。
113
119
 
120
+ - **追加公开路径(0.6.7,默认拒绝的配套开关)**:机器调用、无法携带浏览器会话的端点(GitHub webhook、第三方 OAuth 回跳等)必须显式声明,否则会被闸门 401。同一 settings 段:
121
+ ```yaml
122
+ remote-web-ui:
123
+ publicPaths:
124
+ - /api/webhooks/github # 精确匹配
125
+ - /api/webhooks/* # 前缀匹配(只认结尾的 /*)
126
+ ```
127
+ 仅接受以 `/` 开头、不含空白与控制字符的绝对路径;未声明的路径一律拒绝。配置热更新后下一次扫描(≤10s)生效,无需重启。**内置白名单只有登录/OIDC 往返所必需的五条**,业务端点不应加进去,除非它自身已做签名/state 校验。
128
+
129
+
114
130
  **端口规则**:端口**只来自被选中那一方的字面**,不做任何补全。
115
131
  - 动态路径:端口取自 Host / 原始主机头。不带端口就不带(浏览器按 scheme 推 443)。
116
132
  - 绝对路径:端口取自 `publicBaseUrl`。**CDN 仅在 443 对外时应写不带端口**(`https://dsh.example.com`);写成 `:8443` 会让浏览器去撞 8443。
@@ -355,6 +371,32 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
355
371
 
356
372
  另需 `--trusted-host <对外域名:端口>`,否则桌面浏览器直连 `/api` 会 403(见「已知边界」)。
357
373
 
374
+ ### D. OIDC 授权跳转三路实测(0.6.6 全量验证)
375
+
376
+ 用真实 IdP(HTTPS + 自签 CA)、真实反向代理(两种 Host 改写模式)与真实 dsh 实例,
377
+ 端到端跑完「发起授权 → IdP → 回调 → 换 token → 建会话 → 跳回应用」,共 56 项断言全通过。
378
+ 下表是三条路径下 `redirect_uri` 与「回调后回跳目标」的实测值:
379
+
380
+ | 路径 | 关键配置 | IdP 收到的 redirect_uri | 回调后回跳目标 |
381
+ | --- | --- | --- | --- |
382
+ | 固定路径跳转 | `redirectBase=http://127.0.0.1:14080` | `http://127.0.0.1:14080/dsh-webui-oauth/oidc/callback` | `http://127.0.0.1:14080/?…&oidcbound=1` ✅ |
383
+ | 固定路径 + 浏览器谎报异源 | 同上,且 `base=https://evil.example.com` | 仍为配置的 14080 ✅(不被带偏) | 同上 ✅ |
384
+ | 反代(Host 改写 + XFH) | 同上,经 14080 | 配置的 14080 ✅ | 指向 14080(浏览器侧)✅ |
385
+ | 反代(保留原始 Host) | 同上,经 14081 | 配置的 14080 ✅ | 14080,**不指向内网 13081** ✅ |
386
+ | 浏览器动态跳转 | `redirectBase` 留空 + `trustBrowserOrigin=true` | 浏览器上报的 origin ✅ | 回到该 origin ✅ |
387
+ | 动态 + 直通反代 | 同上,经 14081 | `http://127.0.0.1:14081/dsh-webui-oauth/oidc/callback` ✅ | `http://127.0.0.1:14081/?…`(会话不丢)✅ |
388
+ | 动态但开关关闭 | `trustBrowserOrigin=false` 且无 `redirectBase` | **不发起授权**,返回 `oidc-base-unresolvable` ✅ | — |
389
+
390
+ 被明确拒绝的非法输入(均不发起授权、不产生注入):
391
+ `javascript:alert(1)`、带 path / query / hash / userinfo 的 origin、
392
+ 以及含 CRLF 的注入尝试(`https://evil.test/\r\nX-Injected: 1`)。
393
+
394
+ **关于绑定路径不下发会话 Cookie**:绑定结束时只清一次性 state Cookie。
395
+ 这是刻意的——绑定发生在管理员**已登录之后**,复用同一会话即可;重新下发等于无谓轮换凭据。
396
+ 登录路径相反,必须同时下发新会话(响应含 2 条 `Set-Cookie`)。此差异已在测试中锁定。
397
+
398
+ 复现方式见 `test/oidc-redirect-matrix.test.mjs`(无需外部依赖的单元回归,已接入 `npm test`)。
399
+
358
400
  ## 已知边界
359
401
 
360
402
  - **运行时包装的固有窗口**:路由对象被替换(服务热重载)到下一次重扫之间(≤10s)存在未保护窗口;启用认证时的 fail-closed 已挡住「初始裸奔」,此窗口仅影响运行中的热重载场景。
package/index.js CHANGED
@@ -127,11 +127,25 @@
127
127
  * and the scrypt credential format originate from Yuuz12/dsh-webui-auth
128
128
  * (MIT). This fork keeps the on-disk formats compatible where possible.
129
129
  *
130
- * Routes protected (all via runtime wrapping of the webServer service):
131
- * - prefix "" : SPA resources (302 to login page)
132
- * - prefix "/plugins": client plugin bundles (302 to login page)
133
- * - prefix "/api" : RPC surface (401)
134
- * - upgrades /api/remote.mux (+ legacy /api/events.mux, /api/events.host): (reject upgrade)
130
+ * Route gating (0.6.7+) — DENY BY DEFAULT, allowlist by exception:
131
+ * Every route registered on the webServer is wrapped at registration time:
132
+ * - all exact routes -> 401 unless public / self-gated (below)
133
+ * - all prefix routes -> 401 unless the root "" prefix (ours) or public
134
+ * - all upgrade (WebSocket) -> upgrade rejected with 401
135
+ * - the fallback handler -> 401 unless authenticated
136
+ * Public allowlist (the minimum needed to log in):
137
+ * /dsh-webui-oauth/login, /setup, /oidc/login, /oidc/callback, /oauth/callback
138
+ * Self-gated (each handler calls checkRequest itself): the remaining
139
+ * /dsh-webui-oauth/* endpoints.
140
+ * Additional operator-declared public paths: settings remote-web-ui.publicPaths
141
+ * (e.g. a signature-verified webhook ingress).
142
+ *
143
+ * Why this replaced the old denylist: any third-party plugin could register a
144
+ * more specific prefix/exact route (e.g. /dsh-any-background/font,
145
+ * /dsh-550c-boot/update) that won the longest-prefix match and bypassed the
146
+ * gate entirely — reads were anonymous while writes (over the connection RPC
147
+ * channel) were 401: "write-protected, read-naked". A denylist can never keep
148
+ * up with newly installed plugins; a default-deny allowlist cannot miss.
135
149
  *
136
150
  * Sessions: server-side, persisted across restarts (H3), carried by an
137
151
  * HttpOnly cookie `dsh_wua_session`; changing the password revokes every
@@ -200,7 +214,7 @@ async function dummyVerify(password) {
200
214
  }
201
215
 
202
216
  // 供测试与工具脚本使用(Cordis 加载时只消费 name/inject/apply,多余导出无副作用)
203
- export { hashPassword, verifyPassword, auditLog, readAuditEntries, resolveDataDirFrom, DATA_DIR, resolveRedirectScheme, postLoginRedirect, readOidcConfig, writeOidcConfig, configPath, oidcConfigPath, sendJson, makeOidcStateStore, makeDiscoveryCache }
217
+ export { hashPassword, verifyPassword, auditLog, readAuditEntries, resolveDataDirFrom, DATA_DIR, resolveRedirectScheme, postLoginRedirect, readOidcConfig, writeOidcConfig, configPath, oidcConfigPath, sendJson, makeOidcStateStore, makeDiscoveryCache, installRouteGate, PUBLIC_EXACT_PATHS, SELF_GATED_EXACT_PATHS }
204
218
 
205
219
  // ---------------- 数据目录与文件 ----------------
206
220
 
@@ -1014,9 +1028,105 @@ f.addEventListener('submit', function (ev) {
1014
1028
  const API_PREFIX = '/api'
1015
1029
  const PLUGINS_PREFIX = '/plugins'
1016
1030
  // alpha.2 起事件流 WebSocket 由 dsh-api-gateway 的 /api/remote.mux 承载;
1017
- // 旧的 events.mux/events.host 已删除。候选列表按"存在即包装",兼容新旧核心。
1031
+ // 旧的 events.mux/events.host 已删除。候选列表仅用于"核心事件流是否就位"的
1032
+ // fail-closed 判断;实际包装已改为**所有** upgrade 路由(见 installRouteGate)。
1018
1033
  const UPGRADE_CANDIDATES = ['/api/remote.mux', '/api/events.mux', '/api/events.host']
1019
1034
 
1035
+ // ---------------- 路由白名单(默认拒绝) ----------------
1036
+ //
1037
+ // 0.6.7 起,闸门由「只包 /api 与 /plugins 的黑名单」改为「默认拒绝一切、
1038
+ // 只放行白名单」的白名单模型。
1039
+ //
1040
+ // 为什么必须改:webServer 的匹配顺序是「exact 优先,其次最长前缀」。任何第三方
1041
+ // 插件都可以注册自己的 prefix/exact 路由(实测:/dsh-any-background/font、
1042
+ // /dsh-550c-boot/update),它们比本插件的 "" 前缀更具体,会抢在闸门之前命中,
1043
+ // 于是完全绕过鉴权——读接口 200 裸奔、写接口因为走 connection RPC 通道反而 401,
1044
+ // 形成典型的"写保护、读裸奔"。黑名单永远追不上新装的插件,白名单不会漏。
1045
+ //
1046
+ // 公开(允许匿名)的**精确路径**。只放行登录/OIDC 往返所必需的最小集合:
1047
+ // 少一条登录就进不去,多一条就可能泄露数据。新增请写进 remote-web-ui.publicPaths,
1048
+ // 不要直接改这里——部署差异不该进代码。
1049
+ const PUBLIC_EXACT_PATHS = new Set([
1050
+ '/dsh-webui-oauth/login', // 登录页 GET + 提交 POST
1051
+ '/dsh-webui-oauth/setup', // 首次初始化(由 setup token 把关)
1052
+ '/dsh-webui-oauth/oidc/login', // SSO 发起(未绑定时内部拒绝)
1053
+ '/dsh-webui-oauth/oidc/callback',// IdP 回跳(state + PKCE + 验签把关)
1054
+ '/oauth/callback', // 账号平台的 OAuth 回跳(state 常量时间比对)
1055
+ ])
1056
+
1057
+ // 本插件自己注册的端点:内部**逐个**自带 checkRequest(公开的那几个按设计放行)。
1058
+ // 与 PUBLIC_EXACT_PATHS 分开列,是为了让"为什么不套通用闸门"有明确理由;
1059
+ // 也避免用 startsWith('/dsh-webui-oauth/') 这种宽豁免——那会让第三方注册的
1060
+ // /dsh-webui-oauth/xxx 搭便车。
1061
+ const SELF_GATED_EXACT_PATHS = new Set([
1062
+ '/dsh-webui-oauth/login',
1063
+ '/dsh-webui-oauth/setup',
1064
+ '/dsh-webui-oauth/logout',
1065
+ '/dsh-webui-oauth/status',
1066
+ '/dsh-webui-oauth/audit',
1067
+ '/dsh-webui-oauth/configure',
1068
+ '/dsh-webui-oauth/disable',
1069
+ '/dsh-webui-oauth/oidc/login',
1070
+ '/dsh-webui-oauth/oidc/bind',
1071
+ '/dsh-webui-oauth/oidc/unbind',
1072
+ '/dsh-webui-oauth/oidc/callback',
1073
+ '/dsh-webui-oauth/oidc/logout',
1074
+ ])
1075
+
1076
+ /**
1077
+ * 读取部署方追加的公开路径(settings 的 remote-web-ui.publicPaths)。
1078
+ *
1079
+ * 形如 ['/api/webhooks/github'] 或 ['/api/webhooks/*'](结尾 /* 表示前缀)。
1080
+ * 只接受以 / 开头、无空白与控制字符的绝对路径——避免把配置写成任意匹配。
1081
+ *
1082
+ * @param ctx 插件上下文
1083
+ * @returns {string[]} 清洗后的模式列表
1084
+ */
1085
+ function extraPublicPaths(ctx) {
1086
+ const out = []
1087
+ try {
1088
+ const settings = ctx && typeof ctx.get === 'function' ? ctx.get('settings') : undefined
1089
+ const section = settings && typeof settings.get === 'function'
1090
+ ? settings.get(PUBLIC_BASE_NAMESPACE)
1091
+ : undefined
1092
+ const raw = section && section.publicPaths
1093
+ if (!Array.isArray(raw)) return out
1094
+ for (const item of raw) {
1095
+ if (typeof item !== 'string') continue
1096
+ const p = item.trim()
1097
+ if (!p.startsWith('/')) continue
1098
+ if (/[\x00-\x1f\x7f\s]/.test(p)) continue
1099
+ out.push(p)
1100
+ }
1101
+ } catch (e) { /* 配置不可读时按"没有额外放行"处理(更严格) */ }
1102
+ return out
1103
+ }
1104
+
1105
+ /**
1106
+ * 判断路径是否命中公开模式。
1107
+ * 模式以 '/*' 结尾表示前缀匹配('/api/webhooks/*' 命中 '/api/webhooks/x' 与
1108
+ * '/api/webhooks'),否则要求精确相等。
1109
+ *
1110
+ * @param path 请求/路由路径
1111
+ * @param patterns 公开模式列表
1112
+ * @returns {boolean}
1113
+ */
1114
+ export function matchesPublicPath(path, patterns) {
1115
+ const p = String(path || '')
1116
+ for (const raw of patterns || []) {
1117
+ const pat = String(raw || '')
1118
+ if (!pat) continue
1119
+ if (pat.endsWith('/*')) {
1120
+ const base = pat.slice(0, -1) // 保留结尾的 '/'
1121
+ if (p === pat.slice(0, -2)) return true
1122
+ if (p.startsWith(base)) return true
1123
+ } else if (p === pat) {
1124
+ return true
1125
+ }
1126
+ }
1127
+ return false
1128
+ }
1129
+
1020
1130
  function rejectUpgrade401(socket) {
1021
1131
  try {
1022
1132
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 13\r\n\r\nunauthorized\n')
@@ -1141,31 +1251,170 @@ function installRouteGate(ctx, checkRequest, log) {
1141
1251
  return () => { route.handler = original }
1142
1252
  }
1143
1253
 
1144
- // 已包装的路由打标记,避免重复包装
1145
- const wrapped = new WeakSet()
1146
- const PROTECTED_PREFIXES = [API_PREFIX, PLUGINS_PREFIX]
1254
+ // 已包装的路由 → { kind, path, undo }。
1255
+ // 用 Map 而不是 WeakSet,是为了支持"配置热更新后把已包的路由解包":
1256
+ // publicPaths 追加某路径后,该路径若之前已被包装,需要在下一次扫描时撤销,
1257
+ // 否则文档里说的"无需重启生效"就不成立。
1258
+ const wrappedRoutes = new Map()
1259
+
1260
+ // 部署方追加的公开路径(settings.remote-web-ui.publicPaths)。
1261
+ // 每次扫描重读一次:配置热更新后无需重启即可生效。
1262
+ let publicPatterns = extraPublicPaths(ctx)
1263
+
1264
+ // 当前生效的闸门策略快照,供 status 端点回显(便于运维核对"到底放行了什么")。
1265
+ // 计数在读取时从 wrappedRoutes 现算,避免 counters 与实际状态漂移;
1266
+ // 豁免项用 Set 去重(scanOnce 每 2 秒跑一次,数组会无限增长)。
1267
+ const stats = { publicPaths: new Set(), exemptPrefixes: new Set(), exemptSelfGated: new Set() }
1268
+ let fallbackWrapped = false
1269
+
1270
+ /** 是否为公开路径:内置最小集合 + 部署方追加的模式。 */
1271
+ const isPublicPath = (path) => PUBLIC_EXACT_PATHS.has(path) || matchesPublicPath(path, publicPatterns)
1272
+
1273
+ /** 精确路径/前缀是否属于本插件自管或已声明公开。 */
1274
+ const isExemptHttp = (path, kind) => {
1275
+ if (isPublicPath(path)) return true
1276
+ if (kind === 'exact' && SELF_GATED_EXACT_PATHS.has(path)) return true
1277
+ // 空前缀是本插件自己的传输层闸门(未登录时 302 到登录页)。
1278
+ // 它不能套通用闸门,否则登录页永远打不开(401 死循环)。
1279
+ if (kind === 'prefix' && path === '') return true
1280
+ return false
1281
+ }
1282
+
1283
+ /**
1284
+ * 包一条 HTTP 路由(若它不在白名单里)。
1285
+ * @returns {boolean} 本次是否真的包装了
1286
+ */
1287
+ const wrapRoute = (route, kind) => {
1288
+ if (!route || typeof route !== 'object' || wrappedRoutes.has(route)) return false
1289
+ if (typeof route.handler !== 'function') return false
1290
+ const path = typeof route.path === 'string' ? route.path : ''
1291
+ if (isExemptHttp(path, kind)) {
1292
+ if (kind === 'prefix' && path === '') stats.exemptPrefixes.add('(root)')
1293
+ else if (kind === 'exact' && !isPublicPath(path)) stats.exemptSelfGated.add(path)
1294
+ return false
1295
+ }
1296
+ const undoFn = wrapHttp(route, { loopbackDeputy: kind === 'prefix' && path === API_PREFIX })
1297
+ wrappedRoutes.set(route, { kind, path, undo: undoFn })
1298
+ undos.push(undoFn)
1299
+ log('wrapped ' + kind + ' route ' + (path === '' ? '(root)' : path))
1300
+ return true
1301
+ }
1302
+
1303
+ /** 包一条 upgrade 路由(WebSocket)。默认拒绝,不做白名单。 */
1304
+ const wrapUpgradeRoute = (route) => {
1305
+ if (!route || typeof route !== 'object' || wrappedRoutes.has(route)) return false
1306
+ if (typeof route.handler !== 'function') return false
1307
+ const undoFn = wrapUpgrade(route)
1308
+ wrappedRoutes.set(route, { kind: 'upgrade', path: route.path || '', undo: undoFn })
1309
+ undos.push(undoFn)
1310
+ log('wrapped upgrade route ' + (route.path || '(unnamed)'))
1311
+ return true
1312
+ }
1313
+
1314
+ // ---------------- fallback 也要包 ----------------
1315
+ // fallback 是"未被任何具名路由认领"的兜底处理器。它同样可能被第三方插件占据,
1316
+ // 若不管,攻击者只要找到一个不存在的路径就可能落到未鉴权的 fallback 上。
1317
+ const wrappedFallbacks = new WeakSet()
1318
+ const gateFallback = (handler) => {
1319
+ if (typeof handler !== 'function' || wrappedFallbacks.has(handler)) return handler
1320
+ const original = handler
1321
+ const gated = async (req, res) => {
1322
+ if (!checkRequest(req)) {
1323
+ res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' })
1324
+ res.end('unauthorized')
1325
+ return
1326
+ }
1327
+ return original(req, res)
1328
+ }
1329
+ wrappedFallbacks.add(gated)
1330
+ fallbackWrapped = true
1331
+ log('wrapped fallback handler')
1332
+ return gated
1333
+ }
1334
+
1335
+ // 已经就位的 fallback:直接替换属性(保留原函数以便 undo)。
1336
+ const wrapExistingFallback = () => {
1337
+ const current = ws.fallback
1338
+ if (typeof current !== 'function') return
1339
+ if (wrappedFallbacks.has(current)) return
1340
+ if (current.__dshWuaGated === true) return
1341
+ const gated = gateFallback(current)
1342
+ if (gated === current) return
1343
+ gated.__dshWuaGated = true
1344
+ ws.fallback = gated
1345
+ undos.push(() => { if (ws.fallback === gated) ws.fallback = current })
1346
+ }
1347
+
1348
+ // ---------------- 注册时即拦截(消除重扫窗口) ----------------
1349
+ // 仅靠周期重扫会有最长 2s(降频后 10s)的"新路由尚未包装"窗口:插件热加载
1350
+ // 后立刻被匿名访问就能漏过去。因此直接接管 Map#set,在注册的那一刻包装。
1351
+ const interceptMapSet = (map, kind) => {
1352
+ if (!map || typeof map.set !== 'function') return
1353
+ const originalSet = map.set
1354
+ map.set = function patchedSet (key, value) {
1355
+ try { wrapRoute(value, kind) } catch (e) { /* 包装失败不阻断注册,靠重扫兜底 */ }
1356
+ return originalSet.call(this, key, value)
1357
+ }
1358
+ undos.push(() => { map.set = originalSet })
1359
+ }
1360
+ interceptMapSet(ws.exact, 'exact')
1361
+ interceptMapSet(ws.prefixes, 'prefix')
1362
+
1363
+ // upgrade 表同理:新注册的 WebSocket 路由也要在注册那一刻就上闸门。
1364
+ if (ws.upgrades && typeof ws.upgrades.set === 'function') {
1365
+ const originalUpgradeSet = ws.upgrades.set
1366
+ ws.upgrades.set = function patchedUpgradeSet (key, value) {
1367
+ try { wrapUpgradeRoute(value) } catch (e) { /* 靠重扫兜底 */ }
1368
+ return originalUpgradeSet.call(this, key, value)
1369
+ }
1370
+ undos.push(() => { ws.upgrades.set = originalUpgradeSet })
1371
+ }
1372
+
1373
+ // registerFallback 走的是属性赋值而非 Map,单独接管。
1374
+ if (typeof ws.registerFallback === 'function') {
1375
+ const originalRegisterFallback = ws.registerFallback
1376
+ ws.registerFallback = function patchedRegisterFallback (handler) {
1377
+ return originalRegisterFallback.call(this, gateFallback(handler))
1378
+ }
1379
+ undos.push(() => { ws.registerFallback = originalRegisterFallback })
1380
+ }
1381
+
1147
1382
  const scanOnce = () => {
1148
1383
  if (undone) return
1149
- for (const pfx of PROTECTED_PREFIXES) {
1150
- const route = ws.prefixes.get(pfx)
1151
- if (route !== undefined && !wrapped.has(route)) {
1152
- wrapped.add(route)
1153
- undos.push(wrapHttp(route, { loopbackDeputy: pfx === API_PREFIX }))
1154
- problems.delete(`prefix route "${pfx}" not registered yet`)
1155
- log(`wrapped prefix route ${pfx}`)
1156
- } else if (route === undefined) {
1157
- problems.add(`prefix route "${pfx}" not registered yet`)
1158
- }
1384
+ publicPatterns = extraPublicPaths(ctx)
1385
+ stats.publicPaths = new Set([...PUBLIC_EXACT_PATHS, ...publicPatterns])
1386
+
1387
+ // 配置热更新可能**新增**公开路径:已包装的对应路由必须解包,否则"无需重启
1388
+ // 生效"是假的。只处理 HTTP 路由(upgrade 不做白名单)。
1389
+ for (const [route, rec] of [...wrappedRoutes]) {
1390
+ if (rec.kind === 'upgrade') continue
1391
+ if (!isExemptHttp(rec.path, rec.kind)) continue
1392
+ try { rec.undo() } catch (e) { /* ignore */ }
1393
+ wrappedRoutes.delete(route)
1394
+ log('unwrapped ' + rec.kind + ' route ' + (rec.path || '(root)') + ' (now public)')
1159
1395
  }
1160
- // alpha.2 起仅存在 /api/remote.mux(由 api-gateway 注册);旧核心仍用
1161
- // events.mux/events.host。候选列表兼容两代:已注册的全部包装,其余不计问题。
1162
- for (const path of UPGRADE_CANDIDATES) {
1163
- const r = ws.upgrades.get(path)
1164
- if (r !== undefined && !wrapped.has(r)) {
1165
- wrapped.add(r)
1166
- undos.push(wrapUpgrade(r))
1167
- log(`wrapped upgrade route ${path}`)
1168
- }
1396
+ // 快照一遍再遍历:包装过程中不改 Map,但注册可能并发发生(单线程下安全,
1397
+ // 仍显式取快照以免迭代语义依赖实现细节)。
1398
+ const exactEntries = ws.exact && typeof ws.exact.forEach === 'function' ? [...ws.exact] : []
1399
+ for (const [, route] of exactEntries) wrapRoute(route, 'exact')
1400
+
1401
+ const prefixEntries = ws.prefixes && typeof ws.prefixes.forEach === 'function' ? [...ws.prefixes] : []
1402
+ for (const [, route] of prefixEntries) wrapRoute(route, 'prefix')
1403
+
1404
+ // 所有 WebSocket upgrade 一律包装(不再只认 /api/remote.mux):
1405
+ // 第三方插件注册的 upgrade 同样可能暴露未鉴权的双向通道。
1406
+ const upgradeEntries = ws.upgrades && typeof ws.upgrades.forEach === 'function' ? [...ws.upgrades] : []
1407
+ for (const [, route] of upgradeEntries) wrapUpgradeRoute(route)
1408
+
1409
+ wrapExistingFallback()
1410
+
1411
+ // fail-closed 的判据:核心的 /api 与 /plugins 必须就位,否则说明路由形状
1412
+ // 变了,"已启用"可能意味着裸奔。这里只保留这两条(以及事件流 upgrade),
1413
+ // 不再把"只包了这两个"当成保护范围。
1414
+ for (const pfx of [API_PREFIX, PLUGINS_PREFIX]) {
1415
+ const route = ws.prefixes.get(pfx)
1416
+ if (route === undefined) problems.add(`prefix route "${pfx}" not registered yet`)
1417
+ else problems.delete(`prefix route "${pfx}" not registered yet`)
1169
1418
  }
1170
1419
  if (UPGRADE_CANDIDATES.every((path) => ws.upgrades.get(path) === undefined)) {
1171
1420
  problems.add('no upgrade route registered yet (looked for ' + UPGRADE_CANDIDATES.join(', ') + ')')
@@ -1208,7 +1457,33 @@ function installRouteGate(ctx, checkRequest, log) {
1208
1457
  undone = true
1209
1458
  for (const u of undos.splice(0).reverse()) { try { u() } catch (e) { /* ignore */ } }
1210
1459
  }
1211
- return { ok: () => problems.size === 0, problems: () => [...problems], undo }
1460
+ return {
1461
+ ok: () => problems.size === 0,
1462
+ problems: () => [...problems],
1463
+ // 供 status 端点回显:让运维能直接看到"包了几条、放行了什么",
1464
+ // 而不是只能相信"闸门开着"这句话。
1465
+ stats: () => {
1466
+ // 现算而非累加:解包/重包后计数不会漂移。
1467
+ let exact = 0, prefix = 0, upgrade = 0
1468
+ for (const rec of wrappedRoutes.values()) {
1469
+ if (rec.kind === 'exact') exact += 1
1470
+ else if (rec.kind === 'prefix') prefix += 1
1471
+ else if (rec.kind === 'upgrade') upgrade += 1
1472
+ }
1473
+ return {
1474
+ wrappedExact: exact,
1475
+ wrappedPrefix: prefix,
1476
+ wrappedUpgrade: upgrade,
1477
+ wrappedFallback: fallbackWrapped ? 1 : 0,
1478
+ publicPaths: [...stats.publicPaths].sort(),
1479
+ exemptPrefixes: [...stats.exemptPrefixes].sort(),
1480
+ exemptSelfGated: [...stats.exemptSelfGated].sort(),
1481
+ }
1482
+ },
1483
+ // 立即重扫一次(配置变更后测试/运维可直接触发,不必等 2s 定时器)。
1484
+ refresh: () => { scanOnce(); return { ok: problems.size === 0, problems: [...problems] } },
1485
+ undo,
1486
+ }
1212
1487
  }
1213
1488
 
1214
1489
  // ---------------- DSH 外观偏好 ----------------
@@ -2247,7 +2522,13 @@ export async function apply(ctx) {
2247
2522
  username: enabled ? creds.username : null,
2248
2523
  ttl: ttlOf(creds),
2249
2524
  sessionsPersisted: sessions.ok,
2250
- gate: { ok: gate.ok(), problems: gate.problems().slice(0, 3) },
2525
+ gate: {
2526
+ ok: gate.ok(),
2527
+ problems: gate.problems().slice(0, 3),
2528
+ mode: 'deny-by-default',
2529
+ // 让运维能核对"到底包了哪些、放行了哪些",而不是只看一句"闸门已开"。
2530
+ stats: gate.stats(),
2531
+ },
2251
2532
  trustedOrigin: (() => {
2252
2533
  try {
2253
2534
  const pol = trustedOriginPolicy(ctx, oidcCfg)
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "node test/redirect-scheme.test.mjs && node test/proxy-host.test.mjs && node test/trusted-origin.test.mjs && node test/oidc.test.mjs && node test/oidc-state-ttl.test.mjs && node test/set-cookie-multi.test.mjs && node test/oidc-integration.test.mjs && node test/oidc-config-store.test.mjs && node test/adopt-data-dir.test.mjs && node test/oidc-ec.test.mjs && node test/jwks-refresh.test.mjs && node test/oidc-bind.test.mjs && node test/client-bundle-id.test.mjs && node test/client-prefill.test.mjs && node test/displace-original.test.mjs"
7
+ "test": "node test/redirect-scheme.test.mjs && node test/proxy-host.test.mjs && node test/trusted-origin.test.mjs && node test/oidc.test.mjs && node test/oidc-state-ttl.test.mjs && node test/set-cookie-multi.test.mjs && node test/oidc-integration.test.mjs && node test/oidc-config-store.test.mjs && node test/adopt-data-dir.test.mjs && node test/oidc-ec.test.mjs && node test/jwks-refresh.test.mjs && node test/oidc-bind.test.mjs && node test/client-bundle-id.test.mjs && node test/client-prefill.test.mjs && node test/displace-original.test.mjs && node test/oidc-redirect-matrix.test.mjs && node test/gate-default-deny.test.mjs"
8
8
  },
9
9
  "exports": {
10
10
  ".": "./index.js",