Files
qinglong/test/back/advisory-input-security.test.cjs
whyour 15d54866a8 feat: migrate SQLite runtime and verify complete upgrade bundles
Use MikroORM's official Node SQLite dialect with bounded database isolation, preserve legacy schema and data for rollback, and require Node 22.17 or newer. Update Debian runtimes and retain ARMv6 musl compatibility without a SQLite addon.

Complete short archive writes, stop orphaned SQL workers after owner death, preserve buffered startup requests, and prestart the archived backend on every platform. Cover real package installation, migration, failure recovery, and legacy database compatibility.

Keep authentication storage failures distinct from expired sessions with a pinned Keyv runtime, propagated cache errors, and retryable HTTP 503 responses. Preserve genuine 401 failures and verify legacy cache bytes, initialization safety, and session recovery.
2026-10-22 13:47:26 +08:00

418 lines
13 KiB
JavaScript

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: {
nativeUpdate: async (where, v) => {
return 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'),
},
{
find: async (where, read = {}) => {
return [{ alias: '../victim' }];
},
nativeDelete: async (where, read = {}) => {
return 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: {
find: async (where, read = {}) => {
return [];
},
},
DependenceTypes: {},
DependenceStatus: {},
},
'../data/system': {},
'../shared/pLimit': {},
'../schedule/client': {},
'../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');
});