dsh-webui-oauth 0.7.0 → 0.7.1

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,42 @@
2
2
 
3
3
  本文件记录每个版本的**用户可见**变更。破坏性变更与升级动作单独标注。
4
4
 
5
+ ## 0.7.1
6
+
7
+ 兼容 dsh **0.2.2-rc.1**:`webServer.fallback` 从函数改成了 `{ resource }`。
8
+
9
+ ### 修复:访问 `/` 返回 500 空响应(nginx 日志 `500 0`)
10
+
11
+ - **现象**:升级到 dsh 0.2.2-rc.1 后,访问 `/` 在完成核心 token 交换后返回
12
+ `500`,body 为空;nginx 访问日志表现为 `500 0`。
13
+ - **原因**:0.2.2-rc.1 起 `webServer.fallback` 不再一定是函数——
14
+ `dsh-host-frontend-static` 注册的是 `{ resource }`(Fetch 风格
15
+ `resource(request, peer) -> Response`)。插件仍按 `await fallback(req, res)`
16
+ 调用,抛 `TypeError: fallback is not a function`,被外层 `catch` 兜成
17
+ `500` + 空 body。
18
+ - **修复**:新增 `serveFallback()` 统一分派三种形状:
19
+ 1. 函数(旧核心);
20
+ 2. `{ resource }` + `webServer.serveResource`(0.2.2-rc.1+);
21
+ 3. `{ handler }`(部分中间版本)。
22
+ 没有 fallback 时返回 `false`,由调用方 404——不再把"形状不匹配"误报成 500。
23
+
24
+ ### 安全:资源型 fallback 也必须上闸门
25
+
26
+ 此前 `wrapExistingFallback` 只处理 `typeof === 'function'`,遇到 `{ resource }`
27
+ 直接跳过,等于把兜底入口留给匿名请求。现在:
28
+
29
+ - `resource(request, peer)` 会被包装:从 Fetch Request 的 `cookie` 头取会话,
30
+ 未登录返回 `401 Response`,已登录原样放行;
31
+ - `registerFallback` 注册时的三种形状都会在注册那一刻上闸门;
32
+ - `undo` 会把 `resource` / `handler` 原样还原。
33
+
34
+ ### 测试
35
+
36
+ - 新增 `test/fallback-shape.test.mjs`(19 项):三种形状分派、无 fallback /
37
+ 未知形状 / 缺 `serveResource` 均不抛异常、资源型 fallback 的匿名拒绝与
38
+ 登录放行、`undo` 还原。
39
+ - 全量 18 个测试文件、488 项断言通过。
40
+
5
41
  ## 0.7.0
6
42
 
7
43
  新增**「仅允许 SSO」**开关;同时修复一个会把 OIDC 绑定悄悄弄丢的老问题。
package/README.en.md CHANGED
@@ -401,6 +401,7 @@ wired into `npm test`).
401
401
 
402
402
  ## Known limits
403
403
 
404
+ - **Fallback shape on dsh 0.2.2-rc.1**: since that version `webServer.fallback` may be `{ resource }` (that is how `dsh-host-frontend-static` registers it) instead of a function. The plugin now handles function / `{ resource }` / `{ handler }`; a resource fallback is gated as well. If `/` returns an empty `500` after upgrading (nginx logs `500 0`), make sure the plugin is ≥ 0.7.1.
404
405
  - **Inherent runtime-wrapping window**: between a route-object replacement (service hot-reload) and the next rescan (≤10s) there is an unprotected window; the fail-closed check on enabling covers the "initially exposed" case, so this window only affects hot-reload during runtime.
405
406
  - **WebSocket and `trustedHosts`**: in reverse-proxy / LAN deployments (non-loopback Host), WS downlinks need the public hostname added to `client-connection.trustedHosts` in the DSH config (see "Architecture").
406
407
  - **Proxy on a different host**: if the reverse proxy is not on the same machine as DSH (non-loopback peer), the proxy headers are not trusted and rate limiting aggregates per proxy IP (degrades to a global bucket).
