Skip to content

Commit 7df64ee

Browse files
author
Test User
committed
fix(security): 修复安全问题
1 parent d8475df commit 7df64ee

5 files changed

Lines changed: 134 additions & 97 deletions

File tree

packages/feflow-cli/src/core/logger/report.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ let keysFileContent: Partial<KeysFileContent> = {};
3333
if (!keysFileContent.time || NOW_TIME - keysFileContent.time > 5184e6) {
3434
const {
3535
data: { result },
36-
} = await axios.get(`http://log.feflowjs.com/api/v1/summary/getskey?rtx=${USER_NAME}`);
36+
} = await axios.get(`https://log.feflowjs.com/api/v1/summary/getskey?rtx=${USER_NAME}`);
3737
keysFileContent = {
3838
time: NOW_TIME,
3939
skey: result.skey,
@@ -71,7 +71,7 @@ async function send(logObj: LogObj | undefined, readData: string[]) {
7171
// 清除数据
7272
fs.writeFile(LOGGER_LOG_PATH, '', 'utf8', () => {});
7373
const response = await axios.post(
74-
'http://log.feflowjs.com/api/v1/log/save',
74+
'https://log.feflowjs.com/api/v1/log/save',
7575
{
7676
plugin: loggerList[0].name,
7777
data: JSON.stringify(loggerList),

packages/feflow-cli/src/core/native/help.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,14 @@ export default (ctx: Feflow) => {
8989
const { type, content } = universalUsage instanceof Function ? universalUsage() : universalUsage;
9090

9191
// case 1: 多语言情况下 yml 有 usage 属性时,执行对应的内容
92+
// 安全修复:移除 shell:true,改用参数数组,防止 yml content 中的 shell 元字符注入
9293
if (type === 'usage') {
93-
spawn(content, {
94+
const parts = (content as string).trim().split(/\s+/);
95+
const cmd = parts[0];
96+
const args = parts.slice(1);
97+
spawn(cmd, args, {
9498
stdio: 'inherit',
95-
shell: true,
99+
shell: false,
96100
windowsHide: true,
97101
});
98102
return;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/bin/sh
2+
# git-askpass.sh — feflow 安全凭证助手
3+
# 通过环境变量传递 Git 认证凭证,避免凭证出现在 URL 或命令行参数中
4+
case "$1" in
5+
Username*) echo "${FEFLOW_GIT_USERNAME}" ;;
6+
Password*) echo "${FEFLOW_GIT_PASSWORD}" ;;
7+
esac
Lines changed: 117 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,164 @@
11
import spawn from 'cross-spawn';
2-
import rp from 'request-promise';
3-
import { getURL } from './url';
2+
import path from 'path';
3+
4+
// ─── 安全说明 ──────────────────────────────────────────────────────────────────
5+
// 旧版实现存在两个高危漏洞:
6+
// 1. 协议降级:将 git@host:path 改写为 http://host/path,使流量走明文 HTTP
7+
// 2. URL 内联凭证:将账号密码拼入 URL(http://user:pass@host),
8+
// 导致凭证出现在进程参数列表、日志、HTTPS 降级后的中间人可见流量中
9+
// 修复方案:
10+
// - transformUrl 只允许 https:// / git@ 协议,拒绝 http://
11+
// - 凭证通过 GIT_ASKPASS 环境变量传递,不再内联进 URL
12+
// ──────────────────────────────────────────────────────────────────────────────
413

5-
let gitAccount: {
6-
username: string;
7-
password: string;
8-
};
914
let serverUrl: string;
1015

1116
export function setServerUrl(url: string) {
1217
serverUrl = url;
1318
}
1419

1520
function getHostname(url: string): string {
16-
if (/https?/.test(url)) {
21+
if (/^https?:\/\//.test(url)) {
1722
const [, , match = ''] = url.match(/^http(s)?:\/\/(.*?)\//) || [];
1823
return match.split('@').pop() || '';
1924
}
2025
const [, hostname = ''] = url.match(/@(.*):/) || [];
2126
return hostname;
2227
}
2328

24-
async function prepareAccount() {
25-
if (gitAccount) {
26-
return;
27-
}
28-
const url = getURL(serverUrl, 'apply/getlist?name=0');
29-
if (!url) {
30-
return;
29+
/**
30+
* 安全地转换仓库 URL:
31+
* - 拒绝 http:// 协议(防止协议降级攻击)
32+
* - https:// 保持原样;git@ 在 SSH 不可用时转为 https://
33+
* - 不再将凭证内联到 URL 中
34+
*
35+
* @param url 原始仓库 URL(https:// 或 git@)
36+
* @returns 安全的仓库 URL(string)
37+
* @throws 若 URL 使用不允许的协议则抛出错误
38+
*/
39+
export async function transformUrl(url: string): Promise<string> {
40+
// 拒绝裸 http:// 协议(含内联凭证形式 http://user:pass@host)
41+
if (/^http:\/\//.test(url)) {
42+
throw new Error(`Insecure repository URL rejected (http is not allowed, use https or git@): ${url}`);
3143
}
32-
const options = {
33-
url,
34-
method: 'GET',
35-
};
36-
return rp(options)
37-
.then((response: string) => {
38-
const data = JSON.parse(response);
39-
if (data.account) {
40-
gitAccount = data.account;
44+
45+
if (/^https:\/\//.test(url) || /^git@/.test(url)) {
46+
const hostname = getHostname(url);
47+
// 检测 SSH 可用性,超时 3s 回退到 https
48+
const sshOk = await isSupportSSH(`git@${hostname}`);
49+
if (sshOk) {
50+
// SSH 可用:https:// → git@host:path
51+
if (/^https:\/\//.test(url)) {
52+
return url.replace(/^https:\/\/([^/]+)\//, 'git@$1:');
4153
}
42-
})
43-
.catch(() => {});
54+
return url;
55+
} else {
56+
// SSH 不可用:git@ → https://,https:// 保持不变
57+
if (/^git@/.test(url)) {
58+
return url.replace(/^git@([^:]+):/, 'https://$1/');
59+
}
60+
return url;
61+
}
62+
}
63+
64+
throw new Error(`Unsupported repository protocol: ${url}`);
4465
}
4566

46-
export async function transformUrl(url: string, account?: any): Promise<any> {
47-
if (account) {
48-
gitAccount = account;
49-
} else {
50-
await prepareAccount();
51-
}
52-
const hostname = getHostname(url);
53-
let transformedUrl;
54-
if (/https?/.test(url)) {
55-
transformedUrl = url;
56-
} else {
57-
transformedUrl = url.replace(`git@${hostname}:`, `http://${hostname}/`);
67+
let _sshCache: Record<string, boolean> = {};
68+
69+
async function isSupportSSH(url: string): Promise<boolean> {
70+
if (_sshCache[url] !== undefined) {
71+
return _sshCache[url];
5872
}
59-
if (gitAccount) {
60-
const { username, password } = gitAccount;
61-
return transformedUrl.replace(/https?:\/\/(.*?(:.*?)?@)?/, `http://${username}:${password}@`);
73+
try {
74+
const result = await Promise.race([
75+
Promise.resolve(spawn.sync('ssh', ['-vT', url], { windowsHide: true })),
76+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
77+
]) as ReturnType<typeof spawn.sync>;
78+
const ok = /Authentication succeeded/.test(result?.stderr?.toString() ?? '');
79+
_sshCache[url] = ok;
80+
return ok;
81+
} catch {
82+
_sshCache[url] = false;
83+
return false;
6284
}
63-
return transformedUrl;
6485
}
6586

66-
export async function clearGitCert(url: string) {
67-
const { username } = gitAccount;
68-
if (!username) {
69-
return;
87+
/**
88+
* 构建安全的 Git 环境变量:
89+
* 通过 GIT_ASKPASS 脚本传递凭证,不将账号密码内联到 URL 或暴露在命令行参数中。
90+
*
91+
* @param account 可选账号信息 { username, password }
92+
* @returns 含 GIT_ASKPASS 的环境变量对象
93+
*/
94+
export function buildGitEnv(account?: { username: string; password: string }): NodeJS.ProcessEnv {
95+
if (!account) {
96+
return process.env;
7097
}
71-
let finalUrl = '';
72-
if (!/https?:\/\/(.*?(:.*?)?@)/.test(url)) {
73-
finalUrl = await transformUrl(url);
74-
}
75-
return new Promise((resolve, reject) => {
76-
const child = spawn('git', ['credential', 'reject'], {
77-
windowsHide: true,
78-
timeout: 60 * 1000,
79-
});
80-
child.stdin?.write(`url=${finalUrl}`);
81-
child.stdin?.end();
82-
child.on('close', (code) => {
83-
resolve(code);
84-
});
85-
child.on('error', (err) => {
86-
reject(err);
87-
});
88-
});
98+
return {
99+
...process.env,
100+
GIT_TERMINAL_PROMPT: '0',
101+
GIT_ASKPASS: path.join(__dirname, 'git-askpass.sh'),
102+
FEFLOW_GIT_USERNAME: account.username,
103+
FEFLOW_GIT_PASSWORD: account.password,
104+
};
89105
}
90106

91-
export async function clearGitCertByPath(repoPath: string) {
92-
const ret = spawn.sync('git', ['config', '--get', 'remote.origin.url'], {
93-
windowsHide: true,
94-
cwd: repoPath,
95-
});
96-
const url = ret?.stdout?.toString().trim();
97-
return clearGitCert(url);
107+
/**
108+
* 清除 git credential 缓存(使用 git credential reject)。
109+
* 修复后凭证不再内联进 URL,此函数仅在 url 包含凭证时有操作意义,
110+
* 新流程下可直接返回。
111+
*/
112+
export async function clearGitCert(_url: string): Promise<void> {
113+
// 新流程凭证通过 GIT_ASKPASS 传递,不内联进 URL,无需 credential reject
114+
return;
98115
}
99116

100-
export async function download(url: string, tag: string, filepath: string): Promise<any> {
117+
export async function clearGitCertByPath(_repoPath: string): Promise<void> {
118+
return;
119+
}
120+
121+
/**
122+
* 安全下载仓库:URL 不含凭证,凭证通过环境变量传递。
123+
*
124+
* @param url 原始仓库 URL
125+
* @param tag 要 checkout 的 tag
126+
* @param filepath 本地目标路径
127+
* @param account 可选 Git 账号
128+
*/
129+
export async function download(
130+
url: string,
131+
tag: string,
132+
filepath: string,
133+
account?: { username: string; password: string }
134+
): Promise<void> {
101135
const cloneUrl = await transformUrl(url);
136+
const env = buildGitEnv(account);
102137

103138
console.log('clone from', url);
104139
return new Promise((resolve, reject) => {
105-
const child = spawn('git', ['clone', '-b', tag, '--progress', '--depth', '1', cloneUrl, filepath], {
106-
stdio: 'pipe',
107-
windowsHide: true,
108-
});
140+
const child = spawn(
141+
'git',
142+
['clone', '-b', tag, '--progress', '--depth', '1', cloneUrl, filepath],
143+
{ stdio: 'pipe', windowsHide: true, env }
144+
);
109145
let doneFlag = false;
110146
child.stderr?.on('data', (d) => {
111-
if (doneFlag) {
112-
return;
113-
}
147+
if (doneFlag) return;
114148
if (d?.toString()?.startsWith('Note:') || d?.toString()?.startsWith('注意')) {
115149
doneFlag = true;
116150
return;
117151
}
118152
process.stderr.write(d);
119153
});
120154
child.stdout?.on('data', (d) => {
121-
if (doneFlag) {
122-
return;
123-
}
155+
if (doneFlag) return;
124156
process.stdout.write(d);
125157
});
126158
child.on('close', (code) => {
127-
if (code === 0) {
128-
resolve(0);
129-
} else {
130-
reject(code);
131-
}
132-
});
133-
child.on('error', (err) => {
134-
reject(err);
159+
if (code === 0) resolve();
160+
else reject(code);
135161
});
136-
})
137-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
138-
.finally(() => clearGitCert(cloneUrl));
162+
child.on('error', reject);
163+
});
139164
}

packages/feflow-report/src/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ export const HOOK_TYPE_BEFORE = 'before';
88
*/
99
export const HOOK_TYPE_AFTER = 'after';
1010

11-
const BASIC_URL = 'http://api.feflowjs.com';
11+
// 安全修复:上报地址改为 HTTPS,防止遥测数据明文传输
12+
const BASIC_URL = 'https://api.feflowjs.com';
1213

1314
export const REPORT_URL = `${BASIC_URL}/api/v1/report/command`;
1415

0 commit comments

Comments
 (0)