dsh-crossterm 0.7.5 → 0.8.0

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
@@ -1,5 +1,53 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.8.0 — 2026-10-19
4
+
5
+ ### 安全加固:堵住路径穿越、接口裸奔、伪造发件人(来源:YOCE_C 复核报告)
6
+
7
+ 辅助机 YOCE_C 对 0.7.4 做了一次跨设备模块复核(13 项:3 高危 / 4 中危,附可复现 PoC)。
8
+ 本版按 P0 + P1 先修六条,其余(备份加密、nodeId 碰撞、首台设备自动 admin 等)留在路线图上。
9
+
10
+ - **F1(高危)附件名路径穿越 → 任意路径写文件**:`lib/cloud/relay.js` 原来直接 `path.join(destDir, file.name)`,
11
+ 信封里写 `../../..\evil.txt` 就能落到收件箱外面。现在附件名一律过 `safeFileName()`(只留纯文件名、去控制字符、
12
+ 挡 Windows 非法字符、限长 180),再用 `safeJoin()` 校验解析后的路径仍在目标目录内,越界直接抛错。
13
+ - **F2(高危)本地 HTTP 接口没有鉴权**:10 条 `/crossterm/api/*` 谁都能 POST(含会跑 pnpm 的 `/update`、
14
+ 会弹记事本和浏览器的 `/cloud/setup`)。现在:
15
+ - 令牌存在 `<busDir>\http-token`(首次自动生成,权限 0600),请求要带 `x-crossterm-token`;
16
+ - 6 条写操作路由(backup / check / update / cloud/start / cloud/stop / cloud/setup)第一个判断就是令牌,缺了回 401;
17
+ - 带 `Origin` 且不是 `127.0.0.1`/`localhost`/`[::1]` 的一律 403(挡跨站 CSRF);
18
+ - 卡片通过同源 `GET /crossterm/api/token` 取令牌后带上(跨站页面读不到响应,因为没有 CORS 头)。
19
+ - **F3(高危)信封没有签名、可伪造任意发件人**:现在发信侧用账号级密钥做 HMAC-SHA256 签名
20
+ (`sig` 字段,规范串固定为 `[id, from, to, subject, text, files, createdAt]`),收信侧逐封验签,
21
+ 签名缺失或不符**留件拒收**(记 `state.failed`,不投递)。
22
+ 密钥 `relaySecret` 存在账号目录 `_account.json` 里,首个建出账号目录的设备生成,其他设备读用。
23
+ - **F4(中危)入站没有大小/数量上限**:信封超过 512 KB 直接跳过、附件最多取 20 个、
24
+ 单个附件超过 64 MB 拒收(先看云端返回的 size,再决定是否下载)。
25
+ - **F5(中危)信封可用 `cloudPath` 读云端任意路径**:候选路径只剩「`files/<邮件id>/` 下」和「收件箱同级」两种,
26
+ 信封自带的路径字段一律忽略。
27
+ - **F7/F8(中危)去重与归档顺序**:已处理记录改按**信封 id** 记,并把「先归档 `done/` 成功、再记 state」的顺序改正 ——
28
+ 归档失败时不记已处理,下一轮重试,不会出现「投过但没归档」被重复投递、或归档失败后信件永久滞留。
29
+ - 附带的显示修复:附件行原来渲染 `undefined(17.1 KB)`(渲染读 `file.dst`,中转站给的是 `file.local`),
30
+ 现在 `file.dst || file.local || file.name`,并让中转站同时给出 `dst`。
31
+ - 探针 `probe-security-080.mjs`:**21 项全 PASS**(文件名净化 8 项、`safeJoin` 2 项、签名 4 项、
32
+ 上限常量、以及中转站整链路:未签名拒收 / 签名正确投递并归档 / 篡改拒收 / 穿越附件只落本目录 / 超大信封跳过 / 附件数截断)。
33
+ - 要不要跟着动手:**要** —— 账号里一旦生成了 `relaySecret`,**<0.8.0 的设备发出的未签名信封会被拒收**。
34
+ 请把每台设备都升到 0.8.0;升级后新密钥自动生成,老设备升上来即恢复正常。
35
+
36
+
37
+ ## 0.7.6 — 2026-10-19
38
+
39
+ ### 收信尾行不再重复:跨设备邮件只给一条回信提示
40
+
41
+ - 症状(自测发现):0.7.5 的跨设备邮件末尾出现两条回信提示 ——
42
+ `(回信:调用 term_send,参数 to="yoce-c-4fb38e";…)`(`renderMailList()` 给的)
43
+ 和 `(这封信由云端中转站投递到本机,收件人:…。)(回复用 term_send。)`(`relayDeliver()` 又补了一条)。
44
+ - 根因:0.7.5 把中转站投递改成走 `renderMailList()` 后,`relayDeliver()` 里 0.7.4 留下的那句手写尾行没删。
45
+ - 改法(`lib/index.js` 的 `relayDeliver()`):删掉那句固定尾行,只保留条件提示(附件没下载下来、信封没写收件人的回落说明)。
46
+ - 文档:`README.md` 3.3 的跨设备例子去掉重复尾行;3.4 由「三条硬规则」改为「四条硬规则」,新增
47
+ 「**尾行只给一次**:回信提示和 `经由` 由 `renderMailList()` 统一给,中转站不再另加尾行」。
48
+ - 探针 `probe-mail-label.mjs` 增断言:`lib/index.js` 里不再出现「中转站投递到本机」这句尾行、README 例子不再有重复尾行。
49
+ - 要不要跟着动手:**不用** —— 只删一句重复文案,协议与数据格式没变。
50
+
3
51
  ## 0.7.5 — 2026-10-19