package/README.md CHANGED
@@ -411,6 +411,7 @@ IdP 侧日志确认完整往返:`discovery → jwks → authorize(issued code)
411
411
 
412
412
  ## 已知边界
413
413
 
414
+ - **dsh 0.2.2-rc.1 的 fallback 形状**:该版本起 `webServer.fallback` 可能是 `{ resource }`(`dsh-host-frontend-static` 就是这样注册的),而不再是函数。插件已兼容函数 / `{ resource }` / `{ handler }` 三种形状;资源型 fallback 同样会被闸门包装。若升级后 `/` 返回 `500` 且 body 为空(nginx 日志 `500 0`),请确认插件版本 ≥ 0.7.1。
414
415
  - **运行时包装的固有窗口**:路由对象被替换(服务热重载)到下一次重扫之间(≤10s)存在未保护窗口;启用认证时的 fail-closed 已挡住「初始裸奔」,此窗口仅影响运行中的热重载场景。
415
416
  - **WS 与 trustedHosts**:反代/局域网(非回环 Host)下,WS 下行需在 dsh 配置 `client-connection.trustedHosts` 中加入对外域名(见「架构」节)。
416
417
  - **反代不同机**:若反代与 DSH 不在同一台机器(对端非回环),代理头不被信任,限流将按代理 IP 聚合(退化为全局桶)。
package/index.js CHANGED
@@ -1154,6 +1154,41 @@ function wantsHtml(req) {
1154
1154
  }
1155
1155
  }
1156
1156
 
1157
+ /**
1158
+ * 把请求交给 webServer 的 fallback。
1159
+ *
1160
+ * 0.2.2-rc.1 起 fallback 不再一定是函数:dsh-host-frontend-static 注册的是
1161
+ * `{ resource }`(Fetch 风格的 `resource(request, peer) -> Response`)。
1162
+ * 旧代码直接 `await fallback(req, res)` 会抛 TypeError(fallback is not a function),
1163
+ * 被外层 catch 兜成 `500` + 空 body——nginx 访问日志里就是 `500 0`。
1164
+ *
1165
+ * 这里统一三种形状:
1166
+ * 1. 函数(旧核心 / 第三方 handler);
1167
+ * 2. `{ resource }` + webServer.serveResource(0.2.2-rc.1+);
1168
+ * 3. `{ handler }`(部分中间版本)。
1169
+ *
1170
+ * @returns {Promise<boolean>} 是否已由 fallback 处理;false 表示没有 fallback,
1171
+ * 调用方应自行返回 404(不要把"没有 fallback"误报成 500)。
1172
+ */
1173
+ export async function serveFallback(ctx, req, res) {
1174
+ const ws = ctx && ctx.webServer
1175
+ if (!ws) return false
1176
+ const fallback = ws.fallback
1177
+ if (typeof fallback === 'function') {
1178
+ await fallback(req, res)
1179
+ return true
1180
+ }
1181
+ if (fallback && typeof fallback.resource === 'function' && typeof ws.serveResource === 'function') {
1182
+ await ws.serveResource(req, res, new URL(req.url || '/', 'http://x'), fallback.resource)
1183
+ return true
1184
+ }
1185
+ if (fallback && typeof fallback.handler === 'function') {
1186
+ await fallback.handler(req, res)
1187
+ return true
1188
+ }
1189
+ return false
1190
+ }
1191
+
1157
1192
  function rejectUpgrade401(socket) {
1158
1193
  try {
1159
1194
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 13\r\n\r\nunauthorized\n')
@@ -1373,7 +1408,12 @@ function installRouteGate(ctx, checkRequest, log, options) {
1373
1408
  // ---------------- fallback 也要包 ----------------
1374
1409
  // fallback 是"未被任何具名路由认领"的兜底处理器。它同样可能被第三方插件占据,
1375
1410
  // 若不管,攻击者只要找到一个不存在的路径就可能落到未鉴权的 fallback 上。
1376
- const wrappedFallbacks = new WeakSet()
1411
+ //
1412
+ // 0.2.2-rc.1 起 fallback 还可能是 { resource }(Fetch 风格):既不能当函数调用
1413
+ // (TypeError),也不能放任它绕过闸门。两种形状分别包装。
1414
+ const wrappedFallbacks = new WeakSet() // 原始 handler 函数
1415
+ const wrappedResources = new WeakSet() // 原始 resource 函数
1416
+
1377
1417
  const gateFallback = (handler) => {
1378
1418
  if (typeof handler !== 'function' || wrappedFallbacks.has(handler)) return handler
1379
1419
  const original = handler
@@ -1385,23 +1425,71 @@ function installRouteGate(ctx, checkRequest, log, options) {
1385
1425
  }
1386
1426
  return original(req, res)
1387
1427
  }
1388
- wrappedFallbacks.add(gated)
1428
+ wrappedFallbacks.add(original)
1389
1429
  fallbackWrapped = true
1390
1430
  log('wrapped fallback handler')
1391
1431
  return gated
1392
1432
  }
1393
1433
 
1394
- // 已经就位的 fallback:直接替换属性(保留原函数以便 undo)。
1434
+ const gateResource = (resource) => {
1435
+ if (typeof resource !== 'function' || wrappedResources.has(resource)) return resource
1436
+ const original = resource
1437
+ const gated = async (request, peer) => {
1438
+ // resource 收到的是 Fetch Request;checkRequest 只依赖 cookie 头。
1439
+ const cookie = request && request.headers && typeof request.headers.get === 'function'
1440
+ ? (request.headers.get('cookie') || '')
1441
+ : ''
1442
+ const headers = { cookie }
1443
+ if (!checkRequest({ method: request && request.method, url: request && request.url, headers })) {
1444
+ return new Response('unauthorized', {
1445
+ status: 401,
1446
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1447
+ })
1448
+ }
1449
+ return original(request, peer)
1450
+ }
1451
+ wrappedResources.add(original)
1452
+ fallbackWrapped = true
1453
+ log('wrapped fallback resource')
1454
+ return gated
1455
+ }
1456
+
1457
+ // 已经就位的 fallback:按形状替换(保留原值以便 undo)。
1395
1458
  const wrapExistingFallback = () => {
1396
1459
  const current = ws.fallback
1397
- if (typeof current !== 'function') return
1398
- if (wrappedFallbacks.has(current)) return
1399
- if (current.__dshWuaGated === true) return
1400
- const gated = gateFallback(current)
1401
- if (gated === current) return
1402
- gated.__dshWuaGated = true
1403
- ws.fallback = gated
1404
- undos.push(() => { if (ws.fallback === gated) ws.fallback = current })
1460
+ if (typeof current === 'function') {
1461
+ if (wrappedFallbacks.has(current)) return
1462
+ if (current.__dshWuaGated === true) return
1463
+ const gated = gateFallback(current)
1464
+ if (gated === current) return
1465
+ gated.__dshWuaGated = true
1466
+ ws.fallback = gated
1467
+ undos.push(() => { if (ws.fallback === gated) ws.fallback = current })
1468
+ return
1469
+ }
1470
+ if (current && typeof current === 'object') {
1471
+ if (typeof current.resource === 'function') {
1472
+ if (wrappedResources.has(current.resource)) { fallbackWrapped = true; return }
1473
+ if (current.resource.__dshWuaGated === true) return
1474
+ const original = current.resource
1475
+ const gated = gateResource(original)
1476
+ if (gated === original) return
1477
+ gated.__dshWuaGated = true
1478
+ current.resource = gated
1479
+ undos.push(() => { if (current.resource === gated) current.resource = original })
1480
+ return
1481
+ }
1482
+ if (typeof current.handler === 'function') {
1483
+ if (wrappedFallbacks.has(current.handler)) { fallbackWrapped = true; return }
1484
+ if (current.handler.__dshWuaGated === true) return
1485
+ const original = current.handler
1486
+ const gated = gateFallback(original)
1487
+ if (gated === original) return
1488
+ gated.__dshWuaGated = true
1489
+ current.handler = gated
1490
+ undos.push(() => { if (current.handler === gated) current.handler = original })
1491
+ }
1492
+ }
1405
1493
  }
1406
1494
 
1407
1495
  // ---------------- 注册时即拦截(消除重扫窗口) ----------------
@@ -1432,8 +1520,28 @@ function installRouteGate(ctx, checkRequest, log, options) {
1432
1520
  // registerFallback 走的是属性赋值而非 Map,单独接管。
1433
1521
  if (typeof ws.registerFallback === 'function') {
1434
1522
  const originalRegisterFallback = ws.registerFallback
1435
- ws.registerFallback = function patchedRegisterFallback (handler) {
1436
- return originalRegisterFallback.call(this, gateFallback(handler))
1523
+ ws.registerFallback = function patchedRegisterFallback (fallback) {
1524
+ // 三种形状都要上闸门:函数 / { resource } / { handler }。
1525
+ try {
1526
+ if (typeof fallback === 'function') {
1527
+ fallback = gateFallback(fallback)
1528
+ } else if (fallback && typeof fallback === 'object') {
1529
+ if (typeof fallback.resource === 'function' && !wrappedResources.has(fallback.resource)) {
1530
+ const gated = gateResource(fallback.resource)
1531
+ if (gated !== fallback.resource) {
1532
+ gated.__dshWuaGated = true
1533
+ fallback.resource = gated
1534
+ }
1535
+ } else if (typeof fallback.handler === 'function' && !wrappedFallbacks.has(fallback.handler)) {
1536
+ const gated = gateFallback(fallback.handler)
1537
+ if (gated !== fallback.handler) {
1538
+ gated.__dshWuaGated = true
1539
+ fallback.handler = gated
1540
+ }
1541
+ }
1542
+ }
1543
+ } catch (e) { /* 包装失败不阻断注册,靠重扫兜底 */ }
1544
+ return originalRegisterFallback.call(this, fallback)
1437
1545
  }
1438
1546
  undos.push(() => { ws.registerFallback = originalRegisterFallback })
1439
1547
  }
@@ -3399,11 +3507,9 @@ export async function apply(ctx) {
3399
3507
  return
3400
3508
  }
3401
3509
  }
3402
- const fallback = ctx.webServer.fallback
3403
- if (fallback !== undefined) {
3404
- await fallback(req, res)
3405
- return
3406
- }
3510
+ // fallback 可能是函数,也可能是 { resource }(0.2.2-rc.1+)。
3511
+ // 交给 serveFallback 统一分派,避免 TypeError → 500 空响应。
3512
+ if (await serveFallback(ctx, req, res)) return
3407
3513
  res.writeHead(404)
3408
3514
  res.end()
3409
3515
  return
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "dsh-webui-oauth",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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 && node test/gate-default-deny.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 && node test/fallback-shape.test.mjs"
8
8
  },
9
9
  "exports": {
10
10
  ".": "./index.js",