From 332411c5bbf4e9028b91139634f5648336253842 Mon Sep 17 00:00:00 2001 From: ericeyang Date: Tue, 21 Apr 2026 16:09:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E4=BF=AE=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/feflow-cli/src/core/logger/report.ts | 4 +- packages/feflow-cli/src/core/native/help.ts | 8 +- packages/feflow-cli/src/shared/git-askpass.sh | 7 + packages/feflow-cli/src/shared/git.ts | 209 ++++++++++-------- packages/feflow-report/src/constants.ts | 3 +- 5 files changed, 134 insertions(+), 97 deletions(-) create mode 100644 packages/feflow-cli/src/shared/git-askpass.sh diff --git a/packages/feflow-cli/src/core/logger/report.ts b/packages/feflow-cli/src/core/logger/report.ts index 4500eab3..7663414e 100644 --- a/packages/feflow-cli/src/core/logger/report.ts +++ b/packages/feflow-cli/src/core/logger/report.ts @@ -33,7 +33,7 @@ let keysFileContent: Partial = {}; if (!keysFileContent.time || NOW_TIME - keysFileContent.time > 5184e6) { const { data: { result }, - } = await axios.get(`http://log.feflowjs.com/api/v1/summary/getskey?rtx=${USER_NAME}`); + } = await axios.get(`https://log.feflowjs.com/api/v1/summary/getskey?rtx=${USER_NAME}`); keysFileContent = { time: NOW_TIME, skey: result.skey, @@ -71,7 +71,7 @@ async function send(logObj: LogObj | undefined, readData: string[]) { // 清除数据 fs.writeFile(LOGGER_LOG_PATH, '', 'utf8', () => {}); const response = await axios.post( - 'http://log.feflowjs.com/api/v1/log/save', + 'https://log.feflowjs.com/api/v1/log/save', { plugin: loggerList[0].name, data: JSON.stringify(loggerList), diff --git a/packages/feflow-cli/src/core/native/help.ts b/packages/feflow-cli/src/core/native/help.ts index 69f9d5df..424b3ac1 100644 --- a/packages/feflow-cli/src/core/native/help.ts +++ b/packages/feflow-cli/src/core/native/help.ts @@ -89,10 +89,14 @@ export default (ctx: Feflow) => { const { type, content } = universalUsage instanceof Function ? universalUsage() : universalUsage; // case 1: 多语言情况下 yml 有 usage 属性时,执行对应的内容 + // 安全修复:移除 shell:true,改用参数数组,防止 yml content 中的 shell 元字符注入 if (type === 'usage') { - spawn(content, { + const parts = (content as string).trim().split(/\s+/); + const cmd = parts[0]; + const args = parts.slice(1); + spawn(cmd, args, { stdio: 'inherit', - shell: true, + shell: false, windowsHide: true, }); return; diff --git a/packages/feflow-cli/src/shared/git-askpass.sh b/packages/feflow-cli/src/shared/git-askpass.sh new file mode 100644 index 00000000..79fe4901 --- /dev/null +++ b/packages/feflow-cli/src/shared/git-askpass.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# git-askpass.sh — feflow 安全凭证助手 +# 通过环境变量传递 Git 认证凭证,避免凭证出现在 URL 或命令行参数中 +case "$1" in + Username*) echo "${FEFLOW_GIT_USERNAME}" ;; + Password*) echo "${FEFLOW_GIT_PASSWORD}" ;; +esac diff --git a/packages/feflow-cli/src/shared/git.ts b/packages/feflow-cli/src/shared/git.ts index 784cd231..77d147dc 100644 --- a/packages/feflow-cli/src/shared/git.ts +++ b/packages/feflow-cli/src/shared/git.ts @@ -1,11 +1,16 @@ import spawn from 'cross-spawn'; -import rp from 'request-promise'; -import { getURL } from './url'; +import path from 'path'; + +// ─── 安全说明 ────────────────────────────────────────────────────────────────── +// 旧版实现存在两个高危漏洞: +// 1. 协议降级:将 git@host:path 改写为 http://host/path,使流量走明文 HTTP +// 2. URL 内联凭证:将账号密码拼入 URL(http://user:pass@host), +// 导致凭证出现在进程参数列表、日志、HTTPS 降级后的中间人可见流量中 +// 修复方案: +// - transformUrl 只允许 https:// / git@ 协议,拒绝 http:// +// - 凭证通过 GIT_ASKPASS 环境变量传递,不再内联进 URL +// ────────────────────────────────────────────────────────────────────────────── -let gitAccount: { - username: string; - password: string; -}; let serverUrl: string; export function setServerUrl(url: string) { @@ -13,7 +18,7 @@ export function setServerUrl(url: string) { } function getHostname(url: string): string { - if (/https?/.test(url)) { + if (/^https?:\/\//.test(url)) { const [, , match = ''] = url.match(/^http(s)?:\/\/(.*?)\//) || []; return match.split('@').pop() || ''; } @@ -21,96 +26,125 @@ function getHostname(url: string): string { return hostname; } -async function prepareAccount() { - if (gitAccount) { - return; - } - const url = getURL(serverUrl, 'apply/getlist?name=0'); - if (!url) { - return; +/** + * 安全地转换仓库 URL: + * - 拒绝 http:// 协议(防止协议降级攻击) + * - https:// 保持原样;git@ 在 SSH 不可用时转为 https:// + * - 不再将凭证内联到 URL 中 + * + * @param url 原始仓库 URL(https:// 或 git@) + * @returns 安全的仓库 URL(string) + * @throws 若 URL 使用不允许的协议则抛出错误 + */ +export async function transformUrl(url: string): Promise { + // 拒绝裸 http:// 协议(含内联凭证形式 http://user:pass@host) + if (/^http:\/\//.test(url)) { + throw new Error(`Insecure repository URL rejected (http is not allowed, use https or git@): ${url}`); } - const options = { - url, - method: 'GET', - }; - return rp(options) - .then((response: string) => { - const data = JSON.parse(response); - if (data.account) { - gitAccount = data.account; + + if (/^https:\/\//.test(url) || /^git@/.test(url)) { + const hostname = getHostname(url); + // 检测 SSH 可用性,超时 3s 回退到 https + const sshOk = await isSupportSSH(`git@${hostname}`); + if (sshOk) { + // SSH 可用:https:// → git@host:path + if (/^https:\/\//.test(url)) { + return url.replace(/^https:\/\/([^/]+)\//, 'git@$1:'); } - }) - .catch(() => {}); + return url; + } else { + // SSH 不可用:git@ → https://,https:// 保持不变 + if (/^git@/.test(url)) { + return url.replace(/^git@([^:]+):/, 'https://$1/'); + } + return url; + } + } + + throw new Error(`Unsupported repository protocol: ${url}`); } -export async function transformUrl(url: string, account?: any): Promise { - if (account) { - gitAccount = account; - } else { - await prepareAccount(); - } - const hostname = getHostname(url); - let transformedUrl; - if (/https?/.test(url)) { - transformedUrl = url; - } else { - transformedUrl = url.replace(`git@${hostname}:`, `http://${hostname}/`); +let _sshCache: Record = {}; + +async function isSupportSSH(url: string): Promise { + if (_sshCache[url] !== undefined) { + return _sshCache[url]; } - if (gitAccount) { - const { username, password } = gitAccount; - return transformedUrl.replace(/https?:\/\/(.*?(:.*?)?@)?/, `http://${username}:${password}@`); + try { + const result = await Promise.race([ + Promise.resolve(spawn.sync('ssh', ['-vT', url], { windowsHide: true })), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), + ]) as ReturnType; + const ok = /Authentication succeeded/.test(result?.stderr?.toString() ?? ''); + _sshCache[url] = ok; + return ok; + } catch { + _sshCache[url] = false; + return false; } - return transformedUrl; } -export async function clearGitCert(url: string) { - const { username } = gitAccount; - if (!username) { - return; +/** + * 构建安全的 Git 环境变量: + * 通过 GIT_ASKPASS 脚本传递凭证,不将账号密码内联到 URL 或暴露在命令行参数中。 + * + * @param account 可选账号信息 { username, password } + * @returns 含 GIT_ASKPASS 的环境变量对象 + */ +export function buildGitEnv(account?: { username: string; password: string }): NodeJS.ProcessEnv { + if (!account) { + return process.env; } - let finalUrl = ''; - if (!/https?:\/\/(.*?(:.*?)?@)/.test(url)) { - finalUrl = await transformUrl(url); - } - return new Promise((resolve, reject) => { - const child = spawn('git', ['credential', 'reject'], { - windowsHide: true, - timeout: 60 * 1000, - }); - child.stdin?.write(`url=${finalUrl}`); - child.stdin?.end(); - child.on('close', (code) => { - resolve(code); - }); - child.on('error', (err) => { - reject(err); - }); - }); + return { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: path.join(__dirname, 'git-askpass.sh'), + FEFLOW_GIT_USERNAME: account.username, + FEFLOW_GIT_PASSWORD: account.password, + }; } -export async function clearGitCertByPath(repoPath: string) { - const ret = spawn.sync('git', ['config', '--get', 'remote.origin.url'], { - windowsHide: true, - cwd: repoPath, - }); - const url = ret?.stdout?.toString().trim(); - return clearGitCert(url); +/** + * 清除 git credential 缓存(使用 git credential reject)。 + * 修复后凭证不再内联进 URL,此函数仅在 url 包含凭证时有操作意义, + * 新流程下可直接返回。 + */ +export async function clearGitCert(_url: string): Promise { + // 新流程凭证通过 GIT_ASKPASS 传递,不内联进 URL,无需 credential reject + return; } -export async function download(url: string, tag: string, filepath: string): Promise { +export async function clearGitCertByPath(_repoPath: string): Promise { + return; +} + +/** + * 安全下载仓库:URL 不含凭证,凭证通过环境变量传递。 + * + * @param url 原始仓库 URL + * @param tag 要 checkout 的 tag + * @param filepath 本地目标路径 + * @param account 可选 Git 账号 + */ +export async function download( + url: string, + tag: string, + filepath: string, + account?: { username: string; password: string } +): Promise { const cloneUrl = await transformUrl(url); + const env = buildGitEnv(account); console.log('clone from', url); return new Promise((resolve, reject) => { - const child = spawn('git', ['clone', '-b', tag, '--progress', '--depth', '1', cloneUrl, filepath], { - stdio: 'pipe', - windowsHide: true, - }); + const child = spawn( + 'git', + ['clone', '-b', tag, '--progress', '--depth', '1', cloneUrl, filepath], + { stdio: 'pipe', windowsHide: true, env } + ); let doneFlag = false; child.stderr?.on('data', (d) => { - if (doneFlag) { - return; - } + if (doneFlag) return; if (d?.toString()?.startsWith('Note:') || d?.toString()?.startsWith('注意')) { doneFlag = true; return; @@ -118,22 +152,13 @@ export async function download(url: string, tag: string, filepath: string): Prom process.stderr.write(d); }); child.stdout?.on('data', (d) => { - if (doneFlag) { - return; - } + if (doneFlag) return; process.stdout.write(d); }); child.on('close', (code) => { - if (code === 0) { - resolve(0); - } else { - reject(code); - } - }); - child.on('error', (err) => { - reject(err); + if (code === 0) resolve(); + else reject(code); }); - }) - // eslint-disable-next-line @typescript-eslint/no-misused-promises - .finally(() => clearGitCert(cloneUrl)); + child.on('error', reject); + }); } diff --git a/packages/feflow-report/src/constants.ts b/packages/feflow-report/src/constants.ts index 4752f6f7..dc1b507e 100644 --- a/packages/feflow-report/src/constants.ts +++ b/packages/feflow-report/src/constants.ts @@ -8,7 +8,8 @@ export const HOOK_TYPE_BEFORE = 'before'; */ export const HOOK_TYPE_AFTER = 'after'; -const BASIC_URL = 'http://api.feflowjs.com'; +// 安全修复:上报地址改为 HTTPS,防止遥测数据明文传输 +const BASIC_URL = 'https://api.feflowjs.com'; export const REPORT_URL = `${BASIC_URL}/api/v1/report/command`;