dsh-remote-bridge 2.5.0 → 2.5.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/README.en.md CHANGED
@@ -9,7 +9,7 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <img alt="Version" src="https://img.shields.io/badge/version-v2.5.0-087f8c">
12
+ <img alt="Version" src="https://img.shields.io/badge/version-v2.5.1-087f8c">
13
13
  <img alt="DSH plugin" src="https://img.shields.io/badge/DeepSeek_Harness-plugin-1f2328">
14
14
  <img alt="Platforms" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-586069">
15
15
  <img alt="License" src="https://img.shields.io/badge/license-AGPL--3.0-2da44e">
@@ -102,10 +102,10 @@ dsh plugin --profile web add dsh-remote-bridge
102
102
  dsh plugin --profile web add dsh-remote-bridge --registry https://registry.npmmirror.com
103
103
 
104
104
  # 2. tarball from the GitHub release
105
- dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.5.0/dsh-remote-bridge-v2.5.0.tgz
105
+ dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.5.1/dsh-remote-bridge-v2.5.1.tgz
106
106
 
107
107
  # 3. tarball from the Gitee release (faster from mainland China)
108
- dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.5.0/dsh-remote-bridge-v2.5.0.tgz
108
+ dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.5.1/dsh-remote-bridge-v2.5.1.tgz
109
109
 
110
110
  npx @deepseek-ai/dsh web
111
111
  ```
@@ -122,7 +122,7 @@ Restart DSH after installation, then open settings — The section is titled **
122
122
  To install a downloaded release archive:
123
123
 
124
124
  ```sh
