fix: 加固安全输入边界并修复订阅兼容性 (#3084)

* fix: harden scoped API inputs and isolate login throttling

* fix: close script path gaps and harden security boundaries

* fix: preserve subscription aliases and avoid extension eval

* fix: allow cancelling invalid historical subscriptions

* fix: make script parent validation explicit for CodeQL

* fix: limit command execution and clarify trusted path branches

* fix: separate diff tool script access from config APIs

* fix: clean up obsolete subscription SSH credentials
This commit is contained in:
whyour
2026-10-06 18:21:54 +08:00
committed by GitHub
parent 2ab9c702e6
commit 2cab112391
33 changed files with 2468 additions and 216 deletions
+4 -5
View File
@@ -12,14 +12,13 @@ export default class ConfigService {
public async getFile(filePath: string, res: Response) {
let content = '';
if (!filePath) {
if (!filePath || filePath.startsWith('data/scripts/')) {
return res.send({ code: 403, message: t('文件无法访问') });
}
const scriptFile = filePath.startsWith('data/scripts/');
const resolved = resolveFileAccess(
scriptFile ? config.scriptPath : config.configPath,
[scriptFile ? filePath.slice('data/scripts/'.length) : filePath],
scriptFile ? [] : config.blackFileList,
config.configPath,
[filePath],
config.blackFileList,
);
if (!resolved) {
return res.send({ code: 403, message: t('文件无法访问') });
+9 -3
View File
@@ -7,6 +7,7 @@ import {
import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import { resolveLogPath } from '../shared/logPath';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import {
RunningInstanceModel,
@@ -246,6 +247,9 @@ export default class CronService {
last_execution_time: number;
exit_code?: number;
}) {
if (log_path && log_path !== '/dev/null') {
resolveLogPath(config.logPath, log_path);
}
let options: any = {
status,
pid,
@@ -925,7 +929,9 @@ export default class CronService {
truncated: false,
};
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const absolutePath = doc.log_path
? resolveLogPath(config.logPath, doc.log_path)
: '';
const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) {
const chunk = await readLogChunk(`${absolutePath}`, options);
@@ -955,12 +961,12 @@ export default class CronService {
public async logs(id: number) {
const doc = await this.getDb({ id });
if (!doc || !doc.log_path) {
if (!doc || !doc.log_path || doc.log_path === '/dev/null') {
return [];
}
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir);
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
const dirExist = await fileExist(dir);
if (dirExist) {
let files = await fs.readdir(dir);
+3 -4
View File
@@ -262,10 +262,9 @@ export default class EnvService {
.map((x) => x.value)
.join('&')
.replace(/\\/g, '\\\\')}`;
js_env_string += `process.env.${key}=\`${_env_value.replace(
/\`/g,
'\\`',
)}\`;\n`;
js_env_string += `process.env[${JSON.stringify(key)}]=${JSON.stringify(
group.map((x) => x.value).join('&'),
)};\n`;
py_env_string += `os.environ['${key}']='''${_env_value.replace(
/\'/g,
"\\'",
+30 -22
View File
@@ -8,6 +8,10 @@ import { formatUrl } from '../config/subscription';
import config from '../config';
import { fileExist, rmPath } from '../config/util';
import { writeFileWithLock } from '../shared/utils';
import {
getSubscriptionSshAlias,
resolveSubscriptionPath,
} from '../shared/subscriptionPath';
@Service()
export default class SshKeyService {
@@ -42,7 +46,7 @@ export default class SshKeyService {
key: string,
): Promise<void> {
try {
const filePath = path.join(this.sshPath, alias);
const filePath = resolveSubscriptionPath(this.sshPath, alias);
try {
await rmPath(filePath);
} catch { }
@@ -54,7 +58,7 @@ export default class SshKeyService {
private async removePrivateKeyFile(alias: string): Promise<void> {
try {
const filePath = path.join(this.sshPath, alias);
const filePath = resolveSubscriptionPath(this.sshPath, alias);
await rmPath(filePath);
} catch (error) {
this.logger.error('删除私钥文件失败', error);
@@ -66,18 +70,19 @@ export default class SshKeyService {
host: string,
proxy?: string,
) {
if (!/^[a-zA-Z0-9.-]+$/.test(host) ||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) {
throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 });
}
if (host === 'github.com') {
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
}
const proxyStr = proxy
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
: '';
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n${proxyStr}`;
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config,
{
encoding: 'utf8',
@@ -88,7 +93,7 @@ export default class SshKeyService {
private async removeSshConfig(alias: string) {
try {
const filePath = path.join(this.sshPath, `${alias}.config`);
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
await rmPath(filePath);
} catch (error) {
this.logger.error(`删除ssh配置文件${alias}失败`, error);
@@ -101,6 +106,7 @@ export default class SshKeyService {
host: string,
proxy?: string,
): Promise<void> {
alias = getSubscriptionSshAlias(alias);
await this.generatePrivateKeyFile(alias, key);
await this.generateSingleSshConfig(alias, host, proxy);
}
@@ -110,6 +116,7 @@ export default class SshKeyService {
host: string,
proxy?: string,
): Promise<void> {
alias = getSubscriptionSshAlias(alias);
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
}
@@ -117,15 +124,19 @@ export default class SshKeyService {
public async setSshConfig(docs: Subscription[]) {
for (const doc of docs) {
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
const { alias, proxy } = doc;
const { host } = formatUrl(doc);
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
await this.generatePrivateKeyFile(
alias,
(doc.pull_option as any).private_key,
);
await this.generateSingleSshConfig(alias, host, proxy);
try {
const alias = getSubscriptionSshAlias(doc.alias);
const { host } = formatUrl(doc);
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
await this.generatePrivateKeyFile(
alias,
(doc.pull_option as any).private_key,
);
await this.generateSingleSshConfig(alias, host, doc.proxy);
} catch (error) {
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
}
}
}
}
@@ -144,12 +155,9 @@ export default class SshKeyService {
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config,
{
encoding: 'utf8',
+39 -7
View File
@@ -20,7 +20,7 @@ import {
} from '../config/util';
import fs from 'fs/promises';
import { FindOptions, Op } from 'sequelize';
import path, { join } from 'path';
import path from 'path';
import ScheduleService, { TaskCallbacks } from './schedule';
import { SimpleIntervalSchedule } from 'toad-scheduler';
import SockService from './sock';
@@ -30,6 +30,11 @@ import dayjs from 'dayjs';
import { LOG_END_SYMBOL } from '../config/const';
import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron';
import {
assertSubscriptionAlias,
resolveSubscriptionPath,
} from '../shared/subscriptionPath';
import { resolveLogPath } from '../shared/logPath';
import CrontabService from './cron';
import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@@ -88,9 +93,11 @@ export default class SubscriptionService {
needCreate = true,
runImmediately = false,
) {
const { url } = formatUrl(doc);
doc.command = formatCommand(doc, url as string);
// Cancelling by ID must also work for invalid historical subscriptions.
if (needCreate) {
const { url } = formatUrl(doc);
doc.command = formatCommand(doc, url as string);
}
if (doc.schedule_type === 'crontab') {
this.scheduleService.cancelCronTask(doc as any);
@@ -222,6 +229,7 @@ export default class SubscriptionService {
}
public async create(payload: Subscription): Promise<Subscription> {
assertSubscriptionAlias(payload.alias);
const tab = new Subscription(payload);
const doc = await this.insert(tab);
await this.handleTask(doc.get({ plain: true }));
@@ -234,14 +242,27 @@ export default class SubscriptionService {
}
public async update(payload: Subscription): Promise<Subscription> {
assertSubscriptionAlias(payload.alias);
const doc = await this.getDb({ id: payload.id });
const tab = new Subscription({ ...doc, ...payload });
const newDoc = await this.updateDb(tab);
await this.handleTask(newDoc, !newDoc.is_disabled);
await this.removeSshConfigForSubscription(doc);
await this.setSshConfig();
return newDoc;
}
private async removeSshConfigForSubscription(doc: Subscription) {
if (doc.type !== 'private-repo' || doc.pull_type !== 'ssh-key') return;
// Invalid historical rows must remain deletable without deriving unsafe paths.
try {
assertSubscriptionAlias(doc.alias);
} catch {
return;
}
await this.sshKeyService.removeSSHKey(doc.alias, '');
}
public async updateDb(payload: Subscription): Promise<Subscription> {
await SubscriptionModel.update(payload, { where: { id: payload.id } });
return await this.getDb({ id: payload.id });
@@ -262,6 +283,7 @@ export default class SubscriptionService {
last_running_time: number;
last_execution_time: number;
}) {
if (log_path) resolveLogPath(config.logPath, log_path);
const options: any = {
status,
pid,
@@ -280,10 +302,20 @@ export default class SubscriptionService {
public async remove(ids: number[], query: { force?: boolean }) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
// Validate all deletion targets before changing rows or removing any files.
if (query?.force === true) {
for (const doc of docs) {
resolveSubscriptionPath(config.scriptPath, doc.alias);
resolveSubscriptionPath(config.repoPath, doc.alias);
}
}
for (const doc of docs) {
await this.handleTask(doc.get({ plain: true }), false);
}
await SubscriptionModel.destroy({ where: { id: ids } });
for (const doc of docs) {
await this.removeSshConfigForSubscription(doc);
}
await this.setSshConfig();
if (query?.force === true) {
@@ -292,8 +324,8 @@ export default class SubscriptionService {
await this.crontabService.remove(crons.map((x) => x.id!));
}
for (const doc of docs) {
const filePath = join(config.scriptPath, doc.alias);
const repoPath = join(config.repoPath, doc.alias);
const filePath = resolveSubscriptionPath(config.scriptPath, doc.alias);
const repoPath = resolveSubscriptionPath(config.repoPath, doc.alias);
await rmPath(filePath);
await rmPath(repoPath);
}
@@ -399,7 +431,7 @@ export default class SubscriptionService {
if (doc.log_path) {
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir);
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
const _exist = await fileExist(dir);
if (_exist) {
let files = await fs.readdir(dir);
+20 -10
View File
@@ -1,4 +1,7 @@
import { spawn } from 'cross-spawn';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { shellQuote } from '../shared/shellQuote';
import { Response } from 'express';
import fs from 'fs';
import { Agent, request } from 'undici';
@@ -139,7 +142,7 @@ export default class SystemService {
if (info.dependenceProxy) {
await fs.promises.writeFile(
config.dependenceProxyFile,
`export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`,
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
);
} else {
await fs.promises.rm(config.dependenceProxyFile);
@@ -155,7 +158,7 @@ export default class SystemService {
});
let cmd = 'pnpm config delete registry';
if (info.nodeMirror) {
cmd = `pnpm config set registry ${info.nodeMirror}`;
cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`;
}
let command = `cd && ${cmd}`;
const docs = await DependenceModel.findAll({
@@ -204,7 +207,7 @@ export default class SystemService {
});
let cmd = 'pip config unset global.index-url';
if (info.pythonMirror) {
cmd = `pip3 config set global.index-url ${info.pythonMirror}`;
cmd = `pip3 config set global.index-url ${shellQuote(info.pythonMirror)}`;
}
await promiseExec(cmd);
return { code: 200, data: info };
@@ -357,7 +360,10 @@ export default class SystemService {
}
public async reloadSystem(target?: 'system' | 'data') {
const cmd = `real_time=true ql reload ${target || ''}`;
if (target && !['system', 'data'].includes(target)) {
return { code: 400, message: t('参数错误') };
}
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
const cp = spawn(cmd, {
shell: '/bin/bash',
detached: true,
@@ -438,11 +444,15 @@ export default class SystemService {
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
await promiseExec(
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
} ${dataPaths.join(' ')}`,
);
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d']);
if (dataDirs.some((dir) => !allowed.has(dir))) {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
cwd: path.dirname(config.dataPath),
});
res.download(config.dataTgzFile);
} catch (error: any) {
return res.send({ code: 400, message: error.message });
@@ -565,7 +575,7 @@ export default class SystemService {
try {
await fs.promises.writeFile(
config.langEnvFile,
`export QL_LANG='${lang}'\n`,
`export QL_LANG=${shellQuote(lang)}\n`,
);
} catch (error) {
this.logger.error(`Failed to write lang_env.sh: ${error}`);
+15 -12
View File
@@ -33,9 +33,11 @@ import {
verifyPassword,
} from '../shared/password';
import { serializeAuthMutation } from '../shared/authMutation';
import { LoginThrottle } from '../shared/loginThrottle';
@Service()
export default class UserService {
private loginThrottle = new LoginThrottle();
@Inject((type) => NotificationService)
private notificationService!: NotificationService;
@@ -106,11 +108,8 @@ export default class UserService {
return { code: 403, message: t('该 IP 已被列入黑名单') };
}
const retriesTime = Math.pow(3, retries) * 1000;
if (retries > 2 && timestamp - lastlogon < retriesTime) {
const waitTime = Math.ceil(
(retriesTime - (timestamp - lastlogon)) / 1000,
);
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
if (waitTime > 0) {
return {
code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime),
@@ -133,6 +132,7 @@ export default class UserService {
}
if (passwordMatches) {
this.loginThrottle.reset(ip);
const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, {
@@ -206,8 +206,9 @@ export default class UserService {
},
};
} else {
const failures = this.loginThrottle.fail(ip, timestamp);
await this.updateAuthInfo(content, {
retries: retries + 1,
retries: failures,
lastlogon: timestamp,
lastip: ip,
lastaddr: address,
@@ -239,8 +240,8 @@ export default class UserService {
},
});
this.getLoginLog();
if (retries > 2) {
const waitTime = Math.round(Math.pow(3, retries + 1));
if (failures > 3) {
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
return {
code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime),
@@ -439,8 +440,8 @@ export default class UserService {
const authInfo = await this.getAuthInfo();
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
const now = Date.now();
const retries = authInfo.retries || 0;
if (retries > 2 && now - authInfo.lastlogon < Math.pow(3, retries) * 1000) {
const source = getClientIp(req);
if (this.loginThrottle.retryAfter(source, now) > 0) {
return { code: 410, message: t('失败次数过多,请稍后重试') };
}
if (
@@ -472,9 +473,8 @@ export default class UserService {
.join(' ');
}
await this.updateAuthInfo(authInfo, {
retries: retries + 1,
retries: this.loginThrottle.fail(source, now),
lastlogon: now,
isTwoFactorChecking: retries + 1 < 5,
lastip: ip,
lastaddr: address,
platform: req.platform,
@@ -690,5 +690,8 @@ export default class UserService {
});
}
await this.updateAuthInfo(authInfo, payload);
if (retries === 0 || password !== undefined || username !== undefined) {
this.loginThrottle.clear();
}
}
}