@zicolasjac-ai/remote-readonly-ssh 1.0.3 → 1.1.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/README.md +13 -1
- package/bin/rrs.js +30 -12
- package/config.example.json +9 -0
- package/package.json +2 -2
- package/src/config.js +11 -4
- package/src/jumpshell.js +87 -34
- package/src/whitelist.js +6 -3
package/README.md
CHANGED
|
@@ -18,6 +18,8 @@ npm install -g @zicolasjac-ai/remote-readonly-ssh
|
|
|
18
18
|
|
|
19
19
|
要求 Node.js >= 18,系统已装 OpenSSH 客户端(Windows 10+ / Linux / macOS 自带)。
|
|
20
20
|
|
|
21
|
+
> 从旧包名 `remote-readonly-ssh`(v1.0.0)升级的用户,请先 `npm uninstall -g remote-readonly-ssh` 再安装,否则会报 `EEXIST: file already exists`。
|
|
22
|
+
|
|
21
23
|
## 快速开始
|
|
22
24
|
|
|
23
25
|
```bash
|
|
@@ -30,9 +32,11 @@ rrs sys-info
|
|
|
30
32
|
## 用法
|
|
31
33
|
|
|
32
34
|
```
|
|
33
|
-
rrs <tool> [arg] [--host <ip>] [--config <file>]
|
|
35
|
+
rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
34
36
|
```
|
|
35
37
|
|
|
38
|
+
`--json` 以 JSON 输出结果(`{"ok":true,"tool":...,"host":...,"output":...}`),便于脚本/AI 调用;`-v` / `--version` 查看版本。
|
|
39
|
+
|
|
36
40
|
| 工具 | 说明 |
|
|
37
41
|
|------|------|
|
|
38
42
|
| `init` | 生成配置模板 `~/.rrs/config.json` |
|
|
@@ -52,6 +56,8 @@ rrs <tool> [arg] [--host <ip>] [--config <file>]
|
|
|
52
56
|
| 键 | 说明 |
|
|
53
57
|
|-----|------|
|
|
54
58
|
| `jump_server` | 堡垒机地址 / 端口 / 用户名 |
|
|
59
|
+
| `ssh_options` | 附加 ssh 参数(默认 `StrictHostKeyChecking=no`、兼容旧堡垒机的 ssh-rsa 算法开关,可按需调整) |
|
|
60
|
+
| `audit` | `enabled`(默认 true,关闭则不留痕)与 `include_output`(默认 false,true 时把命令输出摘要写入审计日志) |
|
|
55
61
|
| `private_key_path` | SSH 私钥路径(Windows 建议放 `~/.ssh/locked/` 收紧 ACL;Linux/Mac 用 `chmod 600`) |
|
|
56
62
|
| `target_hosts` | 允许访问的目标主机白名单 `{ip: 堡垒机搜索关键字}` |
|
|
57
63
|
| `allowed_version_bins` | 允许执行 `-v/-V` 的二进制白名单 |
|
|
@@ -61,6 +67,12 @@ rrs <tool> [arg] [--host <ip>] [--config <file>]
|
|
|
61
67
|
|
|
62
68
|
审计日志:`~/.rrs/audit.log`
|
|
63
69
|
|
|
70
|
+
## 开发
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npm test # 运行单元测试(node --test)
|
|
74
|
+
```
|
|
75
|
+
|
|
64
76
|
## 安全模型
|
|
65
77
|
|
|
66
78
|
- **双层校验**:每条命令必须以允许前缀开头,且不得含禁止模式(rm/mv/vi/systemctl/sudo/重定向到文件/shadow/ssh 密钥等),否则一律拒绝。
|
package/bin/rrs.js
CHANGED
|
@@ -24,20 +24,23 @@ function printUsage() {
|
|
|
24
24
|
["exec-only <cmd>", "通用只读命令(前缀白名单+禁止模式)"],
|
|
25
25
|
];
|
|
26
26
|
console.log("rrs — 只读 SSH 运维 CLI(白名单强制,零写能力)\n");
|
|
27
|
-
console.log("用法: rrs <工具> [参数] [--host <ip>] [--config <file>]\n");
|
|
27
|
+
console.log("用法: rrs <工具> [参数] [--host <ip>] [--config <file>] [--json]\n");
|
|
28
|
+
console.log("选项: --json 以 JSON 输出结果(便于脚本/AI 调用); -v/--version 查看版本\n");
|
|
28
29
|
for (const [k, v] of cmds) {
|
|
29
30
|
console.log(` ${k.padEnd(22)} ${v}`);
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function parseArgs(argv) {
|
|
34
|
-
const opts = { tool: null, arg: null, host: null, config: null };
|
|
35
|
+
const opts = { tool: null, arg: null, host: null, config: null, version: false, json: false };
|
|
35
36
|
const rest = [];
|
|
36
37
|
for (let i = 0; i < argv.length; i++) {
|
|
37
38
|
const a = argv[i];
|
|
38
39
|
if (a === "--host") opts.host = argv[++i];
|
|
39
40
|
else if (a === "--config") opts.config = argv[++i];
|
|
40
41
|
else if (a === "-h" || a === "--help") { opts.tool = "help"; }
|
|
42
|
+
else if (a === "-v" || a === "--version") opts.version = true;
|
|
43
|
+
else if (a === "--json") opts.json = true;
|
|
41
44
|
else rest.push(a);
|
|
42
45
|
}
|
|
43
46
|
opts.tool = opts.tool || rest[0] || "help";
|
|
@@ -102,20 +105,30 @@ function listTools(conf) {
|
|
|
102
105
|
console.log(`\n允许主机: ${Object.keys(conf.target_hosts || {}).join(", ")}`);
|
|
103
106
|
console.log(`允许查版本: ${(conf.allowed_version_bins || []).join(", ")}`);
|
|
104
107
|
console.log(`允许读路径前缀: ${(conf.allowed_path_prefixes || []).join(", ")}`);
|
|
105
|
-
console.log(`配置文件: ${process.env.
|
|
108
|
+
console.log(`配置文件: ${process.env.RRS_CONFIG || path.join(configDir(), "config.json")}`);
|
|
106
109
|
console.log(`审计日志: ${path.join(configDir(), "audit.log")}`);
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
function emitResult(opts, data) {
|
|
113
|
+
if (opts.json) console.log(JSON.stringify(data));
|
|
114
|
+
else if (data.error) console.error(`[${data.kind || "错误"}] ${data.error}`);
|
|
115
|
+
else console.log(data.output);
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
async function main() {
|
|
110
119
|
const opts = parseArgs(process.argv.slice(2));
|
|
111
|
-
if (opts.
|
|
120
|
+
if (opts.version) {
|
|
121
|
+
console.log(require(path.join(PACKAGE_ROOT, "package.json")).version);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (opts.tool === "help") { printUsage(); return; }
|
|
112
125
|
if (opts.tool === "init") { initConfig(); return; }
|
|
113
126
|
|
|
114
127
|
let conf;
|
|
115
128
|
try {
|
|
116
129
|
conf = loadConf(opts.config);
|
|
117
130
|
} catch (e) {
|
|
118
|
-
|
|
131
|
+
emitResult(opts, { ok: false, error: e.message, kind: "错误" });
|
|
119
132
|
process.exit(e.exitCode || 1);
|
|
120
133
|
}
|
|
121
134
|
|
|
@@ -126,8 +139,8 @@ async function main() {
|
|
|
126
139
|
host = validateHost(opts.host || Object.keys(conf.target_hosts || {})[0], conf);
|
|
127
140
|
cmds = buildCmds(opts.tool, opts.arg, conf);
|
|
128
141
|
} catch (e) {
|
|
129
|
-
audit(
|
|
130
|
-
|
|
142
|
+
audit(conf, opts.tool, opts.host || "", [], false, e.message);
|
|
143
|
+
emitResult(opts, { ok: false, tool: opts.tool, host: opts.host || "", error: e.message, kind: "拒绝" });
|
|
131
144
|
process.exit(2);
|
|
132
145
|
}
|
|
133
146
|
|
|
@@ -136,15 +149,20 @@ async function main() {
|
|
|
136
149
|
shell.connect();
|
|
137
150
|
await shell.login();
|
|
138
151
|
const out = await shell.runCmds(cmds);
|
|
139
|
-
|
|
140
|
-
|
|
152
|
+
const cleaned = cleanAnsi(out);
|
|
153
|
+
emitResult(opts, { ok: true, tool: opts.tool, host, output: cleaned });
|
|
154
|
+
audit(conf, opts.tool, host, cmds, true, undefined, cleaned);
|
|
141
155
|
} catch (e) {
|
|
142
|
-
audit(
|
|
143
|
-
|
|
156
|
+
audit(conf, opts.tool, host, cmds, false, e.message);
|
|
157
|
+
emitResult(opts, { ok: false, tool: opts.tool, host, error: e.message, kind: "错误" });
|
|
144
158
|
process.exit(1);
|
|
145
159
|
} finally {
|
|
146
160
|
shell.close();
|
|
147
161
|
}
|
|
148
162
|
}
|
|
149
163
|
|
|
150
|
-
main
|
|
164
|
+
if (require.main === module) {
|
|
165
|
+
main();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { parseArgs, buildCmds, printUsage };
|
package/config.example.json
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
"port": 2222,
|
|
5
5
|
"username": "YOUR_USERNAME"
|
|
6
6
|
},
|
|
7
|
+
"ssh_options": [
|
|
8
|
+
"-o", "StrictHostKeyChecking=no",
|
|
9
|
+
"-o", "HostKeyAlgorithms=+ssh-rsa",
|
|
10
|
+
"-o", "PubkeyAcceptedAlgorithms=+ssh-rsa"
|
|
11
|
+
],
|
|
12
|
+
"audit": {
|
|
13
|
+
"enabled": true,
|
|
14
|
+
"include_output": false
|
|
15
|
+
},
|
|
7
16
|
"private_key_path": "C:\\Users\\YOURNAME\\.ssh\\id_rsa",
|
|
8
17
|
"target_hosts": {
|
|
9
18
|
"10.0.0.1": "10.0.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zicolasjac-ai/remote-readonly-ssh",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "只读 SSH 运维 CLI:基于白名单强制校验,通过堡垒机对服务器执行只读查询,设计上零写权限。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,6 @@
|
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"test": "node --test
|
|
33
|
+
"test": "node --test"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/src/config.js
CHANGED
|
@@ -21,7 +21,7 @@ function loadConf(confPath) {
|
|
|
21
21
|
if (!fs.existsSync(resolved)) {
|
|
22
22
|
const err = new Error(
|
|
23
23
|
`找不到配置文件 ${resolved}\n` +
|
|
24
|
-
`请先运行
|
|
24
|
+
`请先运行 rrs init 生成模板,再填入您的跳板机/资产/密钥信息。`
|
|
25
25
|
);
|
|
26
26
|
err.exitCode = 3;
|
|
27
27
|
throw err;
|
|
@@ -70,9 +70,11 @@ function describeCmd(tool, cmd) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
function audit(
|
|
73
|
+
function audit(conf, tool, host, cmds, ok, note, output, confDir) {
|
|
74
74
|
try {
|
|
75
|
-
|
|
75
|
+
if (conf.audit && conf.audit.enabled === false) return;
|
|
76
|
+
const dir = confDir || configDir();
|
|
77
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
78
|
const d = new Date();
|
|
77
79
|
const pad = (n) => String(n).padStart(2, "0");
|
|
78
80
|
const local = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
@@ -89,8 +91,13 @@ function audit(confDir, tool, host, cmds, ok, note) {
|
|
|
89
91
|
lines.push(` ${i + 1}. ${c}`);
|
|
90
92
|
lines.push(` 用途: ${describeCmd(tool, c)}`);
|
|
91
93
|
});
|
|
94
|
+
if (ok && output && conf.audit && conf.audit.include_output) {
|
|
95
|
+
const out = String(output).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trim();
|
|
96
|
+
lines.push(` 输出摘要:`);
|
|
97
|
+
lines.push(out.slice(0, 4000));
|
|
98
|
+
}
|
|
92
99
|
lines.push(bar, "");
|
|
93
|
-
fs.appendFileSync(path.join(
|
|
100
|
+
fs.appendFileSync(path.join(dir, "audit.log"), lines.join("\n"), "utf8");
|
|
94
101
|
} catch {
|
|
95
102
|
// 审计失败不阻断主流程
|
|
96
103
|
}
|
package/src/jumpshell.js
CHANGED
|
@@ -4,6 +4,25 @@ const { spawn } = require("child_process");
|
|
|
4
4
|
|
|
5
5
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
6
|
|
|
7
|
+
// 行尾 shell prompt($/#/% 可带 ~ 家目录前缀,如 "user@host:~$ ")
|
|
8
|
+
// 注意: 不能用 ">",会与 JumpServer 菜单行 "Opt>" 冲突
|
|
9
|
+
const PROMPT_RE = /(?:^|\r?\n)[^\r\n]*~?[$#%]\s*$/;
|
|
10
|
+
|
|
11
|
+
const ERR_PATTERNS = [
|
|
12
|
+
/permission denied/i,
|
|
13
|
+
/connection refused/i,
|
|
14
|
+
/connection timed out/i,
|
|
15
|
+
/host key verification failed/i,
|
|
16
|
+
/could not resolve hostname/i,
|
|
17
|
+
/no route to host/i,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const DEFAULT_SSH_OPTIONS = [
|
|
21
|
+
"-o", "StrictHostKeyChecking=no",
|
|
22
|
+
"-o", "HostKeyAlgorithms=+ssh-rsa",
|
|
23
|
+
"-o", "PubkeyAcceptedAlgorithms=+ssh-rsa",
|
|
24
|
+
];
|
|
25
|
+
|
|
7
26
|
/**
|
|
8
27
|
* 通过系统 ssh 驱动 JumpServer 堡垒机菜单,登录目标资产并执行命令。
|
|
9
28
|
* 仅负责"连接与收发",命令校验在调用方(whitelist.js)完成。
|
|
@@ -14,27 +33,25 @@ class JumpShell {
|
|
|
14
33
|
this.host = host;
|
|
15
34
|
this.conf = conf;
|
|
16
35
|
this.proc = null;
|
|
17
|
-
this.
|
|
18
|
-
this.
|
|
36
|
+
this.out = "";
|
|
37
|
+
this.err = "";
|
|
38
|
+
this.terminated = false;
|
|
19
39
|
}
|
|
20
40
|
|
|
21
41
|
connect() {
|
|
22
42
|
const js = this.conf.jump_server;
|
|
43
|
+
const opts = this.conf.ssh_options && this.conf.ssh_options.length
|
|
44
|
+
? this.conf.ssh_options
|
|
45
|
+
: DEFAULT_SSH_OPTIONS;
|
|
23
46
|
const args = [
|
|
24
47
|
"-i", this.conf.private_key_path,
|
|
25
48
|
"-p", String(js.port),
|
|
26
|
-
|
|
27
|
-
"-o", "HostKeyAlgorithms=+ssh-rsa",
|
|
28
|
-
"-o", "PubkeyAcceptedAlgorithms=+ssh-rsa",
|
|
49
|
+
...opts,
|
|
29
50
|
"-tt", `${js.username}@${js.host}`,
|
|
30
51
|
];
|
|
31
52
|
this.proc = spawn("ssh", args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
32
|
-
this.proc.stdout.on("data", (d) => {
|
|
33
|
-
|
|
34
|
-
});
|
|
35
|
-
this.proc.stderr.on("data", (d) => {
|
|
36
|
-
this.buf += d.toString("utf8");
|
|
37
|
-
});
|
|
53
|
+
this.proc.stdout.on("data", (d) => { this.out += d.toString("utf8"); });
|
|
54
|
+
this.proc.stderr.on("data", (d) => { this.err += d.toString("utf8"); });
|
|
38
55
|
}
|
|
39
56
|
|
|
40
57
|
send(s) {
|
|
@@ -43,51 +60,87 @@ class JumpShell {
|
|
|
43
60
|
}
|
|
44
61
|
}
|
|
45
62
|
|
|
46
|
-
|
|
47
|
-
const
|
|
63
|
+
checkErr() {
|
|
64
|
+
for (const p of ERR_PATTERNS) {
|
|
65
|
+
if (p.test(this.err)) {
|
|
66
|
+
const last = this.err.trim().split("\n").pop();
|
|
67
|
+
throw new Error(`SSH 错误: ${last}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
isPrompt() {
|
|
73
|
+
return PROMPT_RE.test(this.out);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async waitFor(marker, deadline) {
|
|
77
|
+
while (Date.now() < deadline) {
|
|
78
|
+
if (this.terminated) throw new Error("SSH 连接已断开");
|
|
79
|
+
this.checkErr();
|
|
80
|
+
if (this.out.includes(marker)) return true;
|
|
81
|
+
await sleep(150);
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async waitPrompt(deadline) {
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
if (this.terminated) throw new Error("SSH 连接已断开");
|
|
89
|
+
this.checkErr();
|
|
90
|
+
if (this.isPrompt()) return true;
|
|
91
|
+
await sleep(150);
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async waitPromptAfter(snapshot, deadline) {
|
|
48
97
|
while (Date.now() < deadline) {
|
|
49
|
-
if (this.
|
|
98
|
+
if (this.terminated) throw new Error("SSH 连接已断开");
|
|
99
|
+
this.checkErr();
|
|
100
|
+
if (this.out.length > snapshot && this.isPrompt()) return true;
|
|
50
101
|
await sleep(150);
|
|
51
102
|
}
|
|
52
103
|
return false;
|
|
53
104
|
}
|
|
54
105
|
|
|
55
|
-
async login() {
|
|
56
|
-
|
|
106
|
+
async login(timeoutSecs = 30) {
|
|
107
|
+
const deadline = Date.now() + timeoutSecs * 1000;
|
|
108
|
+
if (!(await this.waitFor("Opt>", deadline))) throw new Error("跳板机菜单未出现");
|
|
57
109
|
await sleep(1000);
|
|
58
110
|
this.send("");
|
|
59
111
|
await sleep(2000);
|
|
60
112
|
this.send(this.searchKeyword);
|
|
61
|
-
if (!(await this.waitFor(this.host,
|
|
62
|
-
if (!(await this.
|
|
113
|
+
if (!(await this.waitFor(this.host, deadline))) throw new Error(`未找到目标资产 ${this.host}`);
|
|
114
|
+
if (!(await this.waitPrompt(deadline))) throw new Error("未能进入 shell");
|
|
63
115
|
await sleep(500);
|
|
64
116
|
}
|
|
65
117
|
|
|
66
|
-
async runCmds(cmds,
|
|
67
|
-
const
|
|
118
|
+
async runCmds(cmds, timeoutSecs = 30) {
|
|
119
|
+
const deadline = Date.now() + timeoutSecs * 1000;
|
|
120
|
+
const start = this.out.length;
|
|
68
121
|
for (const c of cmds) {
|
|
122
|
+
if (Date.now() >= deadline) throw new Error("命令执行总超时");
|
|
123
|
+
const snapshot = this.out.length;
|
|
69
124
|
this.send(c);
|
|
70
|
-
await
|
|
125
|
+
if (!(await this.waitPromptAfter(snapshot, deadline))) throw new Error(`等待命令执行完成超时: ${c}`);
|
|
71
126
|
}
|
|
72
|
-
return this.
|
|
127
|
+
return this.out.slice(start);
|
|
73
128
|
}
|
|
74
129
|
|
|
75
130
|
close() {
|
|
76
|
-
this.
|
|
77
|
-
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
try { this.proc.kill(); } catch {}
|
|
83
|
-
}, 1000);
|
|
84
|
-
}, 1000);
|
|
85
|
-
} catch {}
|
|
131
|
+
this.terminated = true;
|
|
132
|
+
try { this.send("exit"); } catch {}
|
|
133
|
+
try { if (this.proc && this.proc.stdin) this.proc.stdin.end(); } catch {}
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
try { if (this.proc) this.proc.kill(); } catch {}
|
|
136
|
+
}, 1000);
|
|
86
137
|
}
|
|
87
138
|
}
|
|
88
139
|
|
|
89
140
|
function cleanAnsi(t) {
|
|
90
|
-
return t
|
|
141
|
+
return t
|
|
142
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "") // OSC 序列(终端标题等)
|
|
143
|
+
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); // CSI 序列(颜色/光标)
|
|
91
144
|
}
|
|
92
145
|
|
|
93
|
-
module.exports = { JumpShell, cleanAnsi, sleep };
|
|
146
|
+
module.exports = { JumpShell, cleanAnsi, sleep, PROMPT_RE };
|
package/src/whitelist.js
CHANGED
|
@@ -4,7 +4,7 @@ const path = require("path");
|
|
|
4
4
|
|
|
5
5
|
const FORBIDDEN_PATTERNS = [
|
|
6
6
|
/>\s*[^|&]/, // output redirection to file (2>&1 stream redirect is allowed)
|
|
7
|
-
/\b(rm|mv|cp|tar|install|yum|dnf|apt-get|vi|vim|nano|mkdir|touch|chmod|chown|useradd|userdel|passwd|sudo|su|reboot|shutdown|systemctl|kill|pkill|wget|curl|nc)\b/,
|
|
7
|
+
/\b(rm|mv|cp|tar|install|yum|dnf|apt-get|vi|vim|nano|mkdir|touch|chmod|chown|useradd|userdel|passwd|sudo|su|reboot|shutdown|systemctl|kill|pkill|wget|curl|nc|tee|xargs|dd|bash|python|perl|ruby|awk|scp|sftp|rsync|git)\b/,
|
|
8
8
|
/nginx\s+-s\b/, // nginx signal controls (stop/quit/reload/reopen) are mutating
|
|
9
9
|
/sed\s+-i\b/, // sed in-place edit is mutating (defense in depth)
|
|
10
10
|
/-exec\b|-execdir\b|-ok\b|-okdir\b/, // find exec actions run arbitrary commands
|
|
@@ -46,10 +46,12 @@ function validateCmd(cmd, conf) {
|
|
|
46
46
|
return c;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
const PATH_RE = /^[A-Za-z0-9._\-/:*]+$/;
|
|
50
|
+
|
|
49
51
|
function validatePath(pathArg, conf) {
|
|
50
52
|
const raw = String(pathArg).trim();
|
|
51
53
|
if (!raw.startsWith("/")) throw new Error(`拒绝: 仅允许绝对路径 [${raw}]`);
|
|
52
|
-
if (
|
|
54
|
+
if (!PATH_RE.test(raw)) throw new Error(`拒绝: 路径含非法字符 [${raw}]`);
|
|
53
55
|
// normalize resolves any . or .. segments BEFORE whitelist checks
|
|
54
56
|
const p = path.posix.normalize(raw);
|
|
55
57
|
const prefixes = conf.allowed_path_prefixes || [];
|
|
@@ -71,7 +73,8 @@ function validateHost(host, conf) {
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
function validateArg(arg, what) {
|
|
74
|
-
if (!arg
|
|
76
|
+
if (!arg) throw new Error(`拒绝: 缺少 ${what} 参数`);
|
|
77
|
+
if (!ARG_RE.test(arg)) {
|
|
75
78
|
throw new Error(`拒绝: ${what} 含非法字符 [${arg}]`);
|
|
76
79
|
}
|
|
77
80
|
return arg;
|