mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
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:
@@ -0,0 +1,415 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const vm = require('node:vm');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const decorators = { Service: () => (x) => x, Inject: () => () => {} };
|
||||
const logger = { info() {}, warn() {}, error() {} };
|
||||
const translate = { t: (x) => x, tf: (x) => x };
|
||||
function temporary(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-advisory-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('environment values remain literal when the generated JavaScript loads', async () => {
|
||||
const output = {};
|
||||
const Env = load(path.resolve('back/services/env.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': { envFile: 'sh', jsEnvFile: 'js', pyEnvFile: 'py' },
|
||||
'../data/env': { EnvStatus: { normal: 0 } },
|
||||
'../data': {},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: async (file, text) => (output[file] = text),
|
||||
},
|
||||
}).default;
|
||||
const service = new Env(logger);
|
||||
const values = [
|
||||
'${globalThis.injected = true}',
|
||||
'quotes\'"` \\ newline\n中文\u2028',
|
||||
'${1 + 2}',
|
||||
];
|
||||
service.envs = async () =>
|
||||
values.map((value, i) => ({ name: `VALUE_${i}`, value }));
|
||||
await service.set_envs();
|
||||
const context = { process: { env: {} } };
|
||||
vm.runInNewContext(output.js, context);
|
||||
assert.equal(context.injected, undefined);
|
||||
values.forEach((value, i) =>
|
||||
assert.equal(context.process.env[`VALUE_${i}`], value),
|
||||
);
|
||||
});
|
||||
|
||||
test('subscription shell parameters round trip without expansion or command substitution', () => {
|
||||
const { formatCommand } = load(path.resolve('back/config/subscription.ts'));
|
||||
const payload =
|
||||
'a $(printf INJECTED) `printf INJECTED` " ; echo BAD; # \' 中文\nnext';
|
||||
for (const type of ['file', 'repo']) {
|
||||
const fields =
|
||||
type === 'file'
|
||||
? ['url', 'proxy']
|
||||
: [
|
||||
'url',
|
||||
'whitelist',
|
||||
'blacklist',
|
||||
'dependences',
|
||||
'branch',
|
||||
'extensions',
|
||||
'proxy',
|
||||
];
|
||||
for (const field of fields) {
|
||||
const doc = {
|
||||
id: 12,
|
||||
type,
|
||||
url: 'https://example.invalid/repo',
|
||||
[field]: payload,
|
||||
};
|
||||
const command = formatCommand(doc);
|
||||
const stdout = execFileSync('/bin/bash', [
|
||||
'-c',
|
||||
`ql() { printf '%s\\0' "$@"; }; ${command}`,
|
||||
]);
|
||||
const args = stdout.toString().split('\0').slice(0, -1);
|
||||
assert.equal(
|
||||
args[fields.indexOf(field) + 1],
|
||||
payload,
|
||||
`${type}:${field}`,
|
||||
);
|
||||
assert.equal(args[0], type === 'file' ? 'raw' : 'repo');
|
||||
assert.equal(args.length, fields.length + 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('SSH alias traversal and symlinks cannot write or remove outside files', async (t) => {
|
||||
const base = temporary(t);
|
||||
const root = path.join(base, 'ssh');
|
||||
fs.mkdirSync(root);
|
||||
const outside = path.join(base, 'outside');
|
||||
fs.writeFileSync(outside, 'keep');
|
||||
fs.symlinkSync(outside, path.join(root, 'link'));
|
||||
const Ssh = load(path.resolve('back/services/sshKey.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': { sshdPath: root },
|
||||
'../config/util': {
|
||||
rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }),
|
||||
},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (p, value) => fs.promises.writeFile(p, value),
|
||||
},
|
||||
}).default;
|
||||
const service = Object.assign(Object.create(Ssh.prototype), {
|
||||
sshPath: root,
|
||||
logger,
|
||||
});
|
||||
for (const alias of [
|
||||
'../outside',
|
||||
'..',
|
||||
'.',
|
||||
'/tmp/outside',
|
||||
'link',
|
||||
'x\nHost *',
|
||||
]) {
|
||||
await service.generatePrivateKeyFile(alias, 'overwrite');
|
||||
await service.removePrivateKeyFile(alias);
|
||||
await assert.rejects(service.generateSingleSshConfig(alias, 'github.com'));
|
||||
assert.equal(fs.readFileSync(outside, 'utf8'), 'keep');
|
||||
}
|
||||
await service.generatePrivateKeyFile('仓库-main', 'private-key');
|
||||
await service.generateSingleSshConfig(
|
||||
'仓库-main',
|
||||
'github.com',
|
||||
'127.0.0.1:1080',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, '仓库-main'), 'utf8').trim(),
|
||||
'private-key',
|
||||
);
|
||||
await service.generatePrivateKeyFile('~global_default', 'global-key');
|
||||
await service.generateGlobalSshConfig('~global_default');
|
||||
assert.match(
|
||||
fs.readFileSync(path.join(root, '~global_default.config'), 'utf8'),
|
||||
/Host \*/,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.generateSingleSshConfig('safe', 'host\nProxyCommand bad'),
|
||||
);
|
||||
await assert.rejects(
|
||||
service.generateSingleSshConfig('safe', 'host', '$(printf BAD)'),
|
||||
);
|
||||
});
|
||||
|
||||
function cronService(config, updates = []) {
|
||||
const Cron = load(path.resolve('back/services/cron.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': config,
|
||||
'../data/cron': {
|
||||
CrontabStatus: { running: 0, idle: 1, queued: 3 },
|
||||
CrontabModel: { update: async (v) => updates.push(v) },
|
||||
},
|
||||
'../data/runningInstance': { RunningInstanceModel: {}, InstanceStatus: {} },
|
||||
'../config/util': { fileExist: async (p) => fs.existsSync(p) },
|
||||
'../config/const': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': translate,
|
||||
'../shared/logStreamManager': {},
|
||||
'../shared/cronSchedule': {},
|
||||
}).default;
|
||||
return new Cron(logger);
|
||||
}
|
||||
function subscriptionService(config, model = {}) {
|
||||
const Subscription = load(path.resolve('back/services/subscription.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': config,
|
||||
'../data/subscription': {
|
||||
SubscriptionModel: model,
|
||||
SubscriptionStatus: {},
|
||||
},
|
||||
'../data/cron': {},
|
||||
'../config/util': { fileExist: async (p) => fs.existsSync(p) },
|
||||
'../config/const': {},
|
||||
'../shared/i18n': translate,
|
||||
'../shared/pLimit': {},
|
||||
'../shared/logStreamManager': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'./sshKey': {},
|
||||
'./cron': {},
|
||||
}).default;
|
||||
return new Subscription(logger, {}, {}, {}, {});
|
||||
}
|
||||
|
||||
test('stored cron/subscription paths cannot read or enumerate outside the log root', async (t) => {
|
||||
const base = temporary(t);
|
||||
const root = path.join(base, 'log');
|
||||
fs.mkdirSync(root);
|
||||
fs.mkdirSync(path.join(base, 'outside'));
|
||||
const secret = path.join(base, 'outside', 'secret');
|
||||
fs.writeFileSync(secret, 'SECRET');
|
||||
fs.symlinkSync(path.dirname(secret), path.join(root, 'escape'));
|
||||
fs.writeFileSync(path.join(root, 'normal.log'), 'NORMAL');
|
||||
const updates = [];
|
||||
const cron = cronService({ logPath: root }, updates);
|
||||
const subscription = subscriptionService(
|
||||
{ logPath: root },
|
||||
{ update: async (v) => updates.push(v) },
|
||||
);
|
||||
for (const log_path of [secret, '../outside/secret', 'escape/secret']) {
|
||||
cron.getDb = subscription.getDb = async () => ({ log_path, status: 1 });
|
||||
await assert.rejects(cron.log(1), /outside the log directory/);
|
||||
await assert.rejects(cron.logs(1), /outside the log directory/);
|
||||
await assert.rejects(subscription.logs(1), /outside the log directory/);
|
||||
await assert.rejects(
|
||||
cron.status({ ids: [1], status: 0, log_path }),
|
||||
/outside the log directory/,
|
||||
);
|
||||
await assert.rejects(
|
||||
subscription.status({ ids: [1], status: 0, log_path }),
|
||||
/outside the log directory/,
|
||||
);
|
||||
}
|
||||
assert.equal(updates.length, 0);
|
||||
cron.getDb = subscription.getDb = async () => ({
|
||||
log_path: 'normal.log',
|
||||
status: 1,
|
||||
});
|
||||
assert.equal((await cron.log(1)).content, 'NORMAL');
|
||||
assert.ok((await cron.logs(1)).some((x) => x.filename === 'normal.log'));
|
||||
assert.ok(
|
||||
(await subscription.logs(1)).some((x) => x.filename === 'normal.log'),
|
||||
);
|
||||
});
|
||||
|
||||
test('subscription aliases are checked before persistence and forced deletion', async (t) => {
|
||||
const base = temporary(t);
|
||||
for (const dir of ['scripts', 'repo']) fs.mkdirSync(path.join(base, dir));
|
||||
let writes = 0;
|
||||
const service = subscriptionService(
|
||||
{
|
||||
scriptPath: path.join(base, 'scripts'),
|
||||
repoPath: path.join(base, 'repo'),
|
||||
},
|
||||
{
|
||||
findAll: async () => [{ alias: '../victim' }],
|
||||
destroy: async () => writes++,
|
||||
},
|
||||
);
|
||||
service.insert = service.updateDb = async () => writes++;
|
||||
await assert.rejects(
|
||||
service.create({ alias: '../victim' }),
|
||||
/Invalid subscription alias/,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.update({ alias: '../victim' }),
|
||||
/Invalid subscription alias/,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.remove([1], { force: true }),
|
||||
/Invalid subscription alias/,
|
||||
);
|
||||
assert.equal(writes, 0);
|
||||
});
|
||||
|
||||
function systemService(config, overrides = {}) {
|
||||
const System = load(path.resolve('back/services/system.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': config,
|
||||
'../config/const': {},
|
||||
'../data/dependence': {
|
||||
DependenceModel: { findAll: async () => [] },
|
||||
DependenceTypes: {},
|
||||
DependenceStatus: {},
|
||||
},
|
||||
'../data/system': {},
|
||||
'../shared/pLimit': {},
|
||||
'../shared/i18n': { ...translate, setLang() {} },
|
||||
'./notify': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'../config/util': {},
|
||||
...overrides,
|
||||
}).default;
|
||||
return new System(logger, {}, {});
|
||||
}
|
||||
|
||||
test('backup rejects unrecognized paths before spawning; valid export uses argv without a shell', async (t) => {
|
||||
const base = temporary(t);
|
||||
const calls = [];
|
||||
const service = systemService(
|
||||
{
|
||||
dataPath: path.join(base, 'data'),
|
||||
dataTgzFile: path.join(base, 'backup file.tgz'),
|
||||
},
|
||||
{
|
||||
child_process: {
|
||||
execFile: (...args) => {
|
||||
calls.push(args.slice(0, -1));
|
||||
args.at(-1)(null, '', '');
|
||||
},
|
||||
},
|
||||
'../config/util': {
|
||||
promiseExec: () => assert.fail('must not invoke shell'),
|
||||
},
|
||||
},
|
||||
);
|
||||
let downloaded = false;
|
||||
const res = {
|
||||
status() {
|
||||
return this;
|
||||
},
|
||||
send(body) {
|
||||
return body;
|
||||
},
|
||||
download() {
|
||||
downloaded = true;
|
||||
},
|
||||
};
|
||||
for (const entry of [
|
||||
'$(printf BAD)',
|
||||
'../outside',
|
||||
'/etc',
|
||||
'scripts;echo BAD',
|
||||
'--checkpoint-action=exec=bad',
|
||||
]) {
|
||||
assert.equal((await service.exportData(res, [entry])).code, 400);
|
||||
}
|
||||
assert.equal(calls.length, 0);
|
||||
await service.exportData(res, ['base', 'scripts', 'config', 'scripts']);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], 'tar');
|
||||
assert.deepEqual(calls[0][1].slice(2), [
|
||||
'--',
|
||||
'data/db',
|
||||
'data/upload',
|
||||
'data/scripts',
|
||||
'data/config',
|
||||
]);
|
||||
assert.equal(calls[0][2].shell, undefined);
|
||||
assert.equal(downloaded, true);
|
||||
});
|
||||
|
||||
test('proxy, language and mirror settings stay literal at their shell boundaries', async (t) => {
|
||||
const base = temporary(t);
|
||||
const commands = [];
|
||||
const service = systemService(
|
||||
{
|
||||
dependenceProxyFile: path.join(base, 'proxy.sh'),
|
||||
langEnvFile: path.join(base, 'lang.sh'),
|
||||
},
|
||||
{
|
||||
'../config/util': {
|
||||
promiseExec: async (command) => commands.push(command),
|
||||
},
|
||||
},
|
||||
);
|
||||
service.getSystemConfig = async () => ({ info: {} });
|
||||
service.updateAuthDb = async () => {};
|
||||
service.scheduleService = { runTask: (command) => commands.push(command) };
|
||||
const value = 'x\'$(printf BAD)`printf BAD`"; echo BAD; #';
|
||||
await service.updateDependenceProxy({ dependenceProxy: value });
|
||||
await service.updateLanguage({ lang: value });
|
||||
const stdout = execFileSync('/bin/bash', [
|
||||
'-c',
|
||||
'source "$1"; source "$2"; printf "%s\\0%s" "$http_proxy" "$QL_LANG"',
|
||||
'bash',
|
||||
path.join(base, 'proxy.sh'),
|
||||
path.join(base, 'lang.sh'),
|
||||
]).toString();
|
||||
assert.equal(stdout, value + '\0' + value);
|
||||
await service.updatePythonMirror({ pythonMirror: value });
|
||||
await service.updateNodeMirror({ nodeMirror: value });
|
||||
for (const command of commands) {
|
||||
const stdout = execFileSync('/bin/bash', [
|
||||
'-c',
|
||||
`pip3() { printf '%s\\0' "$@"; }; pnpm() { printf '%s\\0' "$@"; }; ${command}`,
|
||||
]).toString();
|
||||
assert.equal(stdout.split('\0').at(-2), value);
|
||||
}
|
||||
assert.equal((await service.reloadSystem('$(printf BAD)')).code, 400);
|
||||
});
|
||||
|
||||
test('the ignored-log sentinel remains supported without exposing device files', async (t) => {
|
||||
const root = temporary(t);
|
||||
const cron = cronService({ logPath: root });
|
||||
cron.getDb = async () => ({ log_path: '/dev/null', log_name: '/dev/null' });
|
||||
assert.equal((await cron.log(1)).status, 'ignored');
|
||||
assert.deepEqual(await cron.logs(1), []);
|
||||
await cron.status({ ids: [], status: 0, log_path: '/dev/null' });
|
||||
await assert.rejects(
|
||||
cron.status({ ids: [], status: 0, log_path: '/dev/zero' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('normal backup creates an archive containing the selected data directories', async (t) => {
|
||||
const base = temporary(t);
|
||||
const dataPath = path.join(base, 'data');
|
||||
for (const name of ['db', 'upload', 'scripts'])
|
||||
fs.mkdirSync(path.join(dataPath, name), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataPath, 'scripts/example.js'), 'literal script');
|
||||
const dataTgzFile = path.join(base, 'backup with spaces.tgz');
|
||||
const service = systemService({ dataPath, dataTgzFile });
|
||||
let downloaded;
|
||||
await service.exportData(
|
||||
{
|
||||
download(file) {
|
||||
downloaded = file;
|
||||
},
|
||||
send(error) {
|
||||
assert.fail(JSON.stringify(error));
|
||||
},
|
||||
},
|
||||
['scripts'],
|
||||
);
|
||||
assert.equal(downloaded, dataTgzFile);
|
||||
const content = execFileSync('tar', [
|
||||
'-xzOf',
|
||||
dataTgzFile,
|
||||
'data/scripts/example.js',
|
||||
]).toString();
|
||||
assert.equal(content, 'literal script');
|
||||
});
|
||||
Reference in New Issue
Block a user