125
- dsh plugin --profile web add ./dsh-remote-bridge-v2.5.0.tgz
125
+ dsh plugin --profile web add ./dsh-remote-bridge-v2.5.1.tgz
126
126
  ```
127
127
 
128
128
  ## First connection
@@ -358,9 +358,22 @@ pnpm pack
358
358
 
359
359
  v2.5.0 was verified on DSH **0.2.1-rc.2** on real hardware: all 14/14 host services injected, the three jobs endpoints exercised, and the permission catalog returned the kernel's real three presets. The development baseline is upstream `dsh-v0.2.1-rc.2` (commit `687f282e8625c6b8d11ff58211a90524be2e4317`); see `engines.dsh` above for the supported range. CI covers Windows, Ubuntu, and macOS.
360
360
 
361
+ **v2.5.1 verification status**: 47 test files / 398 cases green (including the tunnel-topology
362
+ management regression, the desktop-panel pass-through assertion, and the `it.fails` conversions),
363
+ `typecheck` and `docs:check` pass, and the audit report's §3.1 attack chain was replayed against a real
364
+ `RemoteApiServer` (now 403 for every step). **Not yet verified on real hardware**, and not published to
365
+ npm or either release site — i.e. "not published ≠ not verified ≠ users have it".
366
+
361
367
  ## Project
362
368
 
363
- - Version: `v2.5.0`
369
+ - Version: `v2.5.1`
370
+ - **v2.5.1 is a security release**: it closes a remote privilege escalation where `/manage/**`
371
+ and the loopback admin header were granted to any request whose TCP source looked like loopback —
372
+ which is exactly what a Cloudflare tunnel (or any reverse proxy) makes every public request look like.
373
+ It also fixes `.heif` handoff images, which were always rejected with 415 by the read-only route.
374
+ After upgrading, **`/manage` returns 403 whenever the tunnel is running** (the only exceptions are the
375
+ read-only tunnel-status endpoints, which hide the tunnel URL from non-local callers); the desktop panel
376
+ is unaffected. See `docs/project/SECURITY.md` §「管理面的信任边界」.
364
377
  - Author: upstream [Hakunm](https://github.com/Hakunm); this fork maintained by [westanke](https://github.com/westanke)
365
378
  - Repository: [GitHub](https://github.com/westanke/dsh-remote-bridge) · [Gitee](https://gitee.com/westanke/dsh-remote-bridge)
366
379
  - npm: [`dsh-remote-bridge`](https://www.npmjs.com/package/dsh-remote-bridge) ([mainland-China mirror](https://registry.npmmirror.com/dsh-remote-bridge))
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <img alt="Version" src="https://img.shields.io/badge/version-v2.5.0-087f8c">
12
+ <img alt="Version" src="https://img.shields.io/badge/version-v2.5.1-087f8c">
13
13
  <img alt="DSH plugin" src="https://img.shields.io/badge/DeepSeek_Harness-plugin-1f2328">
14
14
  <img alt="Platforms" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-586069">
15
15
  <img alt="License" src="https://img.shields.io/badge/license-AGPL--3.0-2da44e">
@@ -93,10 +93,10 @@ dsh plugin --profile web add dsh-remote-bridge
93
93
  dsh plugin --profile web add dsh-remote-bridge --registry https://registry.npmmirror.com
94
94
 
95
95
  # ② GitHub Release 的 tarball
96
- dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.5.0/dsh-remote-bridge-v2.5.0.tgz
96
+ dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.5.1/dsh-remote-bridge-v2.5.1.tgz
97
97
 
98
98
  # ③ Gitee Release 的 tarball(国内访问更快)
99
- dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.5.0/dsh-remote-bridge-v2.5.0.tgz
99
+ dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.5.1/dsh-remote-bridge-v2.5.1.tgz
100
100
 
101
101
  npx @deepseek-ai/dsh web
102
102
  ```
@@ -112,7 +112,7 @@ npx @deepseek-ai/dsh web
112
112
  从 GitHub Release 下载离线安装包时,可直接安装 tarball:
113
113
 
114
114
  ```sh
115
- dsh plugin --profile web add ./dsh-remote-bridge-v2.5.0.tgz
115
+ dsh plugin --profile web add ./dsh-remote-bridge-v2.5.1.tgz
116
116
  ```
117
117
 
118
118
  ## 第一次使用
@@ -346,9 +346,19 @@ v2.5.0 在 DSH **0.2.1-rc.2** 上完成真机验证:14/14 宿主服务全部
346
346
  权限档位目录返回内核真实三档。开发基线为上游 `dsh-v0.2.1-rc.2`(commit `687f282e8625c6b8d11ff58211a90524be2e4317`),
347
347
  兼容范围见上文 `engines.dsh`。CI 覆盖 Windows、Ubuntu 和 macOS。
348
348
 
349
+ **v2.5.1 的验证状态**:47 个测试文件 / 398 个用例全绿(含隧道拓扑下的管理面回归、
350
+ 桌面端放行断言、`it.fails` 转正)、`typecheck` 与 `docs:check` 通过,并用真实
351
+ `RemoteApiServer` 重打了审计报告 §3.1 的攻击链(逐条 403)。**尚未做真机验收**,
352
+ 也未发布到 npm / 两站 Release —— 按「未发布 ≠ 已验证 ≠ 用户吃到了」记三态。
353
+
349
354
  ## 项目信息
350
355
 
351
- - 当前版本:`v2.5.0`
356
+ - 当前版本:`v2.5.1`
357
+ - **v2.5.1 是安全修复版**:堵住「开隧道/反代后管理面免凭据对公网开放」的远程提权(`/manage/**`
358
+ 与回环 admin 头此前只按「TCP 来源是否回环」判定,而隧道转发进来的请求来源恒为 `127.0.0.1`),
359
+ 并修复 `.heif` 交接图必被只读路由 415 的问题。升级后**开隧道时 `/manage` 一律 403**
360
+ (唯一例外是只读的隧道状态端点,且对非本机调用方隐去隧道地址);桌面端面板不受影响。
361
+ 见 `docs/project/SECURITY.md` §管理面的信任边界。
352
362
  - 作者:上游 [Hakunm](https://github.com/Hakunm),本 fork 维护 [westanke](https://github.com/westanke)
353
363
  - 仓库:[GitHub](https://github.com/westanke/dsh-remote-bridge) · [Gitee](https://gitee.com/westanke/dsh-remote-bridge)
354
364
  - npm:[`dsh-remote-bridge`](https://www.npmjs.com/package/dsh-remote-bridge)([国内镜像](https://registry.npmmirror.com/dsh-remote-bridge))
@@ -1,7 +1,7 @@
1
1
  asyncapi: 3.0.0
2
2
  info:
3
3
  title: DeepSeek Harness Mobile Events
4
- version: 2.5.0
4
+ version: 2.5.1
5
5
  description: |
6
6
  Live file and authorized DSH chat events. Reconnect clients must refresh
7
7
  REST state; unbounded replay is not provided.
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: DeepSeek Harness Mobile API
4
- version: 2.5.0
4
+ version: 2.5.1
5
5
  description: Versioned file and chat BFF for mobile and third-party DSH clients. HTTP and HTTPS are both supported.
6
6
  servers:
7
7
  - url: http://127.0.0.1:3090/api/v1
@@ -24,7 +24,7 @@ paths:
24
24
  properties:
25
25
  ok: { type: boolean, const: true }
26
26
  version: { type: string, const: v1 }
27
- pluginVersion: { type: string, const: 2.4.2 }
27
+ pluginVersion: { type: string, const: 2.5.1 }
28
28
  /debug/host-services:
29
29
  get:
30
30
  operationId: hostServices
package/lib/client.cjs CHANGED
@@ -30231,6 +30231,10 @@ window.__ModuleLoader__.load({
30231
30231
  errorNotFound: "请求的文件或资源不存在。",
30232
30232
  errorConflict: "目标已存在或当前状态发生冲突。",
30233
30233
  errorRateLimited: "请求过于频繁,请稍后重试。",
30234
+ errorTunnelModeAdminDisabled: "云隧道正在运行,管理面已停用。请先在本机面板里停止隧道,或直接在本机 WebUI 操作。",
30235
+ errorAdminProxyForwarded: "管理面不接受经代理转发的请求。请在本机用 127.0.0.1 或 localhost 直接打开。",
30236
+ errorAdminHostNotLoopback: "管理面只在本机回环地址下可用。请用 127.0.0.1 或 localhost 打开本页。",
30237
+ errorAdminLoopbackRequired: "此操作只能在本机执行。",
30234
30238
  author: "作者 Github@Hakunm",
30235
30239
  version: "当前版本"
30236
30240
  },
@@ -30403,6 +30407,10 @@ window.__ModuleLoader__.load({
30403
30407
  errorNotFound: "The requested file or resource was not found.",
30404
30408
  errorConflict: "The target already exists or its state has changed.",
30405
30409
  errorRateLimited: "Too many requests. Try again shortly.",
30410
+ errorTunnelModeAdminDisabled: "The Cloudflare tunnel is running, so management endpoints are disabled. Stop the tunnel in the local panel, or use the local WebUI.",
30411
+ errorAdminProxyForwarded: "Management endpoints do not accept proxied requests. Open them locally via 127.0.0.1 or localhost.",
30412
+ errorAdminHostNotLoopback: "Management endpoints are available only under a loopback address. Open this page via 127.0.0.1 or localhost.",
30413
+ errorAdminLoopbackRequired: "This operation is available only from the local machine.",
30406
30414
  author: "Author Github@Hakunm",
30407
30415
  version: "Current version"
30408
30416
  }
@@ -31614,6 +31622,10 @@ window.__ModuleLoader__.load({
31614
31622
  if (error.code === "LISTENER_PORT_INVALID") return "listenerPortInvalid";
31615
31623
  if (error.code === "REMOTE_ENABLED") return "disableToEditListener";
31616
31624
  if (error.code === "NO_ENDPOINT") return "configTextNoEndpoints";
31625
+ if (error.code === "TUNNEL_MODE_ADMIN_DISABLED") return "errorTunnelModeAdminDisabled";
31626
+ if (error.code === "ADMIN_PROXY_FORWARDED") return "errorAdminProxyForwarded";
31627
+ if (error.code === "ADMIN_HOST_NOT_LOOPBACK") return "errorAdminHostNotLoopback";
31628
+ if (error.code === "ADMIN_LOOPBACK_REQUIRED") return "errorAdminLoopbackRequired";
31617
31629
  if (error.code.includes("PATH") || error.code.includes("SYMLINK") || error.code.includes("REPARSE")) return "errorPathInvalid";
31618
31630
  if (error.status === 403) return "errorForbidden";
31619
31631
  if (error.status === 404) return "errorNotFound";
package/lib/index.d.ts CHANGED
@@ -1621,16 +1621,40 @@ declare class FixedWindowLimiter {
1621
1621
  }
1622
1622
  //#endregion
1623
1623
  //#region src/host/auth.d.ts
1624
+ interface AuthServiceOptions {
1625
+ /**
1626
+ * 隧道运行状态探针(`CloudflaredTunnelManager.tunnelActive`)。
1627
+ *
1628
+ * 用函数而不是快照:隧道随时启停,鉴权发生在请求那一刻,读的必须是当下状态。
1629
+ * 不注入(测试、未接隧道的构建)即为「没有隧道」,判据 0 恒不触发。
1630
+ */
1631
+ tunnelActive?: () => boolean;
1632
+ }
1624
1633
  declare class AuthService {
1625
1634
  private readonly database;
1635
+ private readonly options;
1626
1636
  private readonly authFailures;
1627
1637
  readonly pairingAttempts: FixedWindowLimiter;
1628
1638
  readonly requests: FixedWindowLimiter;
1629
- constructor(database: WorkspaceDatabase);
1639
+ constructor(database: WorkspaceDatabase, options?: AuthServiceOptions);
1640
+ /**
1641
+ * 隧道是否在跑。探针抛错时**按「在跑」处理**(fail closed):探针读的是我们自己
1642
+ * 对象上的 getter,它抛错意味着状态不可知;状态不可知时放行管理面正是这次修复要
1643
+ * 消灭的那类决定。
1644
+ */
1645
+ private tunnelRunning;
1630
1646
  principal(req: IncomingMessage, options?: {
1631
1647
  allowAdmin?: boolean;
1632
1648
  }): Principal;
1633
- requireAdmin(req: IncomingMessage): Principal;
1649
+ /**
1650
+ * `/manage/**` 的守门人。
1651
+ *
1652
+ * @param options.allowTunnelMode - 只有「隧道状态下读/停隧道」这几个端点会传 true。
1653
+ * 见 `adminRefusal` 的同名说明与 `router.ts` 的 `TUNNEL_MODE_EXEMPT_PATHS`。
1654
+ */
1655
+ requireAdmin(req: IncomingMessage, options?: {
1656
+ allowTunnelMode?: boolean;
1657
+ }): Principal;
1634
1658
  requireScope(principal: Principal, scope: DeviceScope): void;
1635
1659
  requireRoot(principal: Principal, rootId: string): void;
1636
1660
  }