4
52
 
5
53
  ### 邮件来源四档标签:「跨会话 / 跨工作区 / 跨终端 / 跨设备」,标题行改「发件人」
package/README.md CHANGED
@@ -28,7 +28,15 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
28
28
  > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
29
29
  > 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
30
30
 
31
- **本版更新(0.7.5)**
31
+ **本版更新(0.7.6)**
32
+
33
+ - **跨设备邮件的尾巴不再重复**:0.7.5 里同一封信末尾会出现两条回复提示(一条 `renderMailList()` 给的、
34
+ 一条中转站又补的)。现在只留一条,`经由:云端中转站` 照旧在抬头。
35
+ - 条件提示照旧追加在最后(附件没下载下来、信封没写收件人时的回落说明)。
36
+ - 邮件格式契约补第 4 条硬规则:**尾行只给一次**。
37
+ - 要不要跟着动手:**不用** —— 只删一句重复文案,协议与数据格式没变。
38
+
39
+ **上一版(0.7.5)**
32
40
 
33
41
  - **邮件来源分四档**:同终端同工作区 → 「跨会话邮件」;同终端不同工作区 → **「跨工作区邮件」**;
34
42
  同一台机器别的 DSH 进程 → 「跨终端邮件」;云端中转站投进来的(别的设备)→ **「跨设备邮件」**。
@@ -172,15 +180,15 @@ dsh plugin --profile desktop remove dsh-crossterm
172
180
  主题:旧云端目录已整删(/dsh云盘/crossterm/bus)
173
181
  正文:…\mail\….md(用 read 工具打开,1.1 KB)
174
182
 
175
- (这封信由云端中转站投递到本机,收件人:验证跨终端插件。)(回复用 term_send。)
176
183
  (回信:调用 term_send,参数 to="yoce-c-4fb38e";…)
177
184
  ```
178
185
 
179
- ### 3.4 三条硬规则
186
+ ### 3.4 四条硬规则
180
187
 
181
188
  1. **格式不许各写各的**:本机总线投递、云端中转投递都调 `renderMailList()`;新增字段只加在固定位置,别再另起抬头。
182
189
  2. **正文只落盘**:`mail\` 下的 `.md` 是唯一正文载体(`term_inbox` 里显示同一个路径),删文件即删正文。
183
190
  3. **收件人为空不丢信**:云端信封没写收件会话时,投给「最近活跃会话」并在正文注明;连会话都没有才留件等下次。
191
+ 4. **尾行只给一次**:回信提示和「经由」由 `renderMailList()` 统一给,中转站不再另加尾行(0.7.6 起);条件提示(附件没下下来、回落投递)才追加在最后。
184
192
  ## 四、九个工具
185
193
 
186
194
  | 工具 | 作用 | 主要参数 |
package/client/client.js CHANGED
@@ -126,8 +126,21 @@ window.__ModuleLoader__.load({
126
126
  },
127
127
  };
128
128
 
129
+ let apiToken = "";
130
+ async function ensureToken() {
131
+ if (apiToken) return apiToken;
132
+ try {
133
+ const res = await fetch("/crossterm/api/token");
134
+ const data = await res.json();
135
+ if (data && data.token) apiToken = data.token;
136
+ } catch {}
137
+ return apiToken;
138
+ }
139
+
129
140
  async function call(pathname, method) {
130
- const res = await fetch(pathname, { method: method || "GET" });
141
+ const token = await ensureToken();
142
+ const headers = token ? { "x-crossterm-token": token } : {};
143
+ const res = await fetch(pathname, { method: method || "GET", headers });
131
144
  const text = await res.text();
132
145
  let data;
133
146
  try {
package/lib/bus.js CHANGED
@@ -22,7 +22,7 @@ import os from 'node:os'
22
22
  import path from 'node:path'
23
23
  import crypto from 'node:crypto'
24
24
 
25
- export const VERSION = '0.7.5'
25
+ export const VERSION = '0.8.0'
26
26
 
27
27
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
28
  export function dshHome() {
package/lib/cloud/home.js CHANGED
@@ -42,6 +42,10 @@ export function createCloudHome({ cfg, device, deviceInfo, pan, busDir, deliver,
42
42
  const relay = createRelay({
43
43
  pan,
44
44
  getLayout: () => getLayout(false),
45
+ getSecret: async () => {
46
+ const l = await getLayout(false)
47
+ return (l && l.secret) || ''
48
+ },
45
49
  localDir: relayDir,
46
50
  stateFile: relayState,
47
51
  deliver,
@@ -6,6 +6,7 @@
6
6
  // <大文件夹>/<账号>/shared/<设备>/done/ 已投递归档
7
7
  import fs from 'node:fs'
8
8
  import path from 'node:path'
9
+ import crypto from 'node:crypto'
9
10
 
10
11
  export const LAYOUT_VERSION = 1
11
12
  export const DEFAULT_HOME = 'dsh'
@@ -334,6 +335,14 @@ export async function ensureLayout({ pan, cfg = {}, device = {}, cacheFile, forc
334
335
  role = accountInfo.ownerDevice === nodeId ? 'admin' : 'member'
335
336
  }
336
337
 
338
+ // 0.8.0:中转站签名密钥(账号级共享)。首个建出 _account.json 的设备生成,成员读用;缺了补写。
339
+ if (!accountInfo.relaySecret) {
340
+ accountInfo.relaySecret = crypto.randomBytes(32).toString('hex')
341
+ accountInfo.relaySecretAt = iso()
342
+ await pan.putBuffer(Buffer.from(JSON.stringify(accountInfo, null, 2), 'utf8'), paths.accountFile)
343
+ log('账号签名密钥已生成(relaySecret)')
344
+ }
345
+
337
346
  const oldDevice = (await readJson(pan, paths.deviceFile, () => {})) || {}
338
347
  const deviceInfo = {
339
348
  nodeId,
@@ -376,6 +385,7 @@ export async function ensureLayout({ pan, cfg = {}, device = {}, cacheFile, forc
376
385
  accountSource: who.source,
377
386
  home: cfg.cloudHome || DEFAULT_HOME,
378
387
  role,
388
+ secret: accountInfo.relaySecret || '',
379
389
  // 有文档没写成功就不要把版本号标上去,下次 ensure 还会重试
380
390
  rulesVersion: rules.failed ? 0 : RULES_VERSION,
381
391
  rulesWritten: rules.written,
@@ -28,6 +28,37 @@ function saveState(file, state) {
28
28
 
29
29
  const isTmp = (name) => /\.tmp$/.test(name) || name.startsWith('.')
30
30
 
31
+ // ── 0.8.0 安全加固:入站边界与签名 ──────────────────────────
32
+ // F1:附件名一律当纯文件名处理,绝不接受信封里的路径
33
+ // F3:信封用账号级 relaySecret 做 HMAC-SHA256 签名
34
+ export const MAX_ENVELOPE_BYTES = 512 * 1024
35
+ export const MAX_FILES = 20
36
+ export const MAX_FILE_BYTES = 64 * 1024 * 1024
37
+
38
+ /** 只取纯文件名:剥路径、去控制字符、挡掉 Windows 非法字符;非法返回 '' */
39
+ export function safeFileName(raw) {
40
+ let name = String(raw == null ? '' : raw).replace(/[\u0000-\u001f\u007f]/g, '')
41
+ name = path.basename(name.replace(/\\/g, '/'))
42
+ name = name.replace(/[<>:"|?*]/g, '_').replace(/^\.+/, '').trim()
43
+ if (!name || name === '.' || name === '..') return ''
44
+ return name.slice(0, 180)
45
+ }
46
+
47
+ /** 拼到目录里并确认没跑出目录(防 ../ 穿越) */
48
+ export function safeJoin(dir, name) {
49
+ const base = path.resolve(dir)
50
+ const target = path.resolve(base, name)
51
+ if (target !== base && !target.startsWith(base + path.sep)) throw new Error('路径越界:' + name)
52
+ return target
53
+ }
54
+
55
+ const canonicalEnvelope = (e) =>
56
+ JSON.stringify([e.id || '', e.from || {}, e.to, e.subject || '', e.text || '', e.files || [], e.createdAt || 0])
57
+
58
+ /** 签名(发信侧与收信侧共用同一个规范串) */
59
+ export const signEnvelope = (secret, e) =>
60
+ crypto.createHmac('sha256', String(secret)).update(canonicalEnvelope(e)).digest('hex')
61
+
31
62
  /** 只处理信封 json:跳过 .tmp / 隐藏文件 / 以 _ 开头的系统说明(如 _说明.md、_inbox.json) */
32
63
  export const isEnvelopeFile = (name) =>
33
64
  /\.json$/i.test(name) && !isTmp(name) && !String(name).startsWith('_')
@@ -43,6 +74,7 @@ function parseEnvelope(text) {
43
74
  subject: String(j.subject || ''),
44
75
  files: Array.isArray(j.files) ? j.files : [],
45
76
  createdAt: j.createdAt || Date.now(),
77
+ sig: String(j.sig || ''),
46
78
  }
47
79
  }
48
80
 
@@ -55,25 +87,57 @@ function parseEnvelope(text) {
55
87
  * @param {Function} opts.deliver async ({ to, envelope, files }) => { ok, sessionId, reason }
56
88
  * @param {Function} [opts.log]
57
89
  */
58
- export function createRelay({ pan, getLayout, localDir, stateFile, deliver, log = () => {} }) {
90
+ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getSecret, log = () => {} }) {
59
91
  let running = false
92
+ let secretCache = ''
93
+
94
+ // 账号密钥:优先用 getSecret(),拿不到就直接读账号文件;本进程内缓存
95
+ async function secretNow() {
96
+ if (secretCache) return secretCache
97
+ if (typeof getSecret === 'function') {
98
+ try {
99
+ const s = await getSecret()
100
+ if (s) {
101
+ secretCache = String(s)
102
+ return secretCache
103
+ }
104
+ } catch { /* 继续读账号文件 */ }
105
+ }
106
+ try {
107
+ const layout = await getLayout()
108
+ if (layout && layout.paths && layout.paths.accountFile && typeof pan.getBuffer === 'function') {
109
+ const buf = await pan.getBuffer(layout.paths.accountFile)
110
+ const info = JSON.parse(buf.toString('utf8'))
111
+ if (info && info.relaySecret) {
112
+ secretCache = String(info.relaySecret)
113
+ return secretCache
114
+ }
115
+ }
116
+ } catch { /* 没有密钥就按未签名处理 */ }
117
+ return ''
118
+ }
60
119
  const stats = { at: 0, scanned: 0, delivered: 0, failed: 0, pending: 0, sent: 0, lastError: '' }
61
120
 
62
121
  async function downloadAttachment(layout, mailId, file, destDir) {
63
- // 附件既可能在 files/<id>/ 下,也可能就在 inbox/ 同级(老信封)
122
+ // 0.8.0 F1:附件名一律按纯文件名处理,绝不信任信封里的路径
123
+ const safe = safeFileName(file && file.name)
124
+ if (!safe) return { name: String((file && file.name) || ''), local: '', dst: '', remote: '', error: '附件名非法,已拒绝' }
125
+ if (Number((file && file.size) || 0) > MAX_FILE_BYTES) {
126
+ return { name: safe, local: '', dst: '', remote: '', error: '附件超过大小上限,已拒绝' }
127
+ }
128
+ // 附件既可能在 files/<id>/ 下,也可能就在 inbox/ 同级(老信封);0.8.0 F5:不接受信封自带的云端路径
64
129
  const candidates = [
65
- `${layout.paths.sharedDir}/${layout.deviceId}/files/${mailId}/${file.name}`,
66
- `${layout.paths.inboxDir}/${file.name}`,
67
- file.cloudPath || '',
68
- ].filter(Boolean)
130
+ `${layout.paths.sharedDir}/${layout.deviceId}/files/${mailId}/${safe}`,
131
+ `${layout.paths.inboxDir}/${safe}`,
132
+ ]
69
133
  for (const remote of candidates) {
70
134
  try {
71
- const dst = path.join(destDir, file.name)
135
+ const dst = safeJoin(destDir, safe)
72
136
  await pan.get(remote, dst)
73
- return { name: file.name, local: dst, remote, bytes: fs.statSync(dst).size }
137
+ return { name: safe, local: dst, dst, remote, bytes: fs.statSync(dst).size }
74
138
  } catch { /* try next */ }
75
139
  }
76
- return { name: file.name, local: '', remote: candidates[0] || '', error: '下载失败' }
140
+ return { name: safe, local: '', dst: '', remote: candidates[0] || '', error: '下载失败' }
77
141
  }
78
142
 
79
143
  async function poll() {
@@ -98,6 +162,13 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, log
98
162
 
99
163
  for (const f of files) {
100
164
  const name = f.server_filename
165
+ // 0.8.0 F4:先看大小再下载,超大信封直接拒收
166
+ if (Number(f.size || 0) > MAX_ENVELOPE_BYTES) {
167
+ stats.failed += 1
168
+ stats.lastError = `信封过大,已跳过:${name}(${f.size} B)`
169
+ log(stats.lastError)
170
+ continue
171
+ }
101
172
  if (state.processed[name]) continue
102
173
  const localJson = path.join(incomingDir, name)
103
174
  let envelope
@@ -111,10 +182,25 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, log
111
182
  continue
112
183
  }
113
184
  const mailId = envelope.id || path.basename(name, '.json')
185
+ // 0.8.0 F3:有账号密钥就要求签名对得上,否则拒收(防伪造发件人 / 提示注入)
186
+ const secret = await secretNow()
187
+ if (secret) {
188
+ if (!envelope.sig || envelope.sig !== signEnvelope(secret, envelope)) {
189
+ stats.failed += 1
190
+ stats.lastError = `信封签名无效,已拒收:${name}`
191
+ log(stats.lastError)
192
+ state.failed[mailId] = { at: nowIso(), reason: '签名无效', tries: Number(state.failed?.[mailId]?.tries || 0) + 1 }
193
+ saveState(stateFile, { ...state, at: Date.now() })
194
+ continue
195
+ }
196
+ } else if (!envelope.sig) {
197
+ log(`信封未签名(账号密钥还没就绪):${name}`)
198
+ }
114
199
  const fileDir = path.join(localDir, 'files', mailId)
115
200
  fs.mkdirSync(fileDir, { recursive: true })
116
201
  const landed = []
117
- for (const file of envelope.files) {
202
+ // 0.8.0 F4:附件数量上限
203
+ for (const file of envelope.files.slice(0, MAX_FILES)) {
118
204
  const got = await downloadAttachment(layout, mailId, file, fileDir)
119
205
  landed.push(got)
120
206
  }
@@ -126,15 +212,17 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, log
126
212
  }
127
213
  if (result && result.ok) {
128
214
  stats.delivered += 1
129
- state.processed[name] = { at: nowIso(), mailId, sessionId: result.sessionId || '', to: String(envelope.to) }
130
- // 云端归档:先放 done/,再删收件箱里的原件
215
+ // 0.8.0 F7/F8:先把信归档到 done/,归档结果记进 state,之后才允许按 state 跳过
216
+ let archived = false
131
217
  try {
132
218
  await pan.mkdir(layout.paths.doneDir)
133
219
  await pan.put(localJson, `${layout.paths.doneDir}/${name}`)
134
220
  await pan.rm(`${layout.paths.inboxDir}/${name}`)
221
+ archived = true
135
222
  } catch (e) {
136
223
  log(`归档 ${name} 失败:${(e && e.message) || String(e)}`)
137
224
  }
225
+ state.processed[mailId] = { at: nowIso(), name, mailId, archived, sessionId: result.sessionId || '', to: String(envelope.to) }
138
226
  log(`中转站投递 ${mailId} → ${result.sessionId || String(envelope.to)}`)
139
227
  } else {
140
228
  stats.pending += 1
@@ -180,6 +268,9 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, log
180
268
  createdAt: Date.now(),
181
269
  via: 'crossterm-relay',
182
270
  }
271
+ // 0.8.0 F3:发信侧签名(收信侧用同一个账号密钥验签)
272
+ const signSecret = await secretNow()
273
+ if (signSecret) envelope.sig = signEnvelope(signSecret, envelope)
183
274
  if (files.length) {
184
275
  const fileDir = `${layout.paths.sharedDir}/${target}/files/${mailId}`
185
276
  await pan.mkdir(fileDir)
package/lib/index.js CHANGED
@@ -498,7 +498,7 @@ class CrossTerm {
498
498
  })
499
499
  if (landed.length) {
500
500
  lines.push('', '附件已保存到本机:')
501
- for (const file of landed) lines.push(`- ${file.dst}(${formatBytes(file.bytes)})`)
501
+ for (const file of landed) lines.push(`- ${file.dst || file.local || file.name || '附件'}(${formatBytes(file.bytes)})`)
502
502
  }
503
503
  if (total > 1) {
504
504
  lines.push('', `(读法:上面 ${total} 封各有各的正文文件,先逐个 read 全部读完,再统一回一封;不要读一封回一封。)`)
@@ -862,7 +862,7 @@ class CrossTerm {
862
862
  const extra = []
863
863
  if (bad.length) extra.push(`(有 ${bad.length} 个附件没下载下来:${bad.map((f) => (f && f.name) || '未知').join('、')})`)
864
864
  if (fallbackNote) extra.push(fallbackNote)
865
- extra.push(`(这封信由云端中转站投递到本机,收件人:${targets.join('、')}。)(回复用 term_send。)`)
865
+ // 回复提示与「经由」已由 renderMailList() 统一给出,这里不再重复追加(0.7.6:去掉重复尾行)
866
866
  const text = [this.renderMailList([view], landed), '', ...extra].join('\n')
867
867
  const delivered = []
868
868
  const failed = []
package/lib/web.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // 状态、检查 npm 上的新版本、一键更新(仅 registry 安装模式可用)。
3
3
  import fs from 'node:fs'
4
4
  import os from 'node:os'
5
+ import crypto from 'node:crypto'
5
6
  import path from 'node:path'
6
7
  import { fileURLToPath } from 'node:url'
7
8
  import { spawn } from 'node:child_process'
@@ -28,6 +29,53 @@ function json(res, code, data) {
28
29
  res.end(body)
29
30
  }
30
31
 
32
+ // ── 0.8.0 F2:本地 HTTP 接口加令牌,挡网页 CSRF ──────────────
33
+ const TOKEN_HEADER = 'x-crossterm-token'
34
+ const LOOPBACK_ORIGIN = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
35
+ let httpToken = ''
36
+
37
+ function tokenFile(cfg) {
38
+ const dir = (cfg && cfg.busDir) || path.join(os.homedir(), '.dsh', 'crossterm')
39
+ return path.join(dir, 'http-token')
40
+ }
41
+
42
+ function ensureHttpToken(cfg, log = () => {}) {
43
+ if (httpToken) return httpToken
44
+ if (process.env.CROSSTERM_HTTP_TOKEN) {
45
+ httpToken = String(process.env.CROSSTERM_HTTP_TOKEN)
46
+ return httpToken
47
+ }
48
+ const file = tokenFile(cfg)
49
+ try {
50
+ httpToken = fs.readFileSync(file, 'utf8').trim()
51
+ } catch { httpToken = '' }
52
+ if (!httpToken) {
53
+ httpToken = crypto.randomBytes(24).toString('hex')
54
+ try {
55
+ fs.mkdirSync(path.dirname(file), { recursive: true })
56
+ fs.writeFileSync(file, httpToken, { encoding: 'utf8', mode: 0o600 })
57
+ } catch (e) {
58
+ log(`写 http-token 失败:${(e && e.message) || e}`)
59
+ }
60
+ }
61
+ return httpToken
62
+ }
63
+
64
+ /** 跨站 Origin 一律拒;令牌对得上才放行(同源页面可先 GET /crossterm/api/token 取令牌) */
65
+ function checkHttp(req, res, cfg) {
66
+ const origin = String((req.headers && req.headers.origin) || '')
67
+ if (origin && !LOOPBACK_ORIGIN.test(origin)) {
68
+ json(res, 403, { ok: false, message: '拒绝跨站请求' })
69
+ return false
70
+ }
71
+ const got = String((req.headers && req.headers[TOKEN_HEADER]) || '')
72
+ if (got !== ensureHttpToken(cfg)) {
73
+ json(res, 401, { ok: false, message: '缺少或错误的接口令牌(' + TOKEN_HEADER + ')' })
74
+ return false
75
+ }
76
+ return true
77
+ }
78
+
31
79
  /** 找到装着本插件的 profile(<dsh-home>/profiles/<name>/package.json) */
32
80
  function profileInfo(busDir) {
33
81
  // 先找「正在运行的那个 profile」:桌面宿主把 profile 目录作为 argv 传给自己,
@@ -455,6 +503,21 @@ export function registerWeb(ctx, term, log) {
455
503
  handler: (req, res) => json(res, 200, statusOf(term)),
456
504
  }),
457
505
  )
506
+ // 同源页面从这里取接口令牌(跨站页面读不到响应,因为没有 CORS 头)
507
+ disposers.push(
508
+ webCtx.webServer.register({
509
+ kind: 'exact',
510
+ path: '/crossterm/api/token',
511
+ handler: (req, res) => {
512
+ const origin = String((req.headers && req.headers.origin) || '')
513
+ if (origin && !LOOPBACK_ORIGIN.test(origin)) {
514
+ json(res, 403, { ok: false, message: '拒绝跨站请求' })
515
+ return
516
+ }
517
+ json(res, 200, { ok: true, token: ensureHttpToken(term.cfg, log) })
518
+ },
519
+ }),
520
+ )
458
521
  disposers.push(
459
522
  webCtx.webServer.register({
460
523
  kind: 'exact',
@@ -567,6 +630,7 @@ export function registerWeb(ctx, term, log) {
567
630
  kind: 'exact',
568
631
  path: '/crossterm/api/backup',
569
632
  handler: async (req, res) => {
633
+ if (!checkHttp(req, res, term.cfg)) return
570
634
  try {
571
635
  if (!term.home) term.enableCloud()
572
636
  if (!term.home) {
@@ -587,6 +651,7 @@ export function registerWeb(ctx, term, log) {
587
651
  kind: 'exact',
588
652
  path: '/crossterm/api/check',
589
653
  handler: async (req, res) => {
654
+ if (!checkHttp(req, res, term.cfg)) return
590
655
  try {
591
656
  const latest = await latestVersion()
592
657
  // 只有 npm 上的版本「更新」才算有更新:本地源码版常领先于已发布版,
@@ -605,6 +670,7 @@ export function registerWeb(ctx, term, log) {
605
670
  kind: 'exact',
606
671
  path: '/crossterm/api/cloud/start',
607
672
  handler: (req, res) => {
673
+ if (!checkHttp(req, res, term.cfg)) return
608
674
  try {
609
675
  const before = statusOf(term).cloud
610
676
  if (!before.configured) {
@@ -628,6 +694,7 @@ export function registerWeb(ctx, term, log) {
628
694
  kind: 'exact',
629
695
  path: '/crossterm/api/cloud/stop',
630
696
  handler: (req, res) => {
697
+ if (!checkHttp(req, res, term.cfg)) return
631
698
  try {
632
699
  term.disableCloud()
633
700
  json(res, 200, { ok: true, message: '云同步已关闭(本进程内生效,重启后按配置恢复)', cloud: statusOf(term).cloud })
@@ -642,6 +709,7 @@ export function registerWeb(ctx, term, log) {
642
709
  kind: 'exact',
643
710
  path: '/crossterm/api/cloud/setup',
644
711
  handler: (req, res) => {
712
+ if (!checkHttp(req, res, term.cfg)) return
645
713
  if (setupRunning) {
646
714
  json(res, 200, { ok: false, running: true, message: '配置流程已经在进行中(看任务栏里的记事本)' })
647
715
  return
@@ -678,6 +746,7 @@ export function registerWeb(ctx, term, log) {
678
746
  kind: 'exact',
679
747
  path: '/crossterm/api/update',
680
748
  handler: async (req, res) => {
749
+ if (!checkHttp(req, res, term.cfg)) return
681
750
  const info = profileInfo(term.cfg && term.cfg.busDir)
682
751
  if (!info) {
683
752
  json(res, 200, { ok: false, message: '找不到安装本插件的 profile' })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",