dsh-crossterm 0.7.6 → 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 +34 -0
- package/client/client.js +14 -1
- package/lib/bus.js +1 -1
- package/lib/cloud/home.js +4 -0
- package/lib/cloud/layout.js +10 -0
- package/lib/cloud/relay.js +103 -12
- package/lib/index.js +1 -1
- package/lib/web.js +69 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
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
|
+
|
|
3
37
|
## 0.7.6 — 2026-10-19
|
|
4
38
|
|
|
5
39
|
### 收信尾行不再重复:跨设备邮件只给一条回信提示
|
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
|
|
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
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,
|
package/lib/cloud/layout.js
CHANGED
|
@@ -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,
|
package/lib/cloud/relay.js
CHANGED
|
@@ -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
|
-
//
|
|
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}/${
|
|
66
|
-
`${layout.paths.inboxDir}/${
|
|
67
|
-
|
|
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 =
|
|
135
|
+
const dst = safeJoin(destDir, safe)
|
|
72
136
|
await pan.get(remote, dst)
|
|
73
|
-
return { name:
|
|
137
|
+
return { name: safe, local: dst, dst, remote, bytes: fs.statSync(dst).size }
|
|
74
138
|
} catch { /* try next */ }
|
|
75
139
|
}
|
|
76
|
-
return { name:
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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 全部读完,再统一回一封;不要读一封回一封。)`)
|
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' })
|