@@ -1850,6 +1874,17 @@ declare class CloudflaredTunnelManager {
1850
1874
  reportActivity(a: TunnelActivity): void;
1851
1875
  /** 任务结束(成功或失败)时清空进行中进度。 */
1852
1876
  clearActivity(): void;
1877
+ /**
1878
+ * 是否有隧道在运行 —— 管理面鉴权的**判据 0**。
1879
+ *
1880
+ * 判据取自**进程持有状态**,不是请求头,所以它对「隧道把外部流量转进 127.0.0.1」
1881
+ * 这条绕过路径 100% 可靠:不管转发进来的请求长什么样,只要隧道开着,管理面就关门。
1882
+ * 见 `auth.ts` 的 `adminRefusal` 与 `PITFALLS.md` §隧道让回环判据失效。
1883
+ *
1884
+ * `running` 与 `publicState.running` 是同一个表达式(同一份真相),这里单独暴露是为了
1885
+ * 让鉴权层不必构造一个含 url 的视图对象 —— 少一处把地址带进意外地方的机会。
1886
+ */
1887
+ get tunnelActive(): boolean;
1853
1888
  get publicState(): TunnelPublicState;
1854
1889
  /** 订阅状态变化;返回取消函数。 */
1855
1890
  onStatus(cb: () => void): () => void;
@@ -1985,6 +2020,22 @@ declare class ApiRouter {
1985
2020
  handle(req: IncomingMessage, res: ServerResponse, stripPrefix?: string): Promise<void>;
1986
2021
  private dispatch;
1987
2022
  private dispatchManagement;
2023
+ /**
2024
+ * 隧道状态视图。
2025
+ *
2026
+ * **这里不做脱敏,因为用不上**:判据 1 已经把 Cloudflare 边缘头(`Cf-Connecting-IP` /
2027
+ * `Cf-Ray` / …)算作「前面有代理」的证据,于是任何真实隧道来源的请求在
2028
+ * `requireAdmin` 就被 403 挡住了,根本到不了这个方法。能走到这里的只有真·本机请求
2029
+ * (判据 1/2/3 全过),所以 `url` 可以照常给它 —— 那是桌面端「用隧道地址生成配置文本」
2030
+ * 的依据。
2031
+ *
2032
+ * 为什么仍然保留这个方法而不是直接 `publicState`:脱敏的位置要留在一个**语义明确**的
2033
+ * 出口上。将来若有人为了「让远程客户端也能读到隧道是否开着」而放宽判据 1,
2034
+ * 必须在这里重新定义脱敏,而不是顺手把 `url` 一起放出去。
2035
+ * 评估结论见报告:本轮选择「远程一律 403」而不是「远程读得到但 `url` 为 null」,
2036
+ * 因为后者需要为状态端点在判据 1 上再开一个口子,而那正是这次修复要消灭的东西。
2037
+ */
2038
+ private tunnelStateFor;
1988
2039
  private dispatchEntries;
1989
2040
  private dispatchContent;
1990
2041
  private requireRoot;
package/lib/index.js CHANGED
@@ -44,6 +44,261 @@ function asApiError(error) {
44
44
  return new ApiError(500, "INTERNAL_ERROR", "An internal server error occurred.");
45
45
  }
46
46
  //#endregion
47
+ //#region src/host/image-handoff.ts
48
+ /**
49
+ * 交接目录的**出厂默认**。
50
+ *
51
+ * 设置面板里「当前默认」那一栏展示的就是这个字面量,且是只读的 —— 它是这份代码的
52
+ * 契约(历史交接消息、识图工具、手机端都按它写过),不是一个可以随风改的偏好项。
53
+ * 用户可以改的是设置的**当前值**(存在数据库里),默认值本身不跟着走。
54
+ */
55
+ const DEFAULT_HANDOFF_SUBDIR = ".dsh-images";
56
+ /** settings 表里的键名。 */
57
+ const IMAGE_SUBDIR_SETTING = "image_handoff_subdir";
58
+ /**
59
+ * 只读图片路由。刻意放在 `/plugin/` 下而不是版本化 REST 前缀里:
60
+ * 它不参与 REST 契约、不需要设备 token,将来 REST 升版时这条也不该跟着动。
61
+ */
62
+ const IMAGE_ROUTE = "/plugin/dsh-remote-bridge/raw";
63
+ /** 目录名上限:它要拼进路径,太长的名字只是拿来坑自己的。 */
64
+ const MAX_SUBDIR_CHARS = 64;
65
+ /**
66
+ * 把用户填的目录名收成一个安全值。
67
+ *
68
+ * 只收**单段**目录名:`/`、`\`、NUL 一律拒绝(否则它就不是「目录名」而是「路径」,
69
+ * 会把落盘位置带出工作目录);`.` 与 `..` 拒绝(前者是当前目录、后者是往上爬);
70
+ * 允许点开头 —— 默认值 `.dsh-images` 自己就是点目录。
71
+ */
72
+ function normalizeHandoffSubdir(value) {
73
+ if (typeof value !== "string") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name must be a string.");
74
+ const trimmed = value.trim();
75
+ if (trimmed === "" || trimmed.length > MAX_SUBDIR_CHARS) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", `The image directory name must be 1-${MAX_SUBDIR_CHARS} characters.`);
76
+ if (trimmed === "." || trimmed === "..") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name cannot be \".\" or \"..\".");
77
+ if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name may only contain letters, digits, dots, underscores, and hyphens.");
78
+ return trimmed;
79
+ }
80
+ /**
81
+ * 读出当前生效的目录名。
82
+ *
83
+ * 数据库里那条坏掉(手改过 / 旧版本写过)就退回默认并**顺手修掉**:一个坏值不该让
84
+ * 「发图」这个动作从此失败,而每次读都只回退不修正会把坏值永远留着。
85
+ */
86
+ function readHandoffSubdir(database) {
87
+ const stored = database.getSetting(IMAGE_SUBDIR_SETTING);
88
+ if (stored === void 0) return DEFAULT_HANDOFF_SUBDIR;
89
+ try {
90
+ return normalizeHandoffSubdir(stored);
91
+ } catch {
92
+ database.setSetting(IMAGE_SUBDIR_SETTING, DEFAULT_HANDOFF_SUBDIR);
93
+ return DEFAULT_HANDOFF_SUBDIR;
94
+ }
95
+ }
96
+ /** 校验并保存,返回落库后的值。 */
97
+ function saveHandoffSubdir(database, value) {
98
+ const normalized = normalizeHandoffSubdir(value);
99
+ database.setSetting(IMAGE_SUBDIR_SETTING, normalized);
100
+ return normalized;
101
+ }
102
+ /** 设置面板要的那三个值。 */
103
+ function imageHandoffView(database, fallbackDir) {
104
+ return {
105
+ subdir: readHandoffSubdir(database),
106
+ defaultSubdir: DEFAULT_HANDOFF_SUBDIR,
107
+ fallbackDir
108
+ };
109
+ }
110
+ /** 交接文件名的白名单:`<sha256>.<图片扩展名>`,路径穿越在名字这一层就死掉。 */
111
+ const HANDOFF_FILE_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
112
+ /** 扩展名 → 该扩展名必须用内容自证的身份。 */
113
+ const HANDOFF_MEDIA_TYPES = {
114
+ png: "image/png",
115
+ jpg: "image/jpeg",
116
+ jpeg: "image/jpeg",
117
+ webp: "image/webp",
118
+ gif: "image/gif",
119
+ heic: "image/heic",
120
+ heif: "image/heif",
121
+ bmp: "image/bmp",
122
+ avif: "image/avif"
123
+ };
124
+ /** 浏览器只会显示预览,超过这个就别端过去了。 */
125
+ const MAX_BYTES = 33554432;
126
+ /** 病态长的入参在碰文件系统之前就退掉。 */
127
+ const MAX_NAME_CHARS = 4096;
128
+ /** 按扩展名取声明的媒体类型;不支持的扩展名返回 undefined。 */
129
+ function declaredMediaType(name) {
130
+ const cut = name.lastIndexOf(".");
131
+ if (cut === -1) return void 0;
132
+ return HANDOFF_MEDIA_TYPES[name.slice(cut + 1).toLowerCase()];
133
+ }
134
+ /**
135
+ * 「扩展名声明」与「字节自证」是否属于同一图片家族。
136
+ *
137
+ * 为什么需要这一层而不是直接 `declared === sniffed`:**HEIC 与 HEIF 在字节层面无法区分**。
138
+ * 两者都是 ISO-BMFF,靠 `ftyp` 后面的 brand 认家族,而 `heic`/`heif`/`mif1`/`hevc`
139
+ * 都在同一套 `hei*` / `hev*` / `mif*` 前缀里 —— 嗅探器只能诚实地报出「这是 HEI 家族」,
140
+ * 也就是 `image/heic`;它**不该**为了迁就某条扩展名而返回一个自己无法自证的值。
141
+ *
142
+ * 于是把「等价」这件事放在一致性校验这一侧:
143
+ * - 写盘表(`dsh-0.2-bridge.ts`)按内核给的 mediaType 原样产 `<hash>.heif`;
144
+ * - 读取表把 `heif` 声明成 `image/heif`;
145
+ * - 嗅探器对同一份字节返回 `image/heic`;
146
+ * - 两者同族 → 放行。
147
+ *
148
+ * 这不是把闸门拆了:**跨家族的错配仍然全部 415**(png 字节配 `.jpg` 名、
149
+ * `image/png` 配 `.avif` 名、heic 字节配 `.avif` 名)。只放开这一对无法在字节层
150
+ * 区分的「同一家族两个名字」。
151
+ */
152
+ function sameImageFamily(declared, sniffed) {
153
+ if (declared === sniffed) return true;
154
+ const heifFamily = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
155
+ return heifFamily.has(declared) && heifFamily.has(sniffed);
156
+ }
157
+ /**
158
+ * 从文件头认出真实格式。扩展名只是**声明**,这里才是内容自证。
159
+ * @param head - 文件前至少 12 个字节。
160
+ */
161
+ function sniffMediaType(head) {
162
+ const ascii = (offset, text) => {
163
+ if (head.length < offset + text.length) return false;
164
+ for (let index = 0; index < text.length; index += 1) if (head[offset + index] !== text.charCodeAt(index)) return false;
165
+ return true;
166
+ };
167
+ const bytes = (...expected) => {
168
+ if (head.length < expected.length) return false;
169
+ return expected.every((byte, index) => head[index] === byte);
170
+ };
171
+ if (bytes(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
172
+ if (bytes(255, 216, 255)) return "image/jpeg";
173
+ if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
174
+ if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
175
+ if (bytes(66, 77)) return "image/bmp";
176
+ if (ascii(4, "ftyp")) {
177
+ const brand = head.length >= 12 ? String.fromCharCode(...head.subarray(8, 12)) : "";
178
+ if (brand.startsWith("avi")) return "image/avif";
179
+ if (brand.startsWith("hei") || brand.startsWith("hev") || brand.startsWith("mif")) return "image/heic";
180
+ }
181
+ }
182
+ /** 请求的 `Host` 是否指向回环(DNS 重绑定防护)。 */ function loopbackHost(req) {
183
+ const host = req.headers.host;
184
+ if (typeof host !== "string" || host === "") return false;
185
+ if (host.startsWith("[")) return host.startsWith("[::1]");
186
+ const name = host.split(":")[0];
187
+ return name === "127.0.0.1" || name === "localhost";
188
+ }
189
+ /**
190
+ * 请求是否来自某个插件自己服务的页面。
191
+ *
192
+ * 沙箱化的画布跑在不透明 origin 里,它自己的请求不带可用的 origin,但会带来源页 URL
193
+ * —— 这是 HTML 产物也能显示工作区图片的原因。
194
+ */
195
+ function pluginPageReferer(req) {
196
+ const referer = req.headers.referer;
197
+ if (typeof referer !== "string" || referer === "") return false;
198
+ try {
199
+ const url = new URL(referer);
200
+ if (url.host !== req.headers.host) return false;
201
+ return url.pathname.startsWith("/plugin/");
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+ /**
207
+ * 请求是否可以被回答。
208
+ *
209
+ * 浏览器一定发 `Sec-Fetch-Site`,跨站一律拒绝;没有这个头的调用方不是浏览器页面
210
+ * (curl、测试),此时回环可达性本身就是访问模型 —— 与本地 GUI 其余部分假定的信任一致。
211
+ */
212
+ function sameOrigin(req) {
213
+ if (!loopbackHost(req)) return false;
214
+ if (pluginPageReferer(req)) return true;
215
+ const site = req.headers["sec-fetch-site"];
216
+ if (typeof site === "string") return site === "same-origin" || site === "none";
217
+ const origin = req.headers.origin;
218
+ if (typeof origin !== "string" || origin === "") return true;
219
+ try {
220
+ return new URL(origin).host === req.headers.host;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+ /** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
226
+ function refuse(res, status, reason) {
227
+ const body = `${reason}\n`;
228
+ res.writeHead(status, {
229
+ "content-type": "text/plain; charset=utf-8",
230
+ "content-length": String(Buffer.byteLength(body)),
231
+ "cache-control": "no-store",
232
+ "x-content-type-options": "nosniff"
233
+ });
234
+ res.end(body);
235
+ }
236
+ /**
237
+ * 这条路由的处理器工厂。
238
+ * @param options - 取图实现。
239
+ * @returns 一个 `(req, res) => Promise<void>`,可直接交给 `webServer.register`。
240
+ */
241
+ function createImageRouteHandler(options) {
242
+ return async function serveImage(req, res) {
243
+ if (req.method !== "GET" && req.method !== "HEAD") {
244
+ refuse(res, 405, "method not allowed");
245
+ return;
246
+ }
247
+ if (!sameOrigin(req)) {
248
+ refuse(res, 403, "cross-site request refused");
249
+ return;
250
+ }
251
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
252
+ const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
253
+ const rawName = url.searchParams.get("name") ?? "";
254
+ if (sessionId === "" || rawName === "" || rawName.length > MAX_NAME_CHARS) {
255
+ refuse(res, 400, "sessionId and name are required");
256
+ return;
257
+ }
258
+ const name = rawName.split(/[/\\]/).pop() ?? rawName;
259
+ if (HANDOFF_FILE_NAME.exec(name) === null) {
260
+ refuse(res, 404, "no such handoff image");
261
+ return;
262
+ }
263
+ const declared = declaredMediaType(name);
264
+ let image;
265
+ try {
266
+ image = await options.read(sessionId, name);
267
+ } catch {
268
+ refuse(res, 404, "no such handoff image");
269
+ return;
270
+ }
271
+ if (image.bytes.byteLength === 0) {
272
+ refuse(res, 404, "empty file");
273
+ return;
274
+ }
275
+ if (image.bytes.byteLength > MAX_BYTES) {
276
+ refuse(res, 413, "image is too large to display");
277
+ return;
278
+ }
279
+ const sniffed = sniffMediaType(image.bytes.subarray(0, 12));
280
+ if (sniffed === void 0) {
281
+ refuse(res, 415, "content is not a supported image");
282
+ return;
283
+ }
284
+ if (declared !== void 0 && !sameImageFamily(declared, sniffed)) {
285
+ refuse(res, 415, "content does not match the file extension");
286
+ return;
287
+ }
288
+ res.writeHead(200, {
289
+ "content-type": sniffed,
290
+ "content-length": String(image.bytes.byteLength),
291
+ "cache-control": "private, max-age=300",
292
+ "x-content-type-options": "nosniff"
293
+ });
294
+ if (req.method === "HEAD") {
295
+ res.end();
296
+ return;
297
+ }
298
+ res.end(Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength));
299
+ };
300
+ }
301
+ //#endregion
47
302
  //#region src/host/rate-limit.ts
48
303
  var FixedWindowLimiter = class {
49
304
  limit;
@@ -74,18 +329,141 @@ var FixedWindowLimiter = class {
74
329
  };
75
330
  //#endregion
76
331
  //#region src/host/auth.ts
332
+ /**
333
+ * 判定「这是不是一次真正的本机管理请求」。
334
+ *
335
+ * ## 为什么不能只看 `remoteAddress`
336
+ *
337
+ * 修复前 `requireAdmin` 的全部判据是「TCP 来源是回环 + Origin 是回环」。这条判据在
338
+ * **任何把外部请求转到本机回环的拓扑下都恒真**:插件自带的 cloudflared Quick Tunnel
339
+ * 用 `--url http://127.0.0.1:PORT` 转发(`cloudflared.ts`),公网请求进到 bridge 时
340
+ * `req.socket.remoteAddress` 就是 `127.0.0.1`。于是 `https://xxx.trycloudflare.com/manage/roots`
341
+ * 一个凭据都不带也能读到全部授权根的绝对路径(v2.5.0 实测 200/201)。
342
+ * 同一类拓扑还包括 ngrok、frp、Caddy/Nginx `proxy_pass 127.0.0.1`、SSH 端口转发、
343
+ * Tailscale Serve。见 `docs/project/PITFALLS.md` §隧道让回环判据失效。
344
+ *
345
+ * ## 四层判据(层层收紧,`undefined` = 放行)
346
+ *
347
+ * 0. **隧道运行中一律拒绝**(`tunnelActive === true`)—— 100% 可靠的一条:隧道是我们
348
+ * 自己拉的进程,状态握在自己手里,不依赖任何可伪造的请求头。返回
349
+ * `TUNNEL_MODE_ADMIN_DISABLED`。
350
+ * 1. **出现任何转发头即视为经代理** —— `Forwarded` / `X-Forwarded-For` /
351
+ * `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP`。真正的本机浏览器与
352
+ * `curl 127.0.0.1` 都不会发这些头;发了就说明前面有一层我们不认识的代理。
353
+ * 返回 `ADMIN_PROXY_FORWARDED`。
354
+ * 2. **`Host` 必须是回环**(复用 `image-handoff.ts` 的 `loopbackHost`,它接受
355
+ * `127.0.0.1` / `localhost` / `[::1]`)。缺失 `Host` 也拒。这一条挡两类攻击:
356
+ * 隧道/反代转发进来的公网域名(`Host: xxx.trycloudflare.com`),以及 **DNS 重绑定**
357
+ * (攻击者域名解析到 127.0.0.1,浏览器照样发回环 `Origin`,只有 `Host` 露馅)。
358
+ * 返回 `ADMIN_HOST_NOT_LOOPBACK`。
359
+ * 3. **socket 来源必须是回环,`Origin` 若存在必须是回环**。`Origin` 缺失**仍然放行** ——
360
+ * 这是刻意的取舍,理由见下。
361
+ *
362
+ * ## 为什么 `Origin` 缺失还放行(刻意的取舍,别「顺手修掉」)
363
+ *
364
+ * 翻成「必须带回环 Origin」能多挡一点,但会**同时打死 `curl` 与脚本调试**:它们在
365
+ * 本机跑、天然不带 `Origin`,而本机进程本来就有权读这些文件(它们能直接 `cat`)。
366
+ * 真正的防线不是「请求必须像浏览器」,而是**拓扑与头**:判据 0 管住了我们自己拉的隧道,
367
+ * 判据 1 管住了任何代理,判据 2 管住了公网域名与 DNS 重绑定。隧道场景里公网请求即使
368
+ * 不带 `Origin` 也会先撞上判据 0/1/2 —— 这正是 §3.1 那条攻击链的断点。
369
+ *
370
+ * 换句话说:**允许缺 `Origin` 不再是「可信」,而是「不再有决定权」**。修复前它是有
371
+ * 决定权的(`isLoopbackOrigin(undefined) === true` 直接就给了管理员身份)。
372
+ *
373
+ * @param req - 待判定的请求。
374
+ * @param options.tunnelActive - 是否有我们自管的隧道在运行;缺省视为没有。
375
+ * @param options.allowTunnelMode - 仅用于「隧道状态」这类**只读、无副作用**的端点:
376
+ * 让它们豁免判据 0(否则隧道一开,面板连「隧道开着」这件事都读不到),
377
+ * 判据 1/2/3 仍然全部生效。**其他任何 `/manage` 端点都不许传这个选项。**
378
+ */
379
+ function adminRefusal(req, options = {}) {
380
+ if (options.tunnelActive === true && options.allowTunnelMode !== true) return {
381
+ code: "TUNNEL_MODE_ADMIN_DISABLED",
382
+ message: "Management endpoints are disabled while the Cloudflare tunnel is running. Use the local WebUI, or stop the tunnel first."
383
+ };
384
+ for (const name of FORWARDING_HEADERS) if (headerPresent(req, name)) return {
385
+ code: "ADMIN_PROXY_FORWARDED",
386
+ message: "Management endpoints are available only from the local machine, not through a proxy or tunnel."
387
+ };
388
+ if (!loopbackHost(req)) return {
389
+ code: "ADMIN_HOST_NOT_LOOPBACK",
390
+ message: "Management endpoints are available only under a loopback host name (127.0.0.1, localhost or [::1])."
391
+ };
392
+ if (!isLoopbackAddress(requestAddress(req)) || !isLoopbackOrigin(req.headers.origin)) return {
393
+ code: "ADMIN_LOOPBACK_REQUIRED",
394
+ message: "This operation is available only from the local machine."
395
+ };
396
+ }
397
+ /**
398
+ * 「这个请求不是本机直连」的**全部**证据头。
399
+ *
400
+ * 分两组,理由不同:
401
+ *
402
+ * 1. **转发头**(`Forwarded` / `X-Forwarded-*` / `X-Real-IP`):本机浏览器与
403
+ * `curl 127.0.0.1` 都不会发。**存在即拒**,不看值 —— `X-Forwarded-For: 127.0.0.1`
404
+ * 正是最典型的洗白写法(攻击者从隧道进来,塞一个声称自己是本机的头)。
405
+ * 2. **Cloudflare 边缘头**(`Cf-Connecting-IP` / `Cf-Ray` / `Cf-IpCountry` / `Cf-Visitor`):
406
+ * 这一组是**真实隧道场景的兜底**。cloudflared 的 `httpHostHeader` 默认为空,会把
407
+ * `Host` 改写成源站地址(`127.0.0.1:PORT`),于是判据 2 在真实隧道下挡不住;
408
+ * 而 Cloudflare 边缘一定会注入这组头(且请求方无法伪造成「我没有经过边缘」)。
409
+ * 实测(`.local-backup/security-audit/verify-fix-tunnel-admin.mjs`):
410
+ * 只把 Host 改成回环地址就能拿到 200,加上这组头才被挡住。
411
+ *
412
+ * 这两组都不参与「判据 0 的豁免」—— 隧道状态端点仍要求它们为空。
413
+ */
414
+ const FORWARDING_HEADERS = [
415
+ "forwarded",
416
+ "x-forwarded-for",
417
+ "x-forwarded-host",
418
+ "x-forwarded-proto",
419
+ "x-real-ip",
420
+ "cf-connecting-ip",
421
+ "cf-ray",
422
+ "cf-ipcountry",
423
+ "cf-visitor"
424
+ ];
425
+ /** 头存在且非空才算「出现」:空串是某些代理库的产物,不构成声明。 */
426
+ function headerPresent(req, name) {
427
+ const raw = req.headers[name];
428
+ if (raw === void 0) return false;
429
+ if (Array.isArray(raw)) return raw.some((value) => value.trim() !== "");
430
+ return raw.trim() !== "";
431
+ }
77
432
  var AuthService = class {
78
433
  database;
434
+ options;
79
435
  authFailures = new FixedWindowLimiter(20, 3e5);
80
436
  pairingAttempts = new FixedWindowLimiter(10, 6e5);
81
437
  requests = new FixedWindowLimiter(120, 6e4);
82
- constructor(database) {
438
+ constructor(database, options = {}) {
83
439
  this.database = database;
440
+ this.options = options;
441
+ }
442
+ /**
443
+ * 隧道是否在跑。探针抛错时**按「在跑」处理**(fail closed):探针读的是我们自己
444
+ * 对象上的 getter,它抛错意味着状态不可知;状态不可知时放行管理面正是这次修复要
445
+ * 消灭的那类决定。
446
+ */
447
+ tunnelRunning() {
448
+ const probe = this.options.tunnelActive;
449
+ if (probe === void 0) return false;
450
+ try {
451
+ return probe() === true;
452
+ } catch {
453
+ return true;
454
+ }
84
455
  }
85
456
  principal(req, options = {}) {
86
457
  const remote = requestAddress(req);
87
458
  this.requests.consume(remote);
88
- if (options.allowAdmin === true && req.headers["x-dsh-workspace-admin"] === "1" && isLoopbackAddress(remote) && isLoopbackOrigin(req.headers.origin)) return ADMIN_PRINCIPAL;
459
+ if (options.allowAdmin === true && req.headers["x-dsh-workspace-admin"] === "1") {
460
+ const refusal = adminRefusal(req, { tunnelActive: this.tunnelRunning() });
461
+ if (refusal !== void 0) {
462
+ this.authFailures.consume(remote);
463
+ throw new ApiError(403, refusal.code, refusal.message);
464
+ }
465
+ return ADMIN_PRINCIPAL;
466
+ }
89
467
  const authorization = req.headers.authorization;
90
468
  if (authorization === void 0 || !authorization.startsWith("Bearer ")) {
91
469
  this.authFailures.consume(remote);
@@ -105,8 +483,18 @@ var AuthService = class {
105
483
  rootIds: new Set(device.rootIds)
106
484
  };
107
485
  }
108
- requireAdmin(req) {
109
- if (!isLoopbackAddress(requestAddress(req)) || !isLoopbackOrigin(req.headers.origin)) throw new ApiError(403, "ADMIN_LOOPBACK_REQUIRED", "This operation is available only from the local machine.");
486
+ /**
487
+ * `/manage/**` 的守门人。
488
+ *
489
+ * @param options.allowTunnelMode - 只有「隧道状态下读/停隧道」这几个端点会传 true。
490
+ * 见 `adminRefusal` 的同名说明与 `router.ts` 的 `TUNNEL_MODE_EXEMPT_PATHS`。
491
+ */
492
+ requireAdmin(req, options = {}) {
493
+ const refusal = adminRefusal(req, {
494
+ tunnelActive: this.tunnelRunning(),
495
+ ...options.allowTunnelMode === true ? { allowTunnelMode: true } : {}
496
+ });
497
+ if (refusal !== void 0) throw new ApiError(403, refusal.code, refusal.message);
110
498
  return ADMIN_PRINCIPAL;
111
499
  }
112
500
  requireScope(principal, scope) {
@@ -123,6 +511,13 @@ function isLoopbackAddress(address) {
123
511
  const normalized = address.toLowerCase().split("%")[0];
124
512
  return normalized === "127.0.0.1" || normalized === "::1" || normalized === "::ffff:127.0.0.1";
125
513
  }
514
+ /**
515
+ * `Origin` 是否是回环。
516
+ *
517
+ * `undefined` 返回 `true` 是**刻意保留**的(理由见 `adminRefusal` 的注释):它不再
518
+ * 单独构成「可信」,判据 0/1/2 已经承担了决定性判断。改这一行会让 `curl` 调试
519
+ * 无处可去,而本机进程本来就能直接读这些文件。
520
+ */
126
521
  function isLoopbackOrigin(origin) {
127
522
  if (origin === void 0) return true;
128
523
  try {
@@ -381,11 +776,24 @@ var CloudflaredTunnelManager = class {
381
776
  };
382
777
  this.emit();
383
778
  }
779
+ /**
780
+ * 是否有隧道在运行 —— 管理面鉴权的**判据 0**。
781
+ *
782
+ * 判据取自**进程持有状态**,不是请求头,所以它对「隧道把外部流量转进 127.0.0.1」
783
+ * 这条绕过路径 100% 可靠:不管转发进来的请求长什么样,只要隧道开着,管理面就关门。
784
+ * 见 `auth.ts` 的 `adminRefusal` 与 `PITFALLS.md` §隧道让回环判据失效。
785
+ *
786
+ * `running` 与 `publicState.running` 是同一个表达式(同一份真相),这里单独暴露是为了
787
+ * 让鉴权层不必构造一个含 url 的视图对象 —— 少一处把地址带进意外地方的机会。
788
+ */
789
+ get tunnelActive() {
790
+ return this.process !== null && !this.process.killed;
791
+ }
384
792
  get publicState() {
385
793
  return {
386
794
  channel: this.channel,
387
795
  url: this.url,
388
- running: this.process !== null && !this.process.killed,
796
+ running: this.tunnelActive,
389
797
  ...this.errorText === void 0 ? {} : { error: this.errorText }
390
798
  };
391
799
  }
@@ -471,239 +879,6 @@ var CloudflaredTunnelManager = class {
471
879
  }
472
880
  };
473
881
  //#endregion
474
- //#region src/host/image-handoff.ts
475
- /**
476
- * 交接目录的**出厂默认**。
477
- *
478
- * 设置面板里「当前默认」那一栏展示的就是这个字面量,且是只读的 —— 它是这份代码的
479
- * 契约(历史交接消息、识图工具、手机端都按它写过),不是一个可以随风改的偏好项。
480
- * 用户可以改的是设置的**当前值**(存在数据库里),默认值本身不跟着走。
481
- */
482
- const DEFAULT_HANDOFF_SUBDIR = ".dsh-images";
483
- /** settings 表里的键名。 */
484
- const IMAGE_SUBDIR_SETTING = "image_handoff_subdir";
485
- /**
486
- * 只读图片路由。刻意放在 `/plugin/` 下而不是版本化 REST 前缀里:
487
- * 它不参与 REST 契约、不需要设备 token,将来 REST 升版时这条也不该跟着动。
488
- */
489
- const IMAGE_ROUTE = "/plugin/dsh-remote-bridge/raw";
490
- /** 目录名上限:它要拼进路径,太长的名字只是拿来坑自己的。 */
491
- const MAX_SUBDIR_CHARS = 64;
492
- /**
493
- * 把用户填的目录名收成一个安全值。
494
- *
495
- * 只收**单段**目录名:`/`、`\`、NUL 一律拒绝(否则它就不是「目录名」而是「路径」,
496
- * 会把落盘位置带出工作目录);`.` 与 `..` 拒绝(前者是当前目录、后者是往上爬);
497
- * 允许点开头 —— 默认值 `.dsh-images` 自己就是点目录。
498
- */
499
- function normalizeHandoffSubdir(value) {
500
- if (typeof value !== "string") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name must be a string.");
501
- const trimmed = value.trim();
502
- if (trimmed === "" || trimmed.length > MAX_SUBDIR_CHARS) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", `The image directory name must be 1-${MAX_SUBDIR_CHARS} characters.`);
503
- if (trimmed === "." || trimmed === "..") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name cannot be \".\" or \"..\".");
504
- if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name may only contain letters, digits, dots, underscores, and hyphens.");
505
- return trimmed;
506
- }
507
- /**
508
- * 读出当前生效的目录名。
509
- *
510
- * 数据库里那条坏掉(手改过 / 旧版本写过)就退回默认并**顺手修掉**:一个坏值不该让
511
- * 「发图」这个动作从此失败,而每次读都只回退不修正会把坏值永远留着。
512
- */
513
- function readHandoffSubdir(database) {
514
- const stored = database.getSetting(IMAGE_SUBDIR_SETTING);
515
- if (stored === void 0) return DEFAULT_HANDOFF_SUBDIR;
516
- try {
517
- return normalizeHandoffSubdir(stored);
518
- } catch {
519
- database.setSetting(IMAGE_SUBDIR_SETTING, DEFAULT_HANDOFF_SUBDIR);
520
- return DEFAULT_HANDOFF_SUBDIR;
521
- }
522
- }
523
- /** 校验并保存,返回落库后的值。 */
524
- function saveHandoffSubdir(database, value) {
525
- const normalized = normalizeHandoffSubdir(value);
526
- database.setSetting(IMAGE_SUBDIR_SETTING, normalized);
527
- return normalized;
528
- }
529
- /** 设置面板要的那三个值。 */
530
- function imageHandoffView(database, fallbackDir) {
531
- return {
532
- subdir: readHandoffSubdir(database),
533
- defaultSubdir: DEFAULT_HANDOFF_SUBDIR,
534
- fallbackDir
535
- };
536
- }
537
- /** 交接文件名的白名单:`<sha256>.<图片扩展名>`,路径穿越在名字这一层就死掉。 */
538
- const HANDOFF_FILE_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
539
- /** 扩展名 → 该扩展名必须用内容自证的身份。 */
540
- const HANDOFF_MEDIA_TYPES = {
541
- png: "image/png",
542
- jpg: "image/jpeg",
543
- jpeg: "image/jpeg",
544
- webp: "image/webp",
545
- gif: "image/gif",
546
- heic: "image/heic",
547
- heif: "image/heif",
548
- bmp: "image/bmp",
549
- avif: "image/avif"
550
- };
551
- /** 浏览器只会显示预览,超过这个就别端过去了。 */
552
- const MAX_BYTES = 33554432;
553
- /** 病态长的入参在碰文件系统之前就退掉。 */
554
- const MAX_NAME_CHARS = 4096;
555
- /** 按扩展名取声明的媒体类型;不支持的扩展名返回 undefined。 */
556
- function declaredMediaType(name) {
557
- const cut = name.lastIndexOf(".");
558
- if (cut === -1) return void 0;
559
- return HANDOFF_MEDIA_TYPES[name.slice(cut + 1).toLowerCase()];
560
- }
561
- /**
562
- * 从文件头认出真实格式。扩展名只是**声明**,这里才是内容自证。
563
- * @param head - 文件前至少 12 个字节。
564
- */
565
- function sniffMediaType(head) {
566
- const ascii = (offset, text) => {
567
- if (head.length < offset + text.length) return false;
568
- for (let index = 0; index < text.length; index += 1) if (head[offset + index] !== text.charCodeAt(index)) return false;
569
- return true;
570
- };
571
- const bytes = (...expected) => {
572
- if (head.length < expected.length) return false;
573
- return expected.every((byte, index) => head[index] === byte);
574
- };
575
- if (bytes(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
576
- if (bytes(255, 216, 255)) return "image/jpeg";
577
- if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
578
- if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
579
- if (bytes(66, 77)) return "image/bmp";
580
- if (ascii(4, "ftyp")) {
581
- const brand = head.length >= 12 ? String.fromCharCode(...head.subarray(8, 12)) : "";
582
- if (brand.startsWith("avi")) return "image/avif";
583
- if (brand.startsWith("hei") || brand.startsWith("hev") || brand.startsWith("mif")) return "image/heic";
584
- }
585
- }
586
- /** 请求的 `Host` 是否指向回环(DNS 重绑定防护)。 */
587
- function loopbackHost(req) {
588
- const host = req.headers.host;
589
- if (typeof host !== "string" || host === "") return false;
590
- if (host.startsWith("[")) return host.startsWith("[::1]");
591
- const name = host.split(":")[0];
592
- return name === "127.0.0.1" || name === "localhost";
593
- }
594
- /**
595
- * 请求是否来自某个插件自己服务的页面。
596
- *
597
- * 沙箱化的画布跑在不透明 origin 里,它自己的请求不带可用的 origin,但会带来源页 URL
598
- * —— 这是 HTML 产物也能显示工作区图片的原因。
599
- */
600
- function pluginPageReferer(req) {
601
- const referer = req.headers.referer;
602
- if (typeof referer !== "string" || referer === "") return false;
603
- try {
604
- const url = new URL(referer);
605
- if (url.host !== req.headers.host) return false;
606
- return url.pathname.startsWith("/plugin/");
607
- } catch {
608
- return false;
609
- }
610
- }
611
- /**
612
- * 请求是否可以被回答。
613
- *
614
- * 浏览器一定发 `Sec-Fetch-Site`,跨站一律拒绝;没有这个头的调用方不是浏览器页面
615
- * (curl、测试),此时回环可达性本身就是访问模型 —— 与本地 GUI 其余部分假定的信任一致。
616
- */
617
- function sameOrigin(req) {
618
- if (!loopbackHost(req)) return false;
619
- if (pluginPageReferer(req)) return true;
620
- const site = req.headers["sec-fetch-site"];
621
- if (typeof site === "string") return site === "same-origin" || site === "none";
622
- const origin = req.headers.origin;
623
- if (typeof origin !== "string" || origin === "") return true;
624
- try {
625
- return new URL(origin).host === req.headers.host;
626
- } catch {
627
- return false;
628
- }
629
- }
630
- /** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
631
- function refuse(res, status, reason) {
632
- const body = `${reason}\n`;
633
- res.writeHead(status, {
634
- "content-type": "text/plain; charset=utf-8",
635
- "content-length": String(Buffer.byteLength(body)),
636
- "cache-control": "no-store",
637
- "x-content-type-options": "nosniff"
638
- });
639
- res.end(body);
640
- }
641
- /**
642
- * 这条路由的处理器工厂。
643
- * @param options - 取图实现。
644
- * @returns 一个 `(req, res) => Promise<void>`,可直接交给 `webServer.register`。
645
- */
646
- function createImageRouteHandler(options) {
647
- return async function serveImage(req, res) {
648
- if (req.method !== "GET" && req.method !== "HEAD") {
649
- refuse(res, 405, "method not allowed");
650
- return;
651
- }
652
- if (!sameOrigin(req)) {
653
- refuse(res, 403, "cross-site request refused");
654
- return;
655
- }
656
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
657
- const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
658
- const rawName = url.searchParams.get("name") ?? "";
659
- if (sessionId === "" || rawName === "" || rawName.length > MAX_NAME_CHARS) {
660
- refuse(res, 400, "sessionId and name are required");
661
- return;
662
- }
663
- const name = rawName.split(/[/\\]/).pop() ?? rawName;
664
- if (HANDOFF_FILE_NAME.exec(name) === null) {
665
- refuse(res, 404, "no such handoff image");
666
- return;
667
- }
668
- const declared = declaredMediaType(name);
669
- let image;
670
- try {
671
- image = await options.read(sessionId, name);
672
- } catch {
673
- refuse(res, 404, "no such handoff image");
674
- return;
675
- }
676
- if (image.bytes.byteLength === 0) {
677
- refuse(res, 404, "empty file");
678
- return;
679
- }
680
- if (image.bytes.byteLength > MAX_BYTES) {
681
- refuse(res, 413, "image is too large to display");
682
- return;
683
- }
684
- const sniffed = sniffMediaType(image.bytes.subarray(0, 12));
685
- if (sniffed === void 0) {
686
- refuse(res, 415, "content is not a supported image");
687
- return;
688
- }
689
- if (declared !== void 0 && sniffed !== declared) {
690
- refuse(res, 415, "content does not match the file extension");
691
- return;
692
- }
693
- res.writeHead(200, {
694
- "content-type": sniffed,
695
- "content-length": String(image.bytes.byteLength),
696
- "cache-control": "private, max-age=300",
697
- "x-content-type-options": "nosniff"
698
- });
699
- if (req.method === "HEAD") {
700
- res.end();
701
- return;
702
- }
703
- res.end(Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength));
704
- };
705
- }
706
- //#endregion
707
882
  //#region src/host/path-policy.ts
708
883
  const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
709
884
  function validateRelativePath(value) {
@@ -2092,7 +2267,7 @@ function createDsh02ApiProxy(host, options = {}) {
2092
2267
  }
2093
2268
  //#endregion
2094
2269
  //#region src/shared/version.ts
2095
- const PLUGIN_VERSION = "2.5.0";
2270
+ const PLUGIN_VERSION = "2.5.1";
2096
2271
  //#endregion
2097
2272
  //#region src/host/capabilities.ts
2098
2273
  /**
@@ -5147,6 +5322,23 @@ function matchJobRoute(pathname) {
5147
5322
  };
5148
5323
  return null;
5149
5324
  }
5325
+ /**
5326
+ * 隧道运行期间**仍然可达**的管理端点(豁免判据 0)。
5327
+ *
5328
+ * 为什么存在这个集合:判据 0(隧道开着 → 管理面整体关门)会让「隧道状态」这个信号
5329
+ * 本体也 403,于是面板没法告诉用户「隧道开着所以管理面停用」—— 只剩一个没法解读的
5330
+ * 失败。这两个端点进来的目的就是解掉这个矛盾。
5331
+ *
5332
+ * 为什么是安全的:它们**只读、无副作用**(不写库、不发令牌、不改监听),返回的
5333
+ * `running`/`channel`/`error`/阶段进度都不构成攻击面。**唯一敏感的是 `url`** ——
5334
+ * Quick Tunnel 地址本身是一次对外入口。
5335
+ *
5336
+ * 豁免**只针对判据 0**。判据 1/2/3(转发头+Cloudflare 边缘头 / Host / Origin)对它们
5337
+ * 照旧全部生效,所以真实隧道来源的请求连这两个端点也是 403、拿不到隧道地址。
5338
+ * 「远程能读到 running 但读不到 url」这个方案被**否决**了:那需要在判据 1 上再开一个口子,
5339
+ * 而那正是本次修复要消灭的东西。`tunnelStateFor` 里还留了一层不变量检查作双保险。
5340
+ */
5341
+ const TUNNEL_MODE_EXEMPT_PATHS = /* @__PURE__ */ new Set(["/manage/cloudflare/status", "/manage/cloudflare/activity"]);
5150
5342
  var ApiRouter = class {
5151
5343
  options;
5152
5344
  constructor(options) {
@@ -5205,7 +5397,7 @@ var ApiRouter = class {
5205
5397
  return;
5206
5398
  }
5207
5399
  if (pathname.startsWith("/manage")) {
5208
- this.options.auth.requireAdmin(req);
5400
+ this.options.auth.requireAdmin(req, { ...TUNNEL_MODE_EXEMPT_PATHS.has(pathname) ? { allowTunnelMode: true } : {} });
5209
5401
  await this.dispatchManagement(req, res, url, requestId);
5210
5402
  return;
5211
5403
  }
@@ -5824,7 +6016,7 @@ var ApiRouter = class {
5824
6016
  }
5825
6017
  if (this.options.tunnels !== void 0 && pathname === "/manage/cloudflare/status") {
5826
6018
  if (method === "GET" || method === "HEAD") {
5827
- sendJson(res, 200, this.options.tunnels.publicState);
6019
+ sendJson(res, 200, this.tunnelStateFor(req));
5828
6020
  return;
5829
6021
  }
5830
6022
  }
@@ -5860,6 +6052,30 @@ var ApiRouter = class {
5860
6052
  }
5861
6053
  throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
5862
6054
  }
6055
+ /**
6056
+ * 隧道状态视图。
6057
+ *
6058
+ * **这里不做脱敏,因为用不上**:判据 1 已经把 Cloudflare 边缘头(`Cf-Connecting-IP` /
6059
+ * `Cf-Ray` / …)算作「前面有代理」的证据,于是任何真实隧道来源的请求在
6060
+ * `requireAdmin` 就被 403 挡住了,根本到不了这个方法。能走到这里的只有真·本机请求
6061
+ * (判据 1/2/3 全过),所以 `url` 可以照常给它 —— 那是桌面端「用隧道地址生成配置文本」
6062
+ * 的依据。
6063
+ *
6064
+ * 为什么仍然保留这个方法而不是直接 `publicState`:脱敏的位置要留在一个**语义明确**的
6065
+ * 出口上。将来若有人为了「让远程客户端也能读到隧道是否开着」而放宽判据 1,
6066
+ * 必须在这里重新定义脱敏,而不是顺手把 `url` 一起放出去。
6067
+ * 评估结论见报告:本轮选择「远程一律 403」而不是「远程读得到但 `url` 为 null」,
6068
+ * 因为后者需要为状态端点在判据 1 上再开一个口子,而那正是这次修复要消灭的东西。
6069
+ */
6070
+ tunnelStateFor(req) {
6071
+ const tunnels = this.options.tunnels;
6072
+ if (tunnels === void 0) throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
6073
+ const state = tunnels.publicState;
6074
+ return adminRefusal(req, { tunnelActive: false }) === void 0 ? state : {
6075
+ ...state,
6076
+ url: null
6077
+ };
6078
+ }
5863
6079
  async dispatchEntries(req, res, url, principal, root, requestId) {
5864
6080
  const method = req.method ?? "GET";
5865
6081
  if (method === "GET") {
@@ -6916,7 +7132,8 @@ async function apply(ctx, config = {}) {
6916
7132
  }
6917
7133
  await registerBuiltinWorkspaceRoot(ctx, database);
6918
7134
  const events = new WorkspaceEventBus();
6919
- const auth = new AuthService(database);
7135
+ const tunnels = new CloudflaredTunnelManager();
7136
+ const auth = new AuthService(database, { tunnelActive: () => tunnels.tunnelActive });
6920
7137
  const files = new FileService(database, (event) => events.emit(event));
6921
7138
  const imageHandoffDir = path.join(dataDir, "image-handoff");
6922
7139
  const handoffRootsOf = () => database.listRoots().map((root) => ({
@@ -6963,7 +7180,7 @@ async function apply(ctx, config = {}) {
6963
7180
  chat,
6964
7181
  settings,
6965
7182
  remote,
6966
- tunnels: new CloudflaredTunnelManager(),
7183
+ tunnels,
6967
7184
  cloudflaredBinDir: path.join(dataDir, "cloudflared-bin"),
6968
7185
  maxUploadBytes,
6969
7186
  imageFallbackDir: imageHandoffDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-bridge",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "Versioned REST bridge for DeepSeek Harness: remote access for trusted clients (chat, files, devices) plus a Phone-settings panel inside the DSH WebUI.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.19.0",