mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
* 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
190 lines
6.3 KiB
JavaScript
190 lines
6.3 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const test = require('node:test');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
const express = require('express');
|
|
const load = require('../helpers/load-security-module.cjs');
|
|
|
|
test('Python environment generation escapes backslashes and triple quotes together', async () => {
|
|
const output = {};
|
|
const Env = load(path.resolve('back/services/env.ts'), {
|
|
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
|
'../config': { envFile: 'sh', jsEnvFile: 'js', pyEnvFile: 'py' },
|
|
'../data/env': { EnvStatus: { normal: 0 } },
|
|
'../data': {},
|
|
'../shared/utils': {
|
|
writeFileWithLock: async (file, text) => (output[file] = text),
|
|
},
|
|
}).default;
|
|
const values = [
|
|
"\\'''; injected = True; #",
|
|
"'''",
|
|
'\\\\\\',
|
|
'quotes\'"`\n中文\u2028',
|
|
'${1+2}',
|
|
];
|
|
const service = new Env({});
|
|
service.envs = async () =>
|
|
values.map((value, i) => ({ name: `VALUE_${i}`, value }));
|
|
await service.set_envs();
|
|
const result = execFileSync(
|
|
'python3',
|
|
[
|
|
'-c',
|
|
output.py +
|
|
'\nimport json\nprint(json.dumps([os.environ["VALUE_"+str(i)] for i in range(5)]))\nassert "injected" not in globals()',
|
|
],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.deepEqual(JSON.parse(result), values);
|
|
});
|
|
|
|
test('cron lookup treats regex and shell metacharacters as literal command text', () => {
|
|
const { findCronId } = load(path.resolve('back/shared/cronCommand.ts'));
|
|
const command = 'task file[1].js --value "$(touch ignored)"';
|
|
const crontab =
|
|
`* * * * * real_time=false no_tee=true ID=17 ${command}\n` +
|
|
'* * * * * real_time=false no_tee=true ID=18 task other.js\n';
|
|
assert.equal(findCronId(crontab, command), '17');
|
|
assert.equal(findCronId(crontab, 'task file1.js'), '');
|
|
assert.equal(findCronId(crontab, '.*'), '');
|
|
assert.equal(findCronId(crontab, 'task other.js'), '18');
|
|
assert.equal(findCronId('', command), '');
|
|
assert.equal(findCronId(crontab, ''), '');
|
|
});
|
|
|
|
test('command route does not execute input while looking up the cron id', async (t) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-command-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const marker = path.join(root, 'unexpected');
|
|
const command = `task demo.js "$(touch ${marker})"`;
|
|
const crontabFile = path.join(root, 'crontab');
|
|
fs.writeFileSync(
|
|
crontabFile,
|
|
`* * * * * real_time=false no_tee=true ID=42 ${command}\n`,
|
|
);
|
|
const seen = [];
|
|
const app = express.Router();
|
|
load(path.resolve('back/api/system.ts'), {
|
|
'../config': { crontabFile, tmpPath: root },
|
|
'../services/system': {},
|
|
'../services/user': {},
|
|
'../shared/i18n': { t: (x) => x },
|
|
'../shared/logStreamManager': { logStreamManager: {} },
|
|
typedi: {
|
|
Container: { get: () => ({ run: async (value) => seen.push(value) }) },
|
|
},
|
|
'../config/util': {
|
|
getUniqPath: async (_command, id) => {
|
|
assert.equal(id, '42');
|
|
return 'test';
|
|
},
|
|
promiseExec: async (cmd) => {
|
|
return execFileSync('/bin/sh', ['-c', cmd], { encoding: 'utf8' });
|
|
},
|
|
},
|
|
}).default(app);
|
|
const router = app.stack.find((x) => x.name === 'router').handle;
|
|
const route = router.stack.find(
|
|
(x) => x.route?.path === '/command-run',
|
|
).route;
|
|
await route.stack
|
|
.at(-1)
|
|
.handle({ body: { command } }, { setHeader() {} }, (e) => {
|
|
throw e;
|
|
});
|
|
assert.equal(fs.existsSync(marker), false);
|
|
assert.equal(seen.length, 1);
|
|
assert.equal(seen[0].command, command);
|
|
});
|
|
|
|
test('command execution is limited before running across both API mounts', async (t) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-command-limit-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
let runs = 0;
|
|
const app = express();
|
|
app.use(express.json());
|
|
const router = express.Router();
|
|
load(path.resolve('back/api/system.ts'), {
|
|
'../config': { crontabFile: path.join(root, 'missing'), tmpPath: root },
|
|
'../services/system': {},
|
|
'../services/user': {},
|
|
'../shared/i18n': { t: (x) => x },
|
|
'../shared/logStreamManager': {
|
|
logStreamManager: { closeStream: async () => {} },
|
|
},
|
|
'../config/util': {
|
|
getUniqPath: async () => 'test',
|
|
handleLogPath: async () => path.join(root, 'unused.log'),
|
|
},
|
|
typedi: {
|
|
Container: {
|
|
get: () => ({
|
|
run: async (value, callbacks) => {
|
|
runs++;
|
|
await callbacks.onEnd();
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
}).default(router);
|
|
app.use('/api', router);
|
|
app.use('/open', router);
|
|
const server = await new Promise((resolve) => {
|
|
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
|
});
|
|
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
for (let i = 0; i < 61; i++) {
|
|
const response = await fetch(
|
|
`${base}/${i % 2 ? 'api' : 'open'}/system/command-run`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: 'echo safe' }),
|
|
},
|
|
);
|
|
await response.text();
|
|
assert.equal(response.status, i < 60 ? 200 : 429, `request ${i + 1}`);
|
|
}
|
|
assert.equal(runs, 60);
|
|
});
|
|
|
|
test('anonymous token exchange is limited per source across both API mounts', async (t) => {
|
|
let attempts = 0;
|
|
const app = express();
|
|
const router = express.Router();
|
|
load(path.resolve('back/api/open.ts'), {
|
|
'../services/open': {},
|
|
typedi: {
|
|
Container: {
|
|
get: () => ({
|
|
authToken: async () => {
|
|
attempts++;
|
|
return { code: 401 };
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
}).default(router);
|
|
app.use('/api', router);
|
|
app.use('/open', router);
|
|
const server = await new Promise((resolve) => {
|
|
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
|
});
|
|
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
for (let i = 0; i < 101; i++) {
|
|
const response = await fetch(
|
|
`${base}/${
|
|
i % 2 ? 'api' : 'open'
|
|
}/auth/token?client_id=id&client_secret=wrong`,
|
|
);
|
|
await response.text();
|
|
assert.equal(response.status, i < 100 ? 200 : 429, `request ${i + 1}`);
|
|
}
|
|
assert.equal(attempts, 100);
|
|
});
|