zen-gitsync 2.16.31 → 2.16.33
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.md +31 -0
- package/package.json +2 -2
- package/src/config.js +12 -2
- package/src/ui/public/assets/AgentView-D9y5maA3.js +4 -0
- package/src/ui/public/assets/AppVersionBadge-CsKq0HLG.js +2 -0
- package/src/ui/public/assets/BranchSelector-C09wdMBR.js +1 -0
- package/src/ui/public/assets/CommitForm-wTVAlj8K.js +6 -0
- package/src/ui/public/assets/CommonDialog-gF_3v5QY.js +1 -0
- package/src/ui/public/assets/EditorView-BYaXYY5H.js +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-DvSe3GqC.js +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-FYLZxzMC.js +21 -0
- package/src/ui/public/assets/LogList-BB56Ma_-.js +7 -0
- package/src/ui/public/assets/MindmapView-B57NJ-tK.js +2 -0
- package/src/ui/public/assets/MonitorView-DujOLv6G.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-B0vIxM8Y.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-Tdhzf-Jb.js +5 -0
- package/src/ui/public/assets/SourceMapView-Bf7__bEW.js +3 -0
- package/src/ui/public/assets/SvgIcon-DV0F5fm7.js +1 -0
- package/src/ui/public/assets/UserInputNode-ZatmWGxI.js +1 -0
- package/src/ui/public/assets/WorkbenchView-DVjvpQMi.js +28 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-DkdPQgM8.js +17 -0
- package/src/ui/public/assets/configStore-C2xoYf_U.js +1 -0
- package/src/ui/public/assets/element-plus-BrZIKMTV.js +47 -0
- package/src/ui/public/assets/{flow-mindmap-C0_IdibG.css → flow-mindmap-DnzM9SlK.css} +1 -1
- package/src/ui/public/assets/flow-mindmap-uF7lcnUF.js +139 -0
- package/src/ui/public/assets/index-DDa4fUq_.js +79 -0
- package/src/ui/public/assets/index-DkMUEB4_.css +1 -0
- package/src/ui/public/assets/office-docx-DZfJaVF6.js +32 -0
- package/src/ui/public/assets/office-excel-BhBLQBXN.js +170 -0
- package/src/ui/public/assets/office-pptx-C0yb5pVU.js +74 -0
- package/src/ui/public/assets/toolsStore-Dpb3rc-o.js +1 -0
- package/src/ui/public/assets/{vendor-Cx8PA1NP.js → vendor-BuJWEuf4.js} +43 -43
- package/src/ui/public/assets/vue-flow-CrEicpfB.js +39 -0
- package/src/ui/public/index.html +12 -12
- package/src/ui/server/index.js +9 -23
- package/src/ui/server/middleware/originGuard.js +138 -0
- package/src/ui/server/middleware/originGuard.test.js +125 -0
- package/src/ui/server/routes/fs.js +83 -3
- package/src/ui/server/routes/git/diff.js +12 -4
- package/src/ui/server/routes/git/stash.js +39 -29
- package/src/ui/server/routes/git/tags.js +23 -16
- package/src/ui/server/routes/git.js +17 -11
- package/src/ui/server/routes/gitOps.js +126 -47
- package/src/ui/server/routes/npm.js +36 -10
- package/src/ui/server/utils/gitArgs.js +165 -0
- package/src/ui/server/utils/gitArgs.test.js +215 -0
- package/src/ui/server/utils/officePreview.js +80 -0
- package/src/ui/server/utils/officePreview.test.js +21 -0
- package/src/ui/server/utils/startServerOnAvailablePort.js +20 -2
- package/src/ui/server/utils/startServerOnAvailablePort.test.js +181 -0
- package/src/ui/public/assets/AgentView-DPan52IZ.js +0 -4
- package/src/ui/public/assets/AppVersionBadge-CiorBQrl.js +0 -2
- package/src/ui/public/assets/BranchSelector-OQWGKJq8.js +0 -1
- package/src/ui/public/assets/CommitForm-Cq1UUNM4.js +0 -6
- package/src/ui/public/assets/CommonDialog-DJqOwkbm.js +0 -1
- package/src/ui/public/assets/EditorView-0HmbFAex.js +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-DjIAKDQt.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CCs7sMOO.js +0 -21
- package/src/ui/public/assets/LogList-De9E23D1.js +0 -7
- package/src/ui/public/assets/MindmapView-CpyaWr7b.js +0 -2
- package/src/ui/public/assets/MonitorView-DVwS51YV.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-DiaZ8xiI.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-B299cQhO.js +0 -5
- package/src/ui/public/assets/SourceMapView-C7vO_5Hz.js +0 -3
- package/src/ui/public/assets/SvgIcon-B9XhBoIQ.js +0 -1
- package/src/ui/public/assets/UserInputNode-CeNehn29.js +0 -1
- package/src/ui/public/assets/WorkbenchView-Be4GcjdO.js +0 -28
- package/src/ui/public/assets/_plugin-vue_export-helper-aENhMEEX.js +0 -17
- package/src/ui/public/assets/configStore-BvJZNumP.js +0 -1
- package/src/ui/public/assets/element-plus-B3pTS_5R.js +0 -32
- package/src/ui/public/assets/flow-mindmap-hUZCS6o1.js +0 -138
- package/src/ui/public/assets/index-CuD0oK0z.js +0 -79
- package/src/ui/public/assets/index-zmReBTKj.css +0 -1
- package/src/ui/public/assets/toolsStore-D9gXnqwO.js +0 -1
- package/src/ui/public/assets/vue-flow-COROsN-V.js +0 -39
package/src/ui/server/index.js
CHANGED
|
@@ -50,6 +50,7 @@ import { registerMindmapRoutes } from './routes/mindmap.js';
|
|
|
50
50
|
import { registerAgentRoutes } from './routes/workbench/agentRoutes.js';
|
|
51
51
|
import { createInstanceRegistry } from './utils/instanceRegistry.js';
|
|
52
52
|
import { createSavePortToFile } from './utils/createSavePortToFile.js';
|
|
53
|
+
import { createOriginGuard, createOriginCheckerFromEnv } from './middleware/originGuard.js';
|
|
53
54
|
import { startServerOnAvailablePort } from './utils/startServerOnAvailablePort.js';
|
|
54
55
|
import { resolveStartPort } from './utils/randomStartPort.js';
|
|
55
56
|
import { perfMark } from './utils/perfMark.js';
|
|
@@ -193,35 +194,14 @@ async function startUIServer(noOpen = false, savePort = false) {
|
|
|
193
194
|
const httpServer = createServer(app);
|
|
194
195
|
|
|
195
196
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
196
|
-
// Socket.IO
|
|
197
|
+
// Origin 收紧(Socket.IO + HTTP 共用同一套判定,见 middleware/originGuard.js)
|
|
197
198
|
//
|
|
198
199
|
// 默认 Socket.IO 接受任意 origin,跨域页面也能连上 → CSRF / 跨站攻击面
|
|
199
200
|
// 暴露给恶意页面。这里只允许同源 + 127.0.0.1 / localhost。
|
|
200
201
|
// 用环境变量 ZEN_ALLOWED_ORIGINS 可以追加自定义来源(逗号分隔),
|
|
201
202
|
// CI / 远程调试场景用。
|
|
202
203
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
203
|
-
const
|
|
204
|
-
'http://localhost',
|
|
205
|
-
'https://localhost',
|
|
206
|
-
'http://127.0.0.1',
|
|
207
|
-
'https://127.0.0.1',
|
|
208
|
-
'http://[::1]',
|
|
209
|
-
'null', // 同源 file:// 等场景
|
|
210
|
-
];
|
|
211
|
-
const extraOrigins = String(process.env.ZEN_ALLOWED_ORIGINS || '')
|
|
212
|
-
.split(',').map(s => s.trim()).filter(Boolean);
|
|
213
|
-
const allowedOrigins = new Set([...DEFAULT_ALLOWED_ORIGINS, ...extraOrigins]);
|
|
214
|
-
|
|
215
|
-
function isOriginAllowed(origin) {
|
|
216
|
-
if (!origin) return true; // 同源 / 非浏览器客户端不传 Origin
|
|
217
|
-
if (allowedOrigins.has(origin)) return true;
|
|
218
|
-
// 允许 localhost/127.0.0.1/[::1] 上的任意端口(开发常换端口)
|
|
219
|
-
try {
|
|
220
|
-
const u = new URL(origin);
|
|
221
|
-
if (['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)) return true;
|
|
222
|
-
} catch { /* ignore */ }
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
204
|
+
const isOriginAllowed = createOriginCheckerFromEnv()
|
|
225
205
|
|
|
226
206
|
const io = new Server(httpServer, {
|
|
227
207
|
cors: {
|
|
@@ -270,6 +250,12 @@ async function startUIServer(noOpen = false, savePort = false) {
|
|
|
270
250
|
// 添加全局中间件来解析JSON请求体
|
|
271
251
|
app.use(express.json());
|
|
272
252
|
|
|
253
|
+
// 跨站请求守卫:放在 body 解析之后、业务路由之前。
|
|
254
|
+
// 服务没有认证层,恶意页面只要能把请求打进来就是全权限(命令执行 / 写文件 /
|
|
255
|
+
// 打开任意文件)。监听已收敛到 127.0.0.1,剩下的主要入口就是本机浏览器里的
|
|
256
|
+
// 跨站 fetch 与 DNS rebinding,按 Origin 挡这一道成本最低。
|
|
257
|
+
app.use(createOriginGuard());
|
|
258
|
+
|
|
273
259
|
// 两个 git 检测并行发起(Windows 上每个 git spawn ~180-200ms,串行要 ~380ms):
|
|
274
260
|
// show-toplevel 供下面"记录最近目录"立即 await;is-inside-work-tree 供路由注册后
|
|
275
261
|
// 的 isGitRepo 判定使用,让它在路由注册期间后台完成,此处不 await。
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// HTTP 请求的 Origin 守卫。
|
|
16
|
+
//
|
|
17
|
+
// 监听地址收敛到 127.0.0.1 之后,剩下的主要攻击面是「本机浏览器里的恶意页面」:
|
|
18
|
+
// - 跨站 fetch('http://127.0.0.1:<port>/api/exec-stream', ...) → 带 Origin: evil.com
|
|
19
|
+
// - DNS rebinding:恶意域名解析到 127.0.0.1,绕过同源检查
|
|
20
|
+
// 服务没有认证层,只要请求能进来就是全权限,所以必须在最外层按 Origin 挡一道。
|
|
21
|
+
//
|
|
22
|
+
// 与 Socket.IO 的 cors.origin 判定共用同一套规则(见 createOriginChecker),
|
|
23
|
+
// 避免「WebSocket 连得上但 REST 被拒」这种半残状态。
|
|
24
|
+
|
|
25
|
+
import os from 'node:os'
|
|
26
|
+
import logger from '../utils/logger.js'
|
|
27
|
+
|
|
28
|
+
// 本机主机名:允许其上任意端口(开发期前端跑在 Vite dev server,端口与后端不同)
|
|
29
|
+
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
30
|
+
|
|
31
|
+
// ZEN_HOST 的通配取值:绑这些地址等于"放开到所有网卡"
|
|
32
|
+
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]'])
|
|
33
|
+
|
|
34
|
+
function parseExtraOrigins(envValue) {
|
|
35
|
+
return String(envValue || '')
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((s) => s.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 构造 origin 判定函数。
|
|
43
|
+
*
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {string[]} [opts.extraOrigins] 额外放行的完整 origin(含协议与端口)
|
|
46
|
+
* @param {string[]} [opts.extraHosts] 额外放行的 hostname(允许其上任意端口)。
|
|
47
|
+
* 放开监听地址(ZEN_HOST=0.0.0.0)时用它放行局域网来源,否则用户会被自己的守卫拦在门外。
|
|
48
|
+
* @returns {(origin: string|undefined) => boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function createOriginChecker({ extraOrigins = [], extraHosts = [] } = {}) {
|
|
51
|
+
const exact = new Set(extraOrigins)
|
|
52
|
+
const hosts = new Set(extraHosts)
|
|
53
|
+
|
|
54
|
+
return function isOriginAllowed(origin) {
|
|
55
|
+
// 没有 Origin 头:curl / CLI / 非浏览器客户端 / 部分同源 GET。
|
|
56
|
+
// 这类请求不可能是跨站攻击(浏览器跨站一定带 Origin),放行。
|
|
57
|
+
if (!origin) return true
|
|
58
|
+
if (exact.has(origin)) return true
|
|
59
|
+
// file:// 页面发起的请求,Origin 字面量就是 'null'
|
|
60
|
+
if (origin === 'null') return true
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const u = new URL(origin)
|
|
64
|
+
if (LOCAL_HOSTNAMES.has(u.hostname)) return true
|
|
65
|
+
if (hosts.has(u.hostname)) return true
|
|
66
|
+
} catch {
|
|
67
|
+
// 解析不了的一律拒绝
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 收集本机所有网卡的地址(作为可放行 origin 的 hostname)。
|
|
76
|
+
*
|
|
77
|
+
* 为什么需要:ZEN_HOST=0.0.0.0 放开监听后,用户是从另一台设备通过本机
|
|
78
|
+
* 局域网 IP(如 http://192.168.1.10:5545)访问 GUI 的,浏览器对同源 POST
|
|
79
|
+
* 会带 Origin: http://192.168.1.10:5545 —— hostname 是本机网卡地址而不是
|
|
80
|
+
* 0.0.0.0,不放行的话用户会被自己的守卫 403 掉所有写操作。
|
|
81
|
+
* 安全性不因此受损:Origin 由浏览器生成、页面无法伪造,能带上本机 IP 的
|
|
82
|
+
* 只有从本机加载出去的页面(即 GUI 自己);恶意域名 / DNS rebinding 的
|
|
83
|
+
* origin hostname 都不是本机地址,照样被拒。
|
|
84
|
+
*/
|
|
85
|
+
function collectLocalInterfaceHosts() {
|
|
86
|
+
const hosts = []
|
|
87
|
+
for (const infos of Object.values(os.networkInterfaces())) {
|
|
88
|
+
for (const info of infos || []) {
|
|
89
|
+
// IPv6 的 URL hostname 带方括号,且要去掉 zone id(%eth0 之类)
|
|
90
|
+
const addr = String(info.address || '').split('%')[0]
|
|
91
|
+
if (!addr) continue
|
|
92
|
+
hosts.push(info.family === 'IPv6' ? `[${addr}]` : addr)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return hosts
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 从环境变量读取配置,构造判定函数(Socket.IO 与 HTTP 守卫共用) */
|
|
99
|
+
export function createOriginCheckerFromEnv() {
|
|
100
|
+
const zenHosts = parseExtraOrigins(process.env.ZEN_HOST)
|
|
101
|
+
// 绑通配地址(0.0.0.0 / ::)等于放开所有网卡,本机各网卡地址要一并放行
|
|
102
|
+
const extraHosts = zenHosts.some((h) => WILDCARD_HOSTS.has(h))
|
|
103
|
+
? [...zenHosts, ...collectLocalInterfaceHosts()]
|
|
104
|
+
: zenHosts
|
|
105
|
+
|
|
106
|
+
return createOriginChecker({
|
|
107
|
+
// 默认放行的完整 origin(后来的 hostname 匹配已覆盖大部分场景,保留只为兼容)
|
|
108
|
+
extraOrigins: [
|
|
109
|
+
'http://localhost',
|
|
110
|
+
'https://localhost',
|
|
111
|
+
'http://127.0.0.1',
|
|
112
|
+
'https://127.0.0.1',
|
|
113
|
+
'http://[::1]',
|
|
114
|
+
// ZEN_ALLOWED_ORIGINS: 逗号分隔的完整 origin,CI / 远程调试用
|
|
115
|
+
...parseExtraOrigins(process.env.ZEN_ALLOWED_ORIGINS)
|
|
116
|
+
],
|
|
117
|
+
// ZEN_HOST 放开监听时,对应来源要一并放行,否则局域网访问会被自己的守卫拦掉
|
|
118
|
+
extraHosts
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Express 中间件:拒绝非本机来源的跨站请求。
|
|
124
|
+
*/
|
|
125
|
+
export function createOriginGuard() {
|
|
126
|
+
const isOriginAllowed = createOriginCheckerFromEnv()
|
|
127
|
+
|
|
128
|
+
return function originGuard(req, res, next) {
|
|
129
|
+
const origin = req.get('origin')
|
|
130
|
+
if (isOriginAllowed(origin)) return next()
|
|
131
|
+
|
|
132
|
+
logger.warn(`[origin-guard] 拒绝跨站请求: ${req.method} ${req.path} origin=${origin}`)
|
|
133
|
+
res.status(403).json({
|
|
134
|
+
success: false,
|
|
135
|
+
error: '跨站请求被拒绝(Origin 不在允许列表中)'
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// originGuard.js 单元测试。
|
|
16
|
+
//
|
|
17
|
+
// 锁两条契约:
|
|
18
|
+
// 1. 本机来源(localhost / 127.0.0.1 / [::1] 任意端口)必须放行 —— 否则开发期
|
|
19
|
+
// Vite dev server(端口与后端不同)会全站 403,是典型的自伤式加固。
|
|
20
|
+
// 2. 外部来源必须拒绝 —— 服务无认证层,漏一个就是全权限。
|
|
21
|
+
import { test } from 'node:test'
|
|
22
|
+
import assert from 'node:assert/strict'
|
|
23
|
+
import { createOriginChecker, createOriginCheckerFromEnv } from './originGuard.js'
|
|
24
|
+
|
|
25
|
+
/** 跑一次中间件,返回 { allowed, status } */
|
|
26
|
+
function runGuard(origin, envPatch = {}) {
|
|
27
|
+
const saved = {}
|
|
28
|
+
for (const [k, v] of Object.entries(envPatch)) {
|
|
29
|
+
saved[k] = process.env[k]
|
|
30
|
+
if (v === null) delete process.env[k]
|
|
31
|
+
else process.env[k] = v
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const isAllowed = createOriginCheckerFromEnv()(origin)
|
|
35
|
+
return { allowed: isAllowed }
|
|
36
|
+
} finally {
|
|
37
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
38
|
+
if (v === undefined) delete process.env[k]
|
|
39
|
+
else process.env[k] = v
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('无 Origin 头放行(curl / CLI / 同源 GET 不该被拦)', () => {
|
|
45
|
+
assert.equal(runGuard(undefined).allowed, true)
|
|
46
|
+
assert.equal(runGuard('').allowed, true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('本机 hostname 的任意端口都放行(开发期前后端不同端口)', () => {
|
|
50
|
+
for (const ok of [
|
|
51
|
+
'http://localhost:5545',
|
|
52
|
+
'http://localhost:5544',
|
|
53
|
+
'http://127.0.0.1:5545',
|
|
54
|
+
'https://127.0.0.1:3000',
|
|
55
|
+
'http://[::1]:8080'
|
|
56
|
+
]) {
|
|
57
|
+
assert.equal(runGuard(ok).allowed, true, `应放行 ${ok}`)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('外部来源一律拒绝(挡跨站 fetch 与 DNS rebinding)', () => {
|
|
62
|
+
for (const bad of [
|
|
63
|
+
'http://evil.com',
|
|
64
|
+
'https://evil.com:5545',
|
|
65
|
+
'http://attacker.example',
|
|
66
|
+
'http://192.168.1.66:5545', // 局域网其他主机
|
|
67
|
+
'http://localhost.evil.com' // 看着像 localhost 实则是外部域名
|
|
68
|
+
]) {
|
|
69
|
+
assert.equal(runGuard(bad).allowed, false, `应拒绝 ${bad}`)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('file:// 场景的 null origin 放行', () => {
|
|
74
|
+
assert.equal(runGuard('null').allowed, true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('ZEN_ALLOWED_ORIGINS 可追加放行来源', () => {
|
|
78
|
+
assert.equal(
|
|
79
|
+
runGuard('https://zen.example.com', { ZEN_ALLOWED_ORIGINS: 'https://zen.example.com' }).allowed,
|
|
80
|
+
true
|
|
81
|
+
)
|
|
82
|
+
// 追加不影响默认拒绝行为
|
|
83
|
+
assert.equal(
|
|
84
|
+
runGuard('http://evil.com', { ZEN_ALLOWED_ORIGINS: 'https://zen.example.com' }).allowed,
|
|
85
|
+
false
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('ZEN_HOST 放开监听时,该 host 的来源要一并放行(否则用户被自己的守卫拦在门外)', () => {
|
|
90
|
+
assert.equal(runGuard('http://192.168.1.10:5545', { ZEN_HOST: '192.168.1.10' }).allowed, true)
|
|
91
|
+
// 未放开时,同一个来源仍然拒绝
|
|
92
|
+
assert.equal(runGuard('http://192.168.1.10:5545', { ZEN_HOST: null }).allowed, false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('ZEN_HOST=0.0.0.0 时本机各网卡 IP 的来源放行(跨设备访问 GUI 的场景)', async () => {
|
|
96
|
+
const os = await import('node:os')
|
|
97
|
+
// 取一个真实存在的本机非回环 IPv4 地址来构造 origin;没有则跳过
|
|
98
|
+
const lanIp = Object.values(os.networkInterfaces())
|
|
99
|
+
.flat()
|
|
100
|
+
.find((i) => i && i.family === 'IPv4' && !i.internal)?.address
|
|
101
|
+
if (!lanIp) return
|
|
102
|
+
assert.equal(runGuard(`http://${lanIp}:5545`, { ZEN_HOST: '0.0.0.0' }).allowed, true)
|
|
103
|
+
// 通配绑定也不等于任意来源放行:外部域名与别的 LAN IP 仍拒绝
|
|
104
|
+
assert.equal(runGuard('http://evil.com', { ZEN_HOST: '0.0.0.0' }).allowed, false)
|
|
105
|
+
assert.equal(runGuard('http://203.0.113.9:5545', { ZEN_HOST: '0.0.0.0' }).allowed, false)
|
|
106
|
+
// 未设 ZEN_HOST 时,同一个本机 IP 来源仍然拒绝
|
|
107
|
+
assert.equal(runGuard(`http://${lanIp}:5545`, { ZEN_HOST: null }).allowed, false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('createOriginChecker: extraOrigins / extraHosts 生效', () => {
|
|
111
|
+
const check = createOriginChecker({
|
|
112
|
+
extraOrigins: ['https://a.example.com'],
|
|
113
|
+
extraHosts: ['10.0.0.5']
|
|
114
|
+
})
|
|
115
|
+
assert.equal(check('https://a.example.com'), true)
|
|
116
|
+
assert.equal(check('http://10.0.0.5:1234'), true) // extraHosts 允许任意端口
|
|
117
|
+
assert.equal(check('http://b.example.com'), false)
|
|
118
|
+
assert.equal(check(undefined), true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('createOriginChecker: 无法解析的 origin 一律拒绝', () => {
|
|
122
|
+
const check = createOriginChecker()
|
|
123
|
+
assert.equal(check('not-a-url'), false)
|
|
124
|
+
assert.equal(check('::::'), false)
|
|
125
|
+
})
|
|
@@ -67,7 +67,10 @@ function validateChangeDirectoryPath(input) {
|
|
|
67
67
|
// 把 absolute path 准备好传给 spawn/exec,内部不再做字符串拼接,
|
|
68
68
|
// Windows 走 cmd /c start "" ... argv 模式;Linux 走 sh -c 但 argument 已被
|
|
69
69
|
// shQuote.js 转义
|
|
70
|
+
// 注意:历史上 /api/open_terminal 的 xterm 分支漏了转义,用的是 JSON.stringify
|
|
71
|
+
// (双引号内 $() 仍会展开),已在下方改用 shQuote —— 这里的注释当时是不成立的。
|
|
70
72
|
import { shQuote, psEscape } from '../utils/shellQuote.js';
|
|
73
|
+
import { convertOfficeToPdf, isOfficeFile } from '../utils/officePreview.js';
|
|
71
74
|
|
|
72
75
|
export function registerFsRoutes({
|
|
73
76
|
app,
|
|
@@ -433,11 +436,13 @@ export function registerFsRoutes({
|
|
|
433
436
|
break;
|
|
434
437
|
case 'linux': {
|
|
435
438
|
// Linux: 尝试使用常见的终端模拟器 - argv 数组模式
|
|
436
|
-
// xterm
|
|
439
|
+
// xterm 分支会把路径拼进 bash -c 的字符串,必须用 shQuote(单引号包裹,
|
|
440
|
+
// 单引号内 shell 不做任何展开)。原先这里用 JSON.stringify(双引号),
|
|
441
|
+
// 而 bash 在双引号内仍会展开 $(...) / `...` / ${...},等于没防住。
|
|
437
442
|
const terminals = [
|
|
438
443
|
{ cmd: 'gnome-terminal', args: ['--working-directory', directoryPath] },
|
|
439
444
|
{ cmd: 'konsole', args: ['--workdir', directoryPath] },
|
|
440
|
-
{ cmd: 'xterm', args: ['-e', 'bash', '-c', `cd ${
|
|
445
|
+
{ cmd: 'xterm', args: ['-e', 'bash', '-c', `cd ${shQuote(directoryPath)} && exec $SHELL`] },
|
|
441
446
|
];
|
|
442
447
|
|
|
443
448
|
// 用 spawn 探测命令存在(不走 shell)
|
|
@@ -659,6 +664,82 @@ export function registerFsRoutes({
|
|
|
659
664
|
}
|
|
660
665
|
}));
|
|
661
666
|
|
|
667
|
+
// ── Office 文件预览:工作区文件或 Git 版本统一转换为 PDF ───────────────
|
|
668
|
+
// 预览接口直接返回 PDF,前端可用 iframe/PDF.js 展示;不把 Office ZIP/XML
|
|
669
|
+
// 当作 UTF-8 文本读取,避免二进制损坏。Git revision 仅接受常见安全格式。
|
|
670
|
+
app.get('/api/office/preview', asyncRoute(async (req, res) => {
|
|
671
|
+
const filePath = String(req.query.file || '');
|
|
672
|
+
const source = String(req.query.source || 'worktree');
|
|
673
|
+
const rev = String(req.query.rev || '');
|
|
674
|
+
const side = String(req.query.side || 'new');
|
|
675
|
+
if (!filePath) throw new HttpError(400, '缺少 file 参数');
|
|
676
|
+
if (!['worktree', 'git'].includes(source)) throw new HttpError(400, '非法的 Office 预览来源');
|
|
677
|
+
if (!isOfficeFile(filePath)) throw new HttpError(415, '当前文件不是支持的 Office 类型');
|
|
678
|
+
|
|
679
|
+
const safe = await safePathInProject(filePath);
|
|
680
|
+
if (!safe) throw new HttpError(403, '禁止访问工作目录以外的文件');
|
|
681
|
+
const relPath = path.relative(getCurrentProjectPath() || process.cwd(), safe.safePath).replace(/\\/g, '/');
|
|
682
|
+
let buffer;
|
|
683
|
+
let cacheKey;
|
|
684
|
+
if (source === 'git') {
|
|
685
|
+
if (!/^[0-9a-f]{7,64}$/i.test(rev) || !['old', 'new'].includes(side)) {
|
|
686
|
+
throw new HttpError(400, '非法的 Git 版本参数');
|
|
687
|
+
}
|
|
688
|
+
const target = side === 'old' ? `${rev}^` : rev;
|
|
689
|
+
const spec = `${target}:${relPath}`;
|
|
690
|
+
const result = await execGitCommand(['show', spec], { encoding: 'buffer', log: false, maxBuffer: 60 * 1024 * 1024 });
|
|
691
|
+
buffer = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout || '');
|
|
692
|
+
cacheKey = `git:${target}:${relPath}`;
|
|
693
|
+
} else {
|
|
694
|
+
const stat = await fs.stat(safe.safePath);
|
|
695
|
+
if (!stat.isFile()) throw new HttpError(400, '目标不是文件');
|
|
696
|
+
if (stat.size > 50 * 1024 * 1024) throw new HttpError(413, 'Office 文件过大(最大 50 MB)');
|
|
697
|
+
buffer = await fs.readFile(safe.safePath);
|
|
698
|
+
cacheKey = `worktree:${safe.safePath}:${stat.size}:${stat.mtimeMs}`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const result = await convertOfficeToPdf({ buffer, filePath, cacheKey });
|
|
702
|
+
res.setHeader('Content-Type', result.contentType);
|
|
703
|
+
res.setHeader('Content-Disposition', `inline; filename="${path.basename(filePath)}.pdf"`);
|
|
704
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
705
|
+
res.send(result.buffer);
|
|
706
|
+
}));
|
|
707
|
+
|
|
708
|
+
// ── Office 原始二进制:供 vue-office 在浏览器内解析 ─────────────────────
|
|
709
|
+
app.get('/api/office/raw', asyncRoute(async (req, res) => {
|
|
710
|
+
const filePath = String(req.query.file || '');
|
|
711
|
+
const source = String(req.query.source || 'worktree');
|
|
712
|
+
const rev = String(req.query.rev || '');
|
|
713
|
+
const side = String(req.query.side || 'new');
|
|
714
|
+
if (!filePath) throw new HttpError(400, '缺少 file 参数');
|
|
715
|
+
if (!['worktree', 'git'].includes(source)) throw new HttpError(400, '非法的 Office 文件来源');
|
|
716
|
+
if (!isOfficeFile(filePath)) throw new HttpError(415, '当前文件不是支持的 Office 类型');
|
|
717
|
+
|
|
718
|
+
const safe = await safePathInProject(filePath);
|
|
719
|
+
if (!safe) throw new HttpError(403, '禁止访问工作目录以外的文件');
|
|
720
|
+
let content;
|
|
721
|
+
if (source === 'git') {
|
|
722
|
+
if (!/^[0-9a-f]{7,64}$/i.test(rev) || !['old', 'new'].includes(side)) {
|
|
723
|
+
throw new HttpError(400, '非法的 Git 版本参数');
|
|
724
|
+
}
|
|
725
|
+
const target = side === 'old' ? `${rev}^` : rev;
|
|
726
|
+
const relPath = path.relative(getCurrentProjectPath() || process.cwd(), safe.safePath).replace(/\\/g, '/');
|
|
727
|
+
const result = await execGitCommand(['show', `${target}:${relPath}`], {
|
|
728
|
+
encoding: 'buffer', log: false, maxBuffer: 60 * 1024 * 1024,
|
|
729
|
+
});
|
|
730
|
+
content = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout || '');
|
|
731
|
+
} else {
|
|
732
|
+
const stat = await fs.stat(safe.safePath);
|
|
733
|
+
if (!stat.isFile()) throw new HttpError(400, '目标不是文件');
|
|
734
|
+
if (stat.size > 50 * 1024 * 1024) throw new HttpError(413, 'Office 文件过大(最大 50 MB)');
|
|
735
|
+
content = await fs.readFile(safe.safePath);
|
|
736
|
+
}
|
|
737
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
738
|
+
res.setHeader('Content-Length', content.length);
|
|
739
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
740
|
+
res.send(content);
|
|
741
|
+
}));
|
|
742
|
+
|
|
662
743
|
// ── 编辑器:写入文件内容 ────────────────────────────────────────
|
|
663
744
|
app.put('/api/editor/file', express.json(), async (req, res) => {
|
|
664
745
|
try {
|
|
@@ -849,4 +930,3 @@ export function registerFsRoutes({
|
|
|
849
930
|
}
|
|
850
931
|
});
|
|
851
932
|
}
|
|
852
|
-
|
|
@@ -16,6 +16,7 @@ import fs from 'fs/promises';
|
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import logger from '../../utils/logger.js'
|
|
18
18
|
import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
|
|
19
|
+
import { assertGitHash, assertGitPath } from '../../utils/gitArgs.js';
|
|
19
20
|
|
|
20
21
|
import { createDiffHelpers } from './diffUtils.js';
|
|
21
22
|
|
|
@@ -210,14 +211,21 @@ export function registerGitDiffRoutes({
|
|
|
210
211
|
});
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
|
|
214
|
+
// rev 的合法取值:':'(工作区) / 'HEAD' / commit hash —— 前端就这么传的
|
|
215
|
+
// (GitStatus.vue 的类型标注是 'HEAD' | ':')。它拼进 spec 后落在 git 的参数位,
|
|
216
|
+
// 以 - 开头会被当选项(git show 有 --output=,能写任意文件),所以白名单之外
|
|
217
|
+
// 只放行十六进制 hash。
|
|
218
|
+
const r = String(rev ?? '').trim();
|
|
219
|
+
const ALLOWED_SPECIAL_REVS = new Set([':', 'HEAD']);
|
|
220
|
+
const safeRev = ALLOWED_SPECIAL_REVS.has(r) ? r : assertGitHash(r, 'rev');
|
|
214
221
|
// git show/cat-file 的 rev:path 语法要求 path 必须是相对于仓库根的路径,
|
|
215
222
|
// 不接受绝对路径(safeFilePath 是绝对路径,直接拼 spec 会导致 git show 报
|
|
216
223
|
// "path exists on disk, but not in 'HEAD'" → 前端拿到 notFound:true)。
|
|
217
224
|
// 这里用 toGitRepoRelativePath 转成仓库根相对路径,并把 Windows 反斜杠
|
|
218
225
|
// 替换为正斜杠(git 内部统一用 /)。
|
|
219
|
-
|
|
220
|
-
const
|
|
226
|
+
// 转换结果仍要过一次路径校验:挡 .. 上跳与选项形态的输入。
|
|
227
|
+
const safeRelPath = assertGitPath(toGitRepoRelativePath(safeFilePath), '文件路径');
|
|
228
|
+
const spec = safeRev === ':' ? `:${safeRelPath}` : `${safeRev}:${safeRelPath}`;
|
|
221
229
|
|
|
222
230
|
let sizeBytes = 0;
|
|
223
231
|
try {
|
|
@@ -239,7 +247,7 @@ export function registerGitDiffRoutes({
|
|
|
239
247
|
const { stdout } = await execGitCommand(['show', spec], { log: false });
|
|
240
248
|
return res.json({ success: true, content: stdout ?? '' });
|
|
241
249
|
} catch (error) {
|
|
242
|
-
res.status(500).json({ success: false, error: error.message });
|
|
250
|
+
res.status(error?.statusCode || 500).json({ success: false, error: error.message });
|
|
243
251
|
}
|
|
244
252
|
}));
|
|
245
253
|
|