dsh-webui-oauth 0.6.6 → 0.6.8
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 +108 -0
- package/README.en.md +16 -2
- package/README.md +18 -2
- package/index.js +332 -42
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,114 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
|
|
4
4
|
|
|
5
|
+
## 0.6.8
|
|
6
|
+
|
|
7
|
+
修复 0.6.7 引入的回归:**登录成功后 `/api` 恒 401**。
|
|
8
|
+
|
|
9
|
+
### 修复:/api 的 loopback 伪装在包装时被固化
|
|
10
|
+
|
|
11
|
+
- **现象**:升级到 0.6.7 后可以正常登录、页面也能打开,但所有 `/api` 请求一律 401;
|
|
12
|
+
回退到 0.6.6 立即恢复。
|
|
13
|
+
- **原因**:0.6.7 为了让新装的插件"注册即上闸门",接管了 webServer 三张路由表的
|
|
14
|
+
`set`。这改变了包装时机——`/api` 可能在 `client-connection` 服务对本插件上下文
|
|
15
|
+
**尚不可见**时就被包装。旧代码在**包装那一刻**判定一次 `coreHasRequestGate`,
|
|
16
|
+
于是把"这是旧核心"永久固化下来,之后每个 `/api` 请求都走 loopback 伪装:
|
|
17
|
+
`Host` 被改写成 `127.0.0.1`、`Origin` 被删除。
|
|
18
|
+
而 alpha.2+ 核心的浏览器 Cookie **按 Host authority 绑定**,改写后查不到对应
|
|
19
|
+
Cookie,核心随即 401。登录与页面走的是 `/`,不经过这段伪装,所以"能登录、只有
|
|
20
|
+
`/api` 坏"。
|
|
21
|
+
- **修复**:把判定改为**每次请求现查** `ctx.get('connection').requestRejection`,
|
|
22
|
+
不再在包装时定死。路由注册的先后顺序、服务就绪的时序都不再影响结果。
|
|
23
|
+
旧核心(始终没有 `requestRejection`)仍保留 loopback 伪装,行为不变。
|
|
24
|
+
|
|
25
|
+
### 测试
|
|
26
|
+
|
|
27
|
+
- `test/gate-default-deny.test.mjs` 新增 6 项:
|
|
28
|
+
- 安装时 `connection` 不可见、之后才就绪 → `/api` 的 `Host`/`Origin` 必须保持原样;
|
|
29
|
+
- 旧核心(始终无 `requestRejection`)→ 仍然改写 Host / 删除 Origin,确保修复没误伤。
|
|
30
|
+
- 全量 17 个测试文件、444 项断言通过。
|
|
31
|
+
|
|
32
|
+
## 0.6.7
|
|
33
|
+
|
|
34
|
+
安全修复:路由闸门由**黑名单**改为**默认拒绝的白名单**。此前只包 `/api` 与
|
|
35
|
+
`/plugins`,第三方插件注册的读接口完全裸奔。
|
|
36
|
+
|
|
37
|
+
### 修复:未登录可读任意第三方插件的路由
|
|
38
|
+
|
|
39
|
+
- **现象(实测)**:未登录、不带任何 Cookie,以下端点直接 200:
|
|
40
|
+
- `/dsh-550c-boot/update` —— 版本 / 镜像指纹
|
|
41
|
+
- `/dsh-any-background/font` —— 用户上传字体(12,079,480 B)
|
|
42
|
+
- `/dsh-any-background/wallpaper` —— 用户壁纸
|
|
43
|
+
与此同时写端点(`/font/upload` 等)因为走 connection RPC 通道反而 401。
|
|
44
|
+
即典型的「**写保护、读裸奔**」。
|
|
45
|
+
- **原因**:webServer 的匹配顺序是「exact 优先,其次最长前缀」。闸门原来只包装
|
|
46
|
+
`/api` 与 `/plugins` 两条前缀,第三方插件注册的 `/dsh-any-background`、
|
|
47
|
+
`/dsh-550c-boot/update` 等比本插件的 `""` 前缀更具体,会抢在闸门之前命中,
|
|
48
|
+
于是完全绕过鉴权。黑名单永远追不上「下一个装上的插件」。
|
|
49
|
+
- **修复**:改为**默认拒绝**。安装闸门时枚举并包装 webServer 上的**全部**路由:
|
|
50
|
+
- 所有 `exact` 路由 → 401(白名单/自管端点除外)
|
|
51
|
+
- 所有 `prefix` 路由 → 401(仅本插件自己的 `""` 传输闸门除外,它负责 302 到登录页)
|
|
52
|
+
- **所有** WebSocket upgrade → 一律拒绝(不再只认 `/api/remote.mux`)
|
|
53
|
+
- `fallback` 处理器 → 同样要求会话
|
|
54
|
+
- 并且接管 `exact/prefixes/upgrades` 三张路由表的 `set`,**注册那一刻就上闸门**,
|
|
55
|
+
消除「新装插件到下一次重扫(最长 10s)之间」的裸奔窗口。
|
|
56
|
+
|
|
57
|
+
### 公开白名单(能匿名访问的全部路径)
|
|
58
|
+
|
|
59
|
+
`/dsh-webui-oauth/login`、`/setup`、`/oidc/login`、`/oidc/callback`、
|
|
60
|
+
`/oauth/callback`(账号平台 OAuth 回跳,自带 state 常量时间比对)。
|
|
61
|
+
|
|
62
|
+
其余 `/dsh-webui-oauth/*` 端点(status / audit / configure / disable / logout /
|
|
63
|
+
oidc/bind / oidc/unbind / oidc/logout)保持「各 handler 自己 checkRequest」,
|
|
64
|
+
不套通用闸门,避免改变其 JSON 错误语义。
|
|
65
|
+
|
|
66
|
+
### 新增:部署方可追加公开路径
|
|
67
|
+
|
|
68
|
+
机器调用的端点(例如签名校验的 GitHub webhook)需要匿名可达。在 dsh settings 里声明:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
remote-web-ui:
|
|
72
|
+
publicPaths:
|
|
73
|
+
- /api/webhooks/github # 精确路径
|
|
74
|
+
- /api/webhooks/* # 前缀(结尾 /*)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
未声明的路径一律拒绝。配置热更新后下一次扫描(≤10s)生效,无需重启。
|
|
78
|
+
|
|
79
|
+
### 可观测性
|
|
80
|
+
|
|
81
|
+
`/dsh-webui-oauth/status` 的 `gate` 现在回显实际保护范围,便于运维核对,
|
|
82
|
+
而不是只看到一句「闸门已开」:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"gate": {
|
|
87
|
+
"ok": true,
|
|
88
|
+
"mode": "deny-by-default",
|
|
89
|
+
"stats": {
|
|
90
|
+
"wrappedExact": 8, "wrappedPrefix": 8,
|
|
91
|
+
"wrappedUpgrade": 1, "wrappedFallback": 1,
|
|
92
|
+
"publicPaths": ["/dsh-webui-oauth/login", "..."],
|
|
93
|
+
"exemptSelfGated": ["/dsh-webui-oauth/status", "..."]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 升级注意
|
|
100
|
+
|
|
101
|
+
- 升级后**第三方插件的所有路由默认需要登录**。如果某个端点确实要匿名可达
|
|
102
|
+
(webhook、回调),请按上面的 `publicPaths` 显式声明,否则会 401。
|
|
103
|
+
- 登录页、OIDC 往返、应用根的行为不变;实测登录 → 进应用 → 各插件功能均正常。
|
|
104
|
+
|
|
105
|
+
### 测试
|
|
106
|
+
|
|
107
|
+
- 新增 `test/gate-default-deny.test.mjs`(53 项):锁定默认拒绝、白名单例外、
|
|
108
|
+
晚注册路由无窗口期、upgrade 与 fallback 覆盖、`undo` 完全可逆。
|
|
109
|
+
- 真实实例端到端验证(dsh + `dsh-550c-boot` + `dsh-any-background` + 本插件):
|
|
110
|
+
匿名访问上述三个端点由 200 变为 **401**,登录后仍 200/404(正常业务语义)。
|
|
111
|
+
- 全量 17 个测试文件、434 项断言通过。
|
|
112
|
+
|
|
5
113
|
## 0.6.6
|
|
6
114
|
|
|
7
115
|
对 **OIDC 授权跳转**做一次全量验证(固定路径 / 反代 / 浏览器动态三路),
|
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
|
-
|
|
|
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
|
-
|
|
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.
|
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
|
-
|
|
|
87
|
+
| **第三方插件的任意路由**(exact / prefix) | 默认拒绝:安装时枚举并包装**全部**路由,且接管路由表 `set` 在注册瞬间上闸门 | 401 |
|
|
88
|
+
| WebSocket(**所有** upgrade 路由) | 运行时包装每个 upgrade handler | 401 拒绝升级 |
|
|
89
|
+
| fallback(未被具名路由认领的请求) | 运行时包装 fallback handler | 401 |
|
|
88
90
|
|
|
89
|
-
|
|
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。
|
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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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')
|
|
@@ -1090,26 +1200,35 @@ function installRouteGate(ctx, checkRequest, log) {
|
|
|
1090
1200
|
const undos = []
|
|
1091
1201
|
let undone = false
|
|
1092
1202
|
|
|
1203
|
+
// alpha.2 起核心自带 Connection 浏览器认证(BrowserAuth + trusted-host fence):
|
|
1204
|
+
// /api handler 内建 requestRejection,且 browser cookie 按 Host authority 绑定 ——
|
|
1205
|
+
// 再改写 Host/Origin 会破坏 cookie 匹配。仅旧核心(无 connection.requestRejection)
|
|
1206
|
+
// 需要 loopback 伪装来通过 PRIVILEGED_METHODS fence。
|
|
1207
|
+
//
|
|
1208
|
+
// 【必须按请求判定,不能在包装时判定一次】。0.6.7 引入了"注册瞬间上闸门"
|
|
1209
|
+
// (接管路由表的 set),这使得 /api 可能在 client-connection 服务尚未对本站
|
|
1210
|
+
// 上下文可见时就被包装。若此时把 coreHasRequestGate 固化为 false,
|
|
1211
|
+
// useDeputy 就永久为 true:之后每个 /api 请求都被改写成回环 Host,
|
|
1212
|
+
// 而核心的 authority 绑定 Cookie 随之失配 —— 表现为"登录成功后 /api 恒 401"。
|
|
1213
|
+
// 这是 0.6.7 的回归,改为每次请求现查即可消除对服务就绪时序的依赖。
|
|
1214
|
+
const coreHasRequestGate = () => {
|
|
1215
|
+
try {
|
|
1216
|
+
const connection = ctx.get('connection')
|
|
1217
|
+
return !!(connection && typeof connection.requestRejection === 'function')
|
|
1218
|
+
} catch (e) {
|
|
1219
|
+
return false
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1093
1222
|
const wrapHttp = (route, opts) => {
|
|
1094
1223
|
const original = route.handler
|
|
1095
1224
|
const loopbackDeputy = !!(opts && opts.loopbackDeputy)
|
|
1096
|
-
// alpha.2 起核心自带 Connection 浏览器认证(BrowserAuth + trusted-host fence):
|
|
1097
|
-
// /api handler 内建 requestRejection,且 browser cookie 按 Host authority 绑定 ——
|
|
1098
|
-
// 再改写 Host/Origin 会破坏 cookie 匹配。仅旧核心(无 connection.requestRejection)
|
|
1099
|
-
// 需要 loopback 伪装来通过 PRIVILEGED_METHODS fence。
|
|
1100
|
-
let coreHasRequestGate = false
|
|
1101
|
-
try {
|
|
1102
|
-
const connection = ctx.get('connection')
|
|
1103
|
-
coreHasRequestGate = !!(connection && typeof connection.requestRejection === 'function')
|
|
1104
|
-
} catch (e) { coreHasRequestGate = false }
|
|
1105
|
-
const useDeputy = loopbackDeputy && !coreHasRequestGate
|
|
1106
1225
|
const wrapped = async (req, res) => {
|
|
1107
1226
|
if (!checkRequest(req)) {
|
|
1108
1227
|
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' })
|
|
1109
1228
|
res.end('unauthorized')
|
|
1110
1229
|
return
|
|
1111
1230
|
}
|
|
1112
|
-
if (
|
|
1231
|
+
if (loopbackDeputy && !coreHasRequestGate()) {
|
|
1113
1232
|
// Authenticated + this is the /api surface on a legacy core: present the request
|
|
1114
1233
|
// to the core as loopback so PRIVILEGED_METHODS' strict fence (settings.*,
|
|
1115
1234
|
// credentials.*, agentPreset.*, llm.discoverModels) admits it. Our session gate
|
|
@@ -1141,31 +1260,170 @@ function installRouteGate(ctx, checkRequest, log) {
|
|
|
1141
1260
|
return () => { route.handler = original }
|
|
1142
1261
|
}
|
|
1143
1262
|
|
|
1144
|
-
//
|
|
1145
|
-
|
|
1146
|
-
|
|
1263
|
+
// 已包装的路由 → { kind, path, undo }。
|
|
1264
|
+
// 用 Map 而不是 WeakSet,是为了支持"配置热更新后把已包的路由解包":
|
|
1265
|
+
// publicPaths 追加某路径后,该路径若之前已被包装,需要在下一次扫描时撤销,
|
|
1266
|
+
// 否则文档里说的"无需重启生效"就不成立。
|
|
1267
|
+
const wrappedRoutes = new Map()
|
|
1268
|
+
|
|
1269
|
+
// 部署方追加的公开路径(settings.remote-web-ui.publicPaths)。
|
|
1270
|
+
// 每次扫描重读一次:配置热更新后无需重启即可生效。
|
|
1271
|
+
let publicPatterns = extraPublicPaths(ctx)
|
|
1272
|
+
|
|
1273
|
+
// 当前生效的闸门策略快照,供 status 端点回显(便于运维核对"到底放行了什么")。
|
|
1274
|
+
// 计数在读取时从 wrappedRoutes 现算,避免 counters 与实际状态漂移;
|
|
1275
|
+
// 豁免项用 Set 去重(scanOnce 每 2 秒跑一次,数组会无限增长)。
|
|
1276
|
+
const stats = { publicPaths: new Set(), exemptPrefixes: new Set(), exemptSelfGated: new Set() }
|
|
1277
|
+
let fallbackWrapped = false
|
|
1278
|
+
|
|
1279
|
+
/** 是否为公开路径:内置最小集合 + 部署方追加的模式。 */
|
|
1280
|
+
const isPublicPath = (path) => PUBLIC_EXACT_PATHS.has(path) || matchesPublicPath(path, publicPatterns)
|
|
1281
|
+
|
|
1282
|
+
/** 精确路径/前缀是否属于本插件自管或已声明公开。 */
|
|
1283
|
+
const isExemptHttp = (path, kind) => {
|
|
1284
|
+
if (isPublicPath(path)) return true
|
|
1285
|
+
if (kind === 'exact' && SELF_GATED_EXACT_PATHS.has(path)) return true
|
|
1286
|
+
// 空前缀是本插件自己的传输层闸门(未登录时 302 到登录页)。
|
|
1287
|
+
// 它不能套通用闸门,否则登录页永远打不开(401 死循环)。
|
|
1288
|
+
if (kind === 'prefix' && path === '') return true
|
|
1289
|
+
return false
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* 包一条 HTTP 路由(若它不在白名单里)。
|
|
1294
|
+
* @returns {boolean} 本次是否真的包装了
|
|
1295
|
+
*/
|
|
1296
|
+
const wrapRoute = (route, kind) => {
|
|
1297
|
+
if (!route || typeof route !== 'object' || wrappedRoutes.has(route)) return false
|
|
1298
|
+
if (typeof route.handler !== 'function') return false
|
|
1299
|
+
const path = typeof route.path === 'string' ? route.path : ''
|
|
1300
|
+
if (isExemptHttp(path, kind)) {
|
|
1301
|
+
if (kind === 'prefix' && path === '') stats.exemptPrefixes.add('(root)')
|
|
1302
|
+
else if (kind === 'exact' && !isPublicPath(path)) stats.exemptSelfGated.add(path)
|
|
1303
|
+
return false
|
|
1304
|
+
}
|
|
1305
|
+
const undoFn = wrapHttp(route, { loopbackDeputy: kind === 'prefix' && path === API_PREFIX })
|
|
1306
|
+
wrappedRoutes.set(route, { kind, path, undo: undoFn })
|
|
1307
|
+
undos.push(undoFn)
|
|
1308
|
+
log('wrapped ' + kind + ' route ' + (path === '' ? '(root)' : path))
|
|
1309
|
+
return true
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** 包一条 upgrade 路由(WebSocket)。默认拒绝,不做白名单。 */
|
|
1313
|
+
const wrapUpgradeRoute = (route) => {
|
|
1314
|
+
if (!route || typeof route !== 'object' || wrappedRoutes.has(route)) return false
|
|
1315
|
+
if (typeof route.handler !== 'function') return false
|
|
1316
|
+
const undoFn = wrapUpgrade(route)
|
|
1317
|
+
wrappedRoutes.set(route, { kind: 'upgrade', path: route.path || '', undo: undoFn })
|
|
1318
|
+
undos.push(undoFn)
|
|
1319
|
+
log('wrapped upgrade route ' + (route.path || '(unnamed)'))
|
|
1320
|
+
return true
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ---------------- fallback 也要包 ----------------
|
|
1324
|
+
// fallback 是"未被任何具名路由认领"的兜底处理器。它同样可能被第三方插件占据,
|
|
1325
|
+
// 若不管,攻击者只要找到一个不存在的路径就可能落到未鉴权的 fallback 上。
|
|
1326
|
+
const wrappedFallbacks = new WeakSet()
|
|
1327
|
+
const gateFallback = (handler) => {
|
|
1328
|
+
if (typeof handler !== 'function' || wrappedFallbacks.has(handler)) return handler
|
|
1329
|
+
const original = handler
|
|
1330
|
+
const gated = async (req, res) => {
|
|
1331
|
+
if (!checkRequest(req)) {
|
|
1332
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' })
|
|
1333
|
+
res.end('unauthorized')
|
|
1334
|
+
return
|
|
1335
|
+
}
|
|
1336
|
+
return original(req, res)
|
|
1337
|
+
}
|
|
1338
|
+
wrappedFallbacks.add(gated)
|
|
1339
|
+
fallbackWrapped = true
|
|
1340
|
+
log('wrapped fallback handler')
|
|
1341
|
+
return gated
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// 已经就位的 fallback:直接替换属性(保留原函数以便 undo)。
|
|
1345
|
+
const wrapExistingFallback = () => {
|
|
1346
|
+
const current = ws.fallback
|
|
1347
|
+
if (typeof current !== 'function') return
|
|
1348
|
+
if (wrappedFallbacks.has(current)) return
|
|
1349
|
+
if (current.__dshWuaGated === true) return
|
|
1350
|
+
const gated = gateFallback(current)
|
|
1351
|
+
if (gated === current) return
|
|
1352
|
+
gated.__dshWuaGated = true
|
|
1353
|
+
ws.fallback = gated
|
|
1354
|
+
undos.push(() => { if (ws.fallback === gated) ws.fallback = current })
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// ---------------- 注册时即拦截(消除重扫窗口) ----------------
|
|
1358
|
+
// 仅靠周期重扫会有最长 2s(降频后 10s)的"新路由尚未包装"窗口:插件热加载
|
|
1359
|
+
// 后立刻被匿名访问就能漏过去。因此直接接管 Map#set,在注册的那一刻包装。
|
|
1360
|
+
const interceptMapSet = (map, kind) => {
|
|
1361
|
+
if (!map || typeof map.set !== 'function') return
|
|
1362
|
+
const originalSet = map.set
|
|
1363
|
+
map.set = function patchedSet (key, value) {
|
|
1364
|
+
try { wrapRoute(value, kind) } catch (e) { /* 包装失败不阻断注册,靠重扫兜底 */ }
|
|
1365
|
+
return originalSet.call(this, key, value)
|
|
1366
|
+
}
|
|
1367
|
+
undos.push(() => { map.set = originalSet })
|
|
1368
|
+
}
|
|
1369
|
+
interceptMapSet(ws.exact, 'exact')
|
|
1370
|
+
interceptMapSet(ws.prefixes, 'prefix')
|
|
1371
|
+
|
|
1372
|
+
// upgrade 表同理:新注册的 WebSocket 路由也要在注册那一刻就上闸门。
|
|
1373
|
+
if (ws.upgrades && typeof ws.upgrades.set === 'function') {
|
|
1374
|
+
const originalUpgradeSet = ws.upgrades.set
|
|
1375
|
+
ws.upgrades.set = function patchedUpgradeSet (key, value) {
|
|
1376
|
+
try { wrapUpgradeRoute(value) } catch (e) { /* 靠重扫兜底 */ }
|
|
1377
|
+
return originalUpgradeSet.call(this, key, value)
|
|
1378
|
+
}
|
|
1379
|
+
undos.push(() => { ws.upgrades.set = originalUpgradeSet })
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// registerFallback 走的是属性赋值而非 Map,单独接管。
|
|
1383
|
+
if (typeof ws.registerFallback === 'function') {
|
|
1384
|
+
const originalRegisterFallback = ws.registerFallback
|
|
1385
|
+
ws.registerFallback = function patchedRegisterFallback (handler) {
|
|
1386
|
+
return originalRegisterFallback.call(this, gateFallback(handler))
|
|
1387
|
+
}
|
|
1388
|
+
undos.push(() => { ws.registerFallback = originalRegisterFallback })
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1147
1391
|
const scanOnce = () => {
|
|
1148
1392
|
if (undone) return
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1393
|
+
publicPatterns = extraPublicPaths(ctx)
|
|
1394
|
+
stats.publicPaths = new Set([...PUBLIC_EXACT_PATHS, ...publicPatterns])
|
|
1395
|
+
|
|
1396
|
+
// 配置热更新可能**新增**公开路径:已包装的对应路由必须解包,否则"无需重启
|
|
1397
|
+
// 生效"是假的。只处理 HTTP 路由(upgrade 不做白名单)。
|
|
1398
|
+
for (const [route, rec] of [...wrappedRoutes]) {
|
|
1399
|
+
if (rec.kind === 'upgrade') continue
|
|
1400
|
+
if (!isExemptHttp(rec.path, rec.kind)) continue
|
|
1401
|
+
try { rec.undo() } catch (e) { /* ignore */ }
|
|
1402
|
+
wrappedRoutes.delete(route)
|
|
1403
|
+
log('unwrapped ' + rec.kind + ' route ' + (rec.path || '(root)') + ' (now public)')
|
|
1159
1404
|
}
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1405
|
+
// 快照一遍再遍历:包装过程中不改 Map,但注册可能并发发生(单线程下安全,
|
|
1406
|
+
// 仍显式取快照以免迭代语义依赖实现细节)。
|
|
1407
|
+
const exactEntries = ws.exact && typeof ws.exact.forEach === 'function' ? [...ws.exact] : []
|
|
1408
|
+
for (const [, route] of exactEntries) wrapRoute(route, 'exact')
|
|
1409
|
+
|
|
1410
|
+
const prefixEntries = ws.prefixes && typeof ws.prefixes.forEach === 'function' ? [...ws.prefixes] : []
|
|
1411
|
+
for (const [, route] of prefixEntries) wrapRoute(route, 'prefix')
|
|
1412
|
+
|
|
1413
|
+
// 所有 WebSocket upgrade 一律包装(不再只认 /api/remote.mux):
|
|
1414
|
+
// 第三方插件注册的 upgrade 同样可能暴露未鉴权的双向通道。
|
|
1415
|
+
const upgradeEntries = ws.upgrades && typeof ws.upgrades.forEach === 'function' ? [...ws.upgrades] : []
|
|
1416
|
+
for (const [, route] of upgradeEntries) wrapUpgradeRoute(route)
|
|
1417
|
+
|
|
1418
|
+
wrapExistingFallback()
|
|
1419
|
+
|
|
1420
|
+
// fail-closed 的判据:核心的 /api 与 /plugins 必须就位,否则说明路由形状
|
|
1421
|
+
// 变了,"已启用"可能意味着裸奔。这里只保留这两条(以及事件流 upgrade),
|
|
1422
|
+
// 不再把"只包了这两个"当成保护范围。
|
|
1423
|
+
for (const pfx of [API_PREFIX, PLUGINS_PREFIX]) {
|
|
1424
|
+
const route = ws.prefixes.get(pfx)
|
|
1425
|
+
if (route === undefined) problems.add(`prefix route "${pfx}" not registered yet`)
|
|
1426
|
+
else problems.delete(`prefix route "${pfx}" not registered yet`)
|
|
1169
1427
|
}
|
|
1170
1428
|
if (UPGRADE_CANDIDATES.every((path) => ws.upgrades.get(path) === undefined)) {
|
|
1171
1429
|
problems.add('no upgrade route registered yet (looked for ' + UPGRADE_CANDIDATES.join(', ') + ')')
|
|
@@ -1208,7 +1466,33 @@ function installRouteGate(ctx, checkRequest, log) {
|
|
|
1208
1466
|
undone = true
|
|
1209
1467
|
for (const u of undos.splice(0).reverse()) { try { u() } catch (e) { /* ignore */ } }
|
|
1210
1468
|
}
|
|
1211
|
-
return {
|
|
1469
|
+
return {
|
|
1470
|
+
ok: () => problems.size === 0,
|
|
1471
|
+
problems: () => [...problems],
|
|
1472
|
+
// 供 status 端点回显:让运维能直接看到"包了几条、放行了什么",
|
|
1473
|
+
// 而不是只能相信"闸门开着"这句话。
|
|
1474
|
+
stats: () => {
|
|
1475
|
+
// 现算而非累加:解包/重包后计数不会漂移。
|
|
1476
|
+
let exact = 0, prefix = 0, upgrade = 0
|
|
1477
|
+
for (const rec of wrappedRoutes.values()) {
|
|
1478
|
+
if (rec.kind === 'exact') exact += 1
|
|
1479
|
+
else if (rec.kind === 'prefix') prefix += 1
|
|
1480
|
+
else if (rec.kind === 'upgrade') upgrade += 1
|
|
1481
|
+
}
|
|
1482
|
+
return {
|
|
1483
|
+
wrappedExact: exact,
|
|
1484
|
+
wrappedPrefix: prefix,
|
|
1485
|
+
wrappedUpgrade: upgrade,
|
|
1486
|
+
wrappedFallback: fallbackWrapped ? 1 : 0,
|
|
1487
|
+
publicPaths: [...stats.publicPaths].sort(),
|
|
1488
|
+
exemptPrefixes: [...stats.exemptPrefixes].sort(),
|
|
1489
|
+
exemptSelfGated: [...stats.exemptSelfGated].sort(),
|
|
1490
|
+
}
|
|
1491
|
+
},
|
|
1492
|
+
// 立即重扫一次(配置变更后测试/运维可直接触发,不必等 2s 定时器)。
|
|
1493
|
+
refresh: () => { scanOnce(); return { ok: problems.size === 0, problems: [...problems] } },
|
|
1494
|
+
undo,
|
|
1495
|
+
}
|
|
1212
1496
|
}
|
|
1213
1497
|
|
|
1214
1498
|
// ---------------- DSH 外观偏好 ----------------
|
|
@@ -2247,7 +2531,13 @@ export async function apply(ctx) {
|
|
|
2247
2531
|
username: enabled ? creds.username : null,
|
|
2248
2532
|
ttl: ttlOf(creds),
|
|
2249
2533
|
sessionsPersisted: sessions.ok,
|
|
2250
|
-
gate: {
|
|
2534
|
+
gate: {
|
|
2535
|
+
ok: gate.ok(),
|
|
2536
|
+
problems: gate.problems().slice(0, 3),
|
|
2537
|
+
mode: 'deny-by-default',
|
|
2538
|
+
// 让运维能核对"到底包了哪些、放行了哪些",而不是只看一句"闸门已开"。
|
|
2539
|
+
stats: gate.stats(),
|
|
2540
|
+
},
|
|
2251
2541
|
trustedOrigin: (() => {
|
|
2252
2542
|
try {
|
|
2253
2543
|
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.
|
|
3
|
+
"version": "0.6.8",
|
|
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 && node test/oidc-redirect-matrix.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",
|