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
+415
View File
@@ -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');
});
+189
View File
@@ -0,0 +1,189 @@
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);
});
+234
View File
@@ -0,0 +1,234 @@
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 express = require('express');
const load = require('../helpers/load-security-module.cjs');
function fixture(t, api = 'script') {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-draft-path-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
for (const dir of [
'scripts',
'config/grpc',
'log',
'tmp',
'bak',
'outside',
]) {
fs.mkdirSync(path.join(root, dir), { recursive: true });
}
const config = { blackFileList: ['auth.json', 'token.json', 'grpc'] };
for (const [key, dir] of Object.entries({
scriptPath: 'scripts',
configPath: 'config',
logPath: 'log',
tmpPath: 'tmp',
bakPath: 'bak',
}))
config[key] = path.join(root, dir);
config.writePathList = [config.scriptPath, config.configPath];
const stopped = [];
const removed = [];
const service = {
stopScript: async (...args) => {
stopped.push(args);
return { code: 200 };
},
};
const app = express.Router();
load(path.resolve(`back/api/${api}.ts`), {
'../config': config,
'../config/const': {},
'../services/script': {},
'../services/config': {},
'../shared/i18n': { t: (x) => x },
typedi: { Container: { get: () => service } },
'../shared/utils': {
writeFileWithLock: (p, s) => fs.promises.writeFile(p, s),
},
'../config/util': {
fileExist: async (p) => fs.existsSync(p),
rmPath: async (p) => {
removed.push(p);
await fs.promises.rm(p, { recursive: true, force: true });
},
},
}).default(app);
const router = app.stack.find((x) => x.name === 'router').handle;
const invoke = async (method, url, body) => {
const route = router.stack.find(
(x) => x.route?.path === url && x.route.methods[method],
).route;
let result;
await route.stack
.at(-1)
.handle({ body }, { send: (x) => (result = x) }, (e) => {
throw e;
});
return result;
};
return { root, config, stopped, removed, invoke };
}
test('script creation only creates the validated destination parent', async (t) => {
const { root, config, invoke } = fixture(t);
const outside = path.join(root, 'outside', 'unrequested');
const result = await invoke('post', '/', {
path: `${config.scriptPath}/../outside/unrequested`,
filename: '../../scripts/safe.txt',
content: 'safe',
});
assert.equal(result.code, 200);
assert.equal(
fs.readFileSync(path.join(config.scriptPath, 'safe.txt'), 'utf8'),
'safe',
);
assert.equal(fs.existsSync(outside), false);
assert.equal(
(
await invoke('post', '/', {
path: `${config.scriptPath}/../../escape`,
filename: 'poc.txt',
content: 'blocked',
})
).code,
403,
);
});
test('script creation supports root and nested parents in both writable directories', async (t) => {
const { config, invoke } = fixture(t);
for (const root of [config.scriptPath, config.configPath]) {
for (const filename of ['ordinary.js', 'nested/deeper/script.js']) {
assert.equal(
(await invoke('post', '/', {
path: root,
filename,
content: 'safe',
})).code,
200,
);
assert.equal(fs.readFileSync(path.join(root, filename), 'utf8'), 'safe');
}
}
});
test('script parent creation rejects sibling prefixes and external symlinks', async (t) => {
const { root, config, invoke } = fixture(t);
const sibling = config.scriptPath + '-sibling';
const outside = path.join(root, 'outside');
fs.mkdirSync(sibling);
fs.symlinkSync(outside, path.join(config.scriptPath, 'escape'));
for (const parent of [sibling, path.join(config.scriptPath, 'escape')]) {
assert.equal(
(await invoke('post', '/', {
path: parent,
filename: 'nested/poc.js',
content: 'blocked',
})).code,
403,
);
}
assert.equal(fs.existsSync(path.join(sibling, 'nested')), false);
assert.equal(fs.existsSync(path.join(outside, 'nested')), false);
});
test('stopping scripts rejects logs outside the log root before any side effect', async (t) => {
const { root, config, stopped, invoke } = fixture(t);
const callbacks = [];
t.mock.method(global, 'setTimeout', (fn) => {
callbacks.push(fn);
return {};
});
fs.mkdirSync(path.join(config.configPath, 'victim.swap'));
fs.mkdirSync(path.join(config.scriptPath, 'linked'));
fs.symlinkSync(
path.join(root, 'outside'),
path.join(config.logPath, 'linked'),
);
for (const dir of ['../config', 'linked']) {
assert.equal(
(await invoke('put', '/stop', { filename: 'victim.js', path: dir })).code,
403,
);
}
assert.equal(stopped.length, 0);
assert.equal(callbacks.length, 0);
});
test('log cleanup revalidates delayed paths and still removes ordinary debug logs', async (t) => {
const { root, config, removed, stopped, invoke } = fixture(t);
const callbacks = [];
t.mock.method(global, 'setTimeout', (fn) => {
callbacks.push(fn);
return {};
});
fs.mkdirSync(path.join(config.scriptPath, 'nested'));
fs.mkdirSync(path.join(config.logPath, 'nested'));
fs.mkdirSync(path.join(root, 'outside', 'victim.swap'));
fs.mkdirSync(path.join(config.logPath, 'normal.swap'));
assert.equal(
(await invoke('put', '/stop', { filename: 'normal.js' })).code,
200,
);
assert.equal(
(await invoke('put', '/stop', { filename: 'victim.js', path: 'nested' }))
.code,
200,
);
fs.rmdirSync(path.join(config.logPath, 'nested'));
fs.symlinkSync(
path.join(root, 'outside'),
path.join(config.logPath, 'nested'),
);
callbacks.forEach((fn) => fn());
assert.equal(stopped.length, 2);
assert.deepEqual(removed, [path.join(config.logPath, 'normal.swap')]);
assert.equal(fs.existsSync(path.join(root, 'outside', 'victim.swap')), true);
});
test('draft configuration traversal and normalized secret names cannot write files', async (t) => {
const { config, invoke } = fixture(t, 'config');
fs.writeFileSync(path.join(config.configPath, 'auth.json'), 'secret');
fs.symlinkSync(
path.join(config.configPath, 'auth.json'),
path.join(config.configPath, 'alias'),
);
for (const name of [
'auth.json',
'sub/../auth.json',
'sub/../token.json',
'grpc/client.key',
'../../shell/preload/env.sh',
'../scripts/poc.js',
'data/scripts/../config/auth.json',
'alias',
]) {
assert.equal(
(await invoke('post', '/save', { name, content: 'changed' })).code,
403,
name,
);
}
assert.equal(
fs.readFileSync(path.join(config.configPath, 'auth.json'), 'utf8'),
'secret',
);
assert.equal(
(await invoke('post', '/save', { name: 'config.sh', content: 'normal' }))
.code,
200,
);
assert.equal(
(
await invoke('post', '/save', {
name: 'data/scripts/normal.js',
content: 'normal',
})
).code,
403,
);
assert.equal(fs.existsSync(path.join(config.scriptPath, 'normal.js')), false);
});
+91
View File
@@ -0,0 +1,91 @@
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 express = require('express');
const load = require('../helpers/load-security-module.cjs');
test('environment import never writes upload filenames, including rejected files', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-env-import-'));
fs.writeFileSync(path.join(root, 'sendNotify.js'), 'ORIGINAL');
const created = [];
const Env = class {};
const app = express();
load(path.resolve('back/api/env.ts'), {
'../config': { scriptPath: root },
'../config/util': {
safeJSONParse: (value) => {
try {
return JSON.parse(value);
} catch {
return {};
}
},
},
'../services/env': Env,
'../shared/i18n': { t: (s) => s },
typedi: {
Container: {
get: () => ({
create: async (values) => {
created.push(values);
return values;
},
}),
},
},
}).default(app);
app.use((err, _req, res, _next) =>
res.status(400).json({ code: 400, message: err.message }),
);
const server = app.listen(0, '127.0.0.1');
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(root, { recursive: true, force: true });
});
await new Promise((resolve, reject) => {
server.on('listening', resolve);
server.on('error', reject);
});
const upload = async (content, filename = 'sendNotify.js') => {
const form = new FormData();
form.append('env', new Blob([content]), filename);
const response = await fetch(
`http://127.0.0.1:${server.address().port}/envs/upload`,
{ method: 'POST', body: form },
);
return response.json();
};
for (const payload of [
'globalThis.injected=true;',
'null',
'[]',
'[{"name":"BAD;CODE","value":"x"}]',
'[{"name":"X","value":{}}]',
]) {
assert.equal((await upload(payload)).code, 400);
assert.equal(
fs.readFileSync(path.join(root, 'sendNotify.js'), 'utf8'),
'ORIGINAL',
);
}
assert.equal((await upload('x'.repeat(5 * 1024 * 1024 + 1))).code, 400);
assert.equal(created.length, 0);
assert.equal(
(
await upload(
'[{"name":"TOKEN","value":"literal ${value}","remarks":"ok"}]',
'../sendNotify.js',
)
).code,
200,
);
assert.equal(created.length, 1);
assert.equal(created[0][0].value, 'literal ${value}');
assert.deepEqual(fs.readdirSync(root), ['sendNotify.js']);
assert.equal(
fs.readFileSync(path.join(root, 'sendNotify.js'), 'utf8'),
'ORIGINAL',
);
});
+4 -11
View File
@@ -81,7 +81,7 @@ function api(name, service, mocks) {
}
for (const customData of [false, true]) {
test(`config script access scopes blacklist and uses the configured data directory (custom=${customData})`, async (t) => {
test(`config APIs reject script compatibility paths (custom=${customData})`, async (t) => {
const { config, mocks, secret } = fixture(t, customData);
const Config = load(path.resolve('back/services/config.ts'), mocks).default;
mocks['../services/config'] = Config;
@@ -101,19 +101,12 @@ for (const customData of [false, true]) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'initial');
const name = `data/scripts/${filename}`;
assert.equal(
(await invoke('get', '/detail', { path: name })).data,
'initial',
);
assert.equal((await invoke('get', '/detail', { path: name })).code, 403);
assert.equal(
(await invoke('post', '/save', { name, content: 'updated' })).code,
200,
403,
);
assert.equal(
(await invoke('get', '/detail', { path: name })).data,
'updated',
);
assert.equal(fs.readFileSync(file, 'utf8'), 'updated');
assert.equal(fs.readFileSync(file, 'utf8'), 'initial');
}
if (customData)
assert.equal(
+135
View File
@@ -0,0 +1,135 @@
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 childProcess = require('node:child_process');
const grpc = require('@grpc/grpc-js');
const load = require('../helpers/load-security-module.cjs');
const logger = { info() {}, debug() {}, warn() {}, error() {} };
function fixture(t, extra = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ql-grpc's-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const module = load(path.resolve('back/config/grpcCerts.ts'), {
'./index': { configPath: root },
'./util': { fileExist: async (p) => fs.existsSync(p) },
'../loaders/logger': logger,
...extra,
});
return { root, module };
}
test('gRPC keys use a private reserved workspace and clean up all OpenSSL side files', async (t) => {
const workspaces = new Set();
const { root, module } = fixture(t, {
child_process: {
...childProcess,
execFileSync: (file, args, opts) => {
const temporary = args.find((arg) => arg.includes('/.generate-'));
const workspace = path.dirname(temporary);
workspaces.add(workspace);
assert.equal(fs.statSync(workspace).mode & 0o777, 0o700);
return childProcess.execFileSync(file, args, opts);
},
},
});
const certs = await module.initGrpcCerts();
assert.match(certs.serverCert, /BEGIN CERTIFICATE/);
assert.equal(workspaces.size, 1);
for (const dir of workspaces) assert.equal(fs.existsSync(dir), false);
assert.deepEqual(fs.readdirSync(path.join(root, 'grpc')).sort(), [
'ca.crt',
'ca.key',
'client.crt',
'client.key',
'server.crt',
'server.key',
]);
for (const file of ['ca.key', 'client.key', 'server.key']) {
assert.equal(
fs.statSync(path.join(root, 'grpc', file)).mode & 0o777,
0o600,
);
}
assert.strictEqual(await module.initGrpcCerts(), certs);
});
test('gRPC certificate generation cleans partial files on OpenSSL errors', async (t) => {
const { root, module } = fixture(t, {
child_process: {
execFileSync: (_file, args) => {
fs.writeFileSync(args[2], 'partial private key');
throw new Error('openssl failed');
},
},
});
await assert.rejects(module.initGrpcCerts(), /openssl failed/);
assert.deepEqual(fs.readdirSync(path.join(root, 'grpc')), []);
});
test('real gRPC server rejects plaintext and TLS without client certificates, accepts mTLS', async (t) => {
const { module } = fixture(t);
const certs = await module.initGrpcCerts();
let port;
let checks = 0;
const Grpc = load(path.resolve('back/services/grpc.ts'), {
'../config': { bindHostGrpc: '127.0.0.1', grpcPort: 0 },
'../config/grpcCerts': { initGrpcCerts: async () => certs },
'../loaders/logger': logger,
'./metrics': { metricsService: { record() {} } },
typedi: { Service: () => (x) => x },
'@grpc/grpc-js': {
...grpc,
Server: class extends grpc.Server {
bindAsync(address, creds, cb) {
super.bindAsync(address, creds, (e, boundPort) => {
port = boundPort;
cb(e, boundPort);
});
}
},
},
'../schedule/addCron': {},
'../schedule/delCron': {},
'../schedule/api': {},
'../schedule/health': {
check: (_call, cb) => {
checks++;
cb(null, { status: 1 });
},
},
}).GrpcServerService;
const server = new Grpc();
await server.initialize();
t.after(() => server.shutdown());
const HealthClient = load(path.resolve('back/protos/health.ts')).HealthClient;
const request = async (credentials) => {
const client = new HealthClient(`localhost:${port}`, credentials, {
'grpc.enable_http_proxy': 0,
});
try {
return await new Promise((resolve, reject) => {
client.check({}, { deadline: Date.now() + 2000 }, (e, reply) =>
e ? reject(e) : resolve(reply),
);
});
} finally {
client.close();
}
};
await assert.rejects(request(grpc.credentials.createInsecure()));
await assert.rejects(
request(grpc.credentials.createSsl(Buffer.from(certs.caCert))),
);
assert.equal(checks, 0);
const reply = await request(
grpc.credentials.createSsl(
Buffer.from(certs.caCert),
Buffer.from(certs.clientKey),
Buffer.from(certs.clientCert),
),
);
assert.equal(reply.status, 1);
assert.equal(checks, 1);
});
+90 -1
View File
@@ -9,7 +9,7 @@ const load = require('../helpers/load-security-module.cjs');
test('HTTP authentication protects init, scopes, expired sessions and config secrets', async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-http-security-'));
for (const dir of ['config/grpc', 'scripts', 'upload', 'tmp'])
for (const dir of ['config/grpc', 'scripts', 'upload', 'tmp', 'bak'])
fs.mkdirSync(path.join(tmp, dir), { recursive: true });
fs.writeFileSync(path.join(tmp, 'config/grpc/client.key'), 'SENTINEL');
fs.writeFileSync(path.join(tmp, 'config/normal.txt'), 'normal');
@@ -40,6 +40,8 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
scriptPath: path.join(tmp, 'scripts/'),
uploadPath: path.join(tmp, 'upload'),
tmpPath: path.join(tmp, 'tmp'),
bakPath: path.join(tmp, 'bak'),
writePathList: [path.join(tmp, 'scripts')],
blackFileList: ['auth.json', 'grpc'],
baseUrl: '/panel',
};
@@ -48,6 +50,16 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
scopes: ['configs'],
tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['configs', 'scripts'],
tokens: [
{ value: 'script-config-app', expiration: Date.now() / 1000 + 3600 },
],
},
{
scopes: ['scripts'],
tokens: [{ value: 'script-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['envs'],
tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }],
@@ -68,6 +80,8 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
getToken: (r) => (r.headers.authorization || '').replace(/^Bearer /, ''),
getPlatform: () => 'desktop',
getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'),
fileExist: (p) => fs.existsSync(p),
rmPath: (p) => fs.promises.rm(p),
},
'../shared/i18n': { t: (x) => x },
'../shared/store': {
@@ -75,6 +89,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
},
'../config/serverEnv': { serveEnv: (_req, res) => res.end() },
'../services/user': User,
'../services/script': class {},
'../data/open': {},
'../data/system': {},
'../shared/utils': {
@@ -96,6 +111,9 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
load(path.join(__dirname, '../../back/api/config.ts'), mocks).default(
router,
);
load(path.join(__dirname, '../../back/api/script.ts'), mocks).default(
router,
);
router.get('/envs', (_req, res) => res.json({ code: 200 }));
return router;
};
@@ -210,4 +228,75 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
).status,
400,
);
const scriptName = 'data/scripts/test.js';
for (const prefix of ['/open', '/panel/open']) {
const denied = await request(
`${prefix}/configs/save`,
'config-app',
'POST',
{
name: scriptName,
content: 'overwrite',
},
);
assert.equal(denied.body.code, 403);
assert.equal(fs.existsSync(path.join(tmp, 'scripts/test.js')), false);
}
assert.equal(
(
await request('/open/configs/save', 'script-config-app', 'POST', {
name: scriptName,
content: 'authorized script',
})
).body.code,
403,
);
assert.equal(fs.existsSync(path.join(tmp, 'scripts/test.js')), false);
assert.equal(
(await request(`/open/configs/detail?path=${scriptName}`, 'config-app'))
.body.code,
403,
);
assert.equal(
(
await request('/open/configs/save', 'config-app', 'POST', {
name: 'normal.txt',
content: 'normal config',
})
).body.code,
200,
);
assert.equal(
(
await request('/api/configs/save', valid, 'POST', {
name: scriptName,
content: 'owner script',
})
).body.code,
403,
);
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(
await request(`${prefix}/scripts`, 'config-app', 'POST', {
filename: 'test.js',
content: 'unauthorized',
})
).status,
401,
);
assert.equal(
(
await request(`${prefix}/scripts`, 'script-app', 'POST', {
filename: 'test.js',
content: 'authorized script',
})
).body.code,
200,
);
assert.equal(
fs.readFileSync(path.join(tmp, 'scripts/test.js'), 'utf8'),
'authorized script',
);
}
});
+5 -1
View File
@@ -1,9 +1,13 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { Sequelize, DataTypes } = require('sequelize');
const load = require('../helpers/load-security-module.cjs');
async function fixture(t, onKill = async () => {}) {
const logPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-stop-log-'));
t.after(() => fs.rmSync(logPath, { recursive: true, force: true }));
const db = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
@@ -41,7 +45,7 @@ async function fixture(t, onKill = async () => {}) {
let service;
const killed = [];
const CronService = load(path.resolve('back/services/cron.ts'), {
'../config': {},
'../config': { logPath },
'../data/cron': {
CrontabModel: crons,
CrontabStatus: { queued: 3, running: 0, idle: 1 },
@@ -0,0 +1,607 @@
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 { execFileSync } = require('node:child_process');
const ts = require('typescript');
const load = require('../helpers/load-security-module.cjs');
const paths = load(path.resolve('back/shared/subscriptionPath.ts'));
const { formatUrl, formatCommand } = load(
path.resolve('back/config/subscription.ts'),
);
function temporary(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-sub-review-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
function privateSubscription(alias, id = 1) {
return {
id,
type: 'private-repo',
pull_type: 'ssh-key',
alias,
url: 'git@example.com:owner/repo.git',
pull_option: { private_key: 'fixture-key' },
};
}
function sshService(root, warnings = []) {
const Ssh = load(path.resolve('back/services/sshKey.ts'), {
typedi: { Service: () => (x) => x, Inject: () => () => {} },
'../config': { sshdPath: root },
'../config/util': {
fileExist: async (p) => fs.existsSync(p),
rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }),
},
}).default;
return Object.assign(Object.create(Ssh.prototype), {
sshPath: root,
logger: { error() {}, warn: (...args) => warnings.push(args) },
});
}
test('aliases generated by the frontend for valid branches remain accepted', () => {
const text = fs.readFileSync(
path.resolve('src/pages/subscription/modal.tsx'),
'utf8',
);
const source = text.slice(
text.indexOf('const formatAlias ='),
text.indexOf('\n\n const IntervalSelect'),
);
const js = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2020 },
}).outputText;
const formatAlias = new Function(
'repoUrlRegx',
'fileUrlRegx',
'type',
js + '\nreturn formatAlias;',
)(
/([^\/\:]+\/[^\/]+)(?=\.git)/,
/([^\/\:]+\/[^\/\.]+)\.[a-z]+/,
'public-repo',
);
for (const branch of [
'main',
'release+hotfix',
'feature/foo',
'feature/foo/bar',
'release@v2',
'release#v2',
]) {
const alias = formatAlias('https://example.com/owner/repo.git', branch);
assert.equal(alias, `owner_repo_${branch}`);
assert.doesNotThrow(() => paths.assertSubscriptionAlias(alias));
}
});
test('nested aliases stay inside the root and cannot claim internal SSH names', (t) => {
const root = temporary(t);
const scripts = path.join(root, 'scripts');
fs.mkdirSync(scripts);
assert.equal(
paths.resolveSubscriptionPath(scripts, 'owner_repo_feature/foo'),
path.join(scripts, 'owner_repo_feature/foo'),
);
fs.symlinkSync(root, path.join(scripts, 'escape'));
assert.throws(() => paths.resolveSubscriptionPath(scripts, 'escape/outside'));
for (const alias of [
'.',
'..',
'../outside',
'/outside',
'x/../outside',
'x/./a',
'x//a',
'x/',
'x\\a',
'x\0a',
'x\nHost *',
'x y',
'~global_secret',
'~sub_' + 'a'.repeat(64),
]) {
assert.throws(() => paths.assertSubscriptionAlias(alias), alias);
}
assert.throws(() => paths.assertSubscriptionAlias('a'.repeat(201)));
});
test('SSH URL, key and configuration use the same stable name for nested aliases', async (t) => {
const root = temporary(t);
const ssh = sshService(root);
const docs = [
privateSubscription('owner_repo_release+hotfix'),
privateSubscription('owner_repo_feature/foo', 2),
privateSubscription('owner_repo_feature_foo', 3),
privateSubscription('owner_repo.config', 4),
];
const snapshot = structuredClone(docs);
await ssh.setSshConfig(docs);
assert.deepEqual(docs, snapshot);
const mapped = paths.getSubscriptionSshAlias(docs[1].alias);
assert.match(mapped, /^~sub_[a-f0-9]{64}$/);
assert.notEqual(mapped, paths.getSubscriptionSshAlias(docs[2].alias));
assert.equal(paths.getSubscriptionSshAlias(docs[0].alias), docs[0].alias);
for (const doc of docs) {
const name = paths.getSubscriptionSshAlias(doc.alias);
const key = path.join(root, name);
const config = path.join(root, `${name}.config`);
assert.equal(fs.readFileSync(key, 'utf8').trim(), 'fixture-key');
assert.equal(fs.statSync(key).mode & 0o777, 0o400);
assert.equal(fs.statSync(config).mode & 0o777, 0o600);
assert.ok(fs.readFileSync(config, 'utf8').startsWith(`Host ${name}\n`));
assert.equal(formatUrl(doc).url, `git@${name}:owner/repo.git`);
const parsed = execFileSync('ssh', ['-G', '-F', config, name], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
assert.match(parsed, /^hostname example\.com$/m);
assert.ok(parsed.includes(`identityfile ${key}\n`));
}
await ssh.removeSSHKey(docs[1].alias, 'example.com');
assert.equal(fs.existsSync(path.join(root, mapped)), false);
assert.equal(fs.existsSync(path.join(root, `${mapped}.config`)), false);
await ssh.addSSHKey('replacement', docs[1].alias, 'example.com');
assert.equal(
fs.readFileSync(path.join(root, mapped), 'utf8').trim(),
'replacement',
);
});
test('startup retains historical aliases and isolates bad rows and asynchronous scheduling failures', async (t) => {
const root = temporary(t);
const warnings = [];
const ssh = sshService(root, warnings);
const docs = [
privateSubscription('owner_repo_release+hotfix', 1),
privateSubscription('owner_repo_feature/foo', 2),
privateSubscription('../outside', 3),
{
id: 4,
type: 'public-repo',
alias: 'failed-job',
url: 'https://example.com/repo.git',
},
{
id: 5,
type: 'public-repo',
alias: 'healthy-job',
url: 'https://example.com/repo.git',
},
];
const registered = [];
const types = Object.fromEntries(
['system', 'schedule', 'subscription', 'ssh'].map((key) => [key, class {}]),
);
const services = new Map([
[types.system, { getSystemConfig: async () => ({}) }],
[
types.schedule,
{ cancelIntervalTask: async () => {}, createIntervalTask: () => {} },
],
[types.ssh, ssh],
[
types.subscription,
{
setSshConfig: () => ssh.setSshConfig(docs),
list: async () => docs.map((doc) => ({ ...doc, get: () => doc })),
handleTask: async (doc) => {
if (doc.id === 4) throw new Error('unavailable scheduler');
registered.push(doc.id);
},
},
],
]);
const initTask = load(path.resolve('back/loaders/initTask.ts'), {
'../config': { rootPath: root },
'../config/util': { fileExist: async () => false },
'../shared/i18n': { t: (x) => x },
'./logger': { warn: (...args) => warnings.push(args) },
'../services/system': types.system,
'../services/schedule': types.schedule,
'../services/subscription': types.subscription,
'../services/sshKey': types.ssh,
typedi: { Container: { get: (key) => services.get(key) } },
}).default;
await initTask();
assert.deepEqual(registered, [1, 2, 5]);
assert.equal(warnings.length, 3);
assert.equal(
fs.existsSync(path.join(root, 'owner_repo_release+hotfix.config')),
true,
);
});
test('subscription create and update preserve branch aliases without rewriting records', async (t) => {
const root = temporary(t);
const Subscription = load(path.resolve('back/services/subscription.ts'), {
'../config': { logPath: root },
'../data/subscription': {
Subscription: class {
constructor(value) {
Object.assign(this, value);
}
},
SubscriptionModel: {},
},
'../data/cron': {},
'../config/util': {},
'../config/const': {},
'../shared/i18n': { t: (x) => x, tf: (x) => x },
'../shared/pLimit': {},
'./schedule': {},
'./sock': {},
'./sshKey': {},
'./cron': {},
}).default;
const service = new Subscription({}, {}, {}, {}, {});
const tasks = [];
service.insert = async (value) => ({ ...value, get: () => value });
service.getDb = async () => ({ id: 1, alias: 'owner_repo_release+hotfix' });
service.updateDb = async (value) => value;
service.handleTask = async (doc) => tasks.push(doc.alias);
service.setSshConfig = async () => {};
for (const alias of ['owner_repo_release+hotfix', 'owner_repo_feature/foo']) {
await service.create({ id: 1, alias });
await service.update({ id: 1, alias });
}
assert.deepEqual(tasks, [
'owner_repo_release+hotfix',
'owner_repo_release+hotfix',
'owner_repo_feature/foo',
'owner_repo_feature/foo',
]);
});
function shellFunction(name) {
const source = fs.readFileSync(path.resolve('shell/update.sh'), 'utf8');
const start = source.indexOf(`${name}() {`);
assert.ok(start >= 0);
return source.slice(start, source.indexOf('\n}', start) + 2);
}
function updateFixture(t) {
const root = temporary(t);
const template = path.join(root, 'template');
for (const dir of ['template', 'repo', 'scripts', 'lists'])
fs.mkdirSync(path.join(root, dir));
for (const file of ['keep.js', 'keep.py', 'omit.js', 'other.txt', 'dep.js'])
fs.writeFileSync(path.join(template, file), file);
for (const file of ['notify.js', 'notify.py', 'crontab'])
fs.writeFileSync(path.join(root, file), '');
const helpers = [
'update_repo',
'diff_scripts',
'gen_list_repo',
'get_uniq_path',
]
.map(shellFunction)
.join('\n');
return {
root,
run: (extensions, dependence = 'dep') => {
const doc = {
id: 1,
type: 'public-repo',
alias: 'owner_repo',
url: 'https://example.invalid/owner/repo.git',
whitelist: 'keep',
blacklist: 'omit',
dependences: dependence,
extensions,
autoAddCron: false,
autoDelCron: false,
};
const code = `dir_repo="$QA_ROOT/repo"; dir_scripts="$QA_ROOT/scripts"; dir_list_tmp="$QA_ROOT/lists"
file_notify_js="$QA_ROOT/notify.js"; file_notify_py="$QA_ROOT/notify.py"; list_crontab_user="$QA_ROOT/crontab"
cmd_task=task; file_extensions='js py'
${helpers}
t() { :; }; make_dir() { mkdir -p "$1"; }; diff_cron() { :; }
git_clone_scripts() { mkdir -p "$2"; cp "$QA_ROOT/template/"* "$2/"; exit_status=0; }
ql() { get_uniq_path "$2" "$6"; update_repo "${'${@:2}'}"; }
${formatCommand(doc)}`;
execFileSync('/bin/bash', ['-c', code], {
env: { ...process.env, QA_ROOT: root },
encoding: 'utf8',
timeout: 5000,
});
return path.join(root, 'scripts', 'owner_repo');
},
};
}
test('real repository update supports extension separators, filtering and dependency copies', (t) => {
for (const extension of ['js|py', 'js py', 'js\tpy', 'js\npy', '']) {
const f = updateFixture(t);
const scripts = f.run(extension);
assert.deepEqual(fs.readdirSync(scripts).sort(), [
'dep.js',
'keep.js',
'keep.py',
'notify.js',
'notify.py',
]);
assert.deepEqual(
fs
.readFileSync(
path.join(f.root, 'lists', 'owner_repo_scripts.list'),
'utf8',
)
.trim()
.split('\n')
.sort(),
['owner_repo/keep.js', 'owner_repo/keep.py'],
);
}
});
test('real repository update never evaluates extension input, including dependency discovery', (t) => {
const f = updateFixture(t);
const marker = path.join(f.root, 'executed');
const payloads = [
'js|$(touch${IFS}' + marker + ')',
'js|`touch${IFS}' + marker + '`',
'js|py";touch${IFS}' + marker + ';#',
'js|$(touch${IFS}' + marker + ')\npy',
];
for (const payload of payloads) {
const scripts = f.run(payload);
assert.equal(fs.existsSync(marker), false, payload);
assert.equal(
fs.readFileSync(path.join(scripts, 'keep.js'), 'utf8'),
'keep.js',
);
assert.equal(
fs.readFileSync(path.join(scripts, 'dep.js'), 'utf8'),
'dep.js',
);
}
});
test('force deletion supports historical nested aliases while preserving outside targets', async (t) => {
const root = temporary(t);
const config = {
scriptPath: path.join(root, 'scripts'),
repoPath: path.join(root, 'repo'),
};
const alias = 'owner_repo_feature/foo';
for (const dir of [config.scriptPath, config.repoPath]) {
fs.mkdirSync(path.join(dir, alias), { recursive: true });
fs.writeFileSync(path.join(dir, alias, 'file'), 'fixture');
}
const outside = path.join(root, 'outside');
fs.mkdirSync(outside);
fs.writeFileSync(path.join(outside, 'file'), 'keep');
let destroyed = 0;
const doc = { id: 1, alias, get: () => doc };
const Subscription = load(path.resolve('back/services/subscription.ts'), {
'../config': config,
'../data/subscription': {
SubscriptionModel: {
findAll: async () => [doc],
destroy: async () => destroyed++,
},
},
'../data/cron': { CrontabModel: { findAll: async () => [] } },
'../config/util': {
rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }),
},
'../config/const': {},
'../shared/i18n': { t: (x) => x, tf: (x) => x },
'../shared/pLimit': {},
'./schedule': {},
'./sock': {},
'./sshKey': {},
'./cron': {},
}).default;
const service = new Subscription({}, {}, {}, {}, {});
service.handleTask = async () => {};
service.setSshConfig = async () => {};
await service.remove([1], { force: true });
assert.equal(destroyed, 1);
for (const dir of [config.scriptPath, config.repoPath])
assert.equal(fs.existsSync(path.join(dir, alias)), false);
fs.symlinkSync(outside, path.join(config.scriptPath, 'escape'));
doc.alias = 'escape/file';
await assert.rejects(
service.remove([1], { force: true }),
/Invalid subscription path/,
);
assert.equal(destroyed, 1);
assert.equal(fs.readFileSync(path.join(outside, 'file'), 'utf8'), 'keep');
assert.equal(paths.getSubscriptionSshAlias('仓库-main'), '仓库-main');
});
function cancellationFixture(t, subscriptions) {
const root = temporary(t);
const events = [];
const docs = subscriptions.map((value) => {
const doc = { ...value };
doc.get = () => ({ ...doc });
return doc;
});
const Subscription = load(path.resolve('back/services/subscription.ts'), {
'../config': { scriptPath: root, repoPath: root },
'../data/subscription': {
SubscriptionModel: {
findAll: async ({ where }) =>
docs.filter((doc) => where.id.includes(doc.id)),
destroy: async ({ where }) => events.push(['destroy', where.id]),
update: async (value, { where }) => {
for (const doc of docs) {
if (where.id.includes(doc.id)) Object.assign(doc, value);
}
},
},
},
'../data/cron': {},
'../config/util': { rmPath: async () => events.push(['removeFile']) },
'../config/const': {},
'../shared/i18n': { t: (x) => x, tf: (x) => x },
'../shared/pLimit': {},
'./schedule': {},
'./sock': {},
'./sshKey': {},
'./cron': {},
}).default;
const schedule = {
cancelCronTask: (doc) => events.push(['cancelCron', doc.id]),
cancelIntervalTask: (doc) => events.push(['cancelInterval', doc.id]),
createCronTask: async (doc, callbacks, immediately) =>
events.push(['createCron', doc.command, immediately]),
createIntervalTask: async (doc, interval, immediately) =>
events.push(['createInterval', doc.command, interval, immediately]),
};
const service = new Subscription(
{}, schedule, {}, { removeSSHKey: async () => {} }, {},
);
service.setSshConfig = async () => {};
return { service, docs, events };
}
async function sshLifecycleFixture(t, initial) {
const root = temporary(t);
const ssh = sshService(root);
let docs = [{ ...initial, get() { return { ...this }; } }];
const SubscriptionService = load(path.resolve('back/services/subscription.ts'), {
'../config': { scriptPath: root, repoPath: root },
'../data/subscription': {
Subscription: class { constructor(value) { Object.assign(this, value); } },
SubscriptionModel: {
findAll: async () => docs,
destroy: async () => { docs = []; },
},
},
'../data/cron': { CrontabModel: { findAll: async () => [] } },
'../config/util': {
rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }),
},
'../config/const': {},
'../shared/i18n': { t: (x) => x, tf: (x) => x },
'../shared/pLimit': {},
'./schedule': {}, './sock': {}, './sshKey': {}, './cron': {},
}).default;
const service = new SubscriptionService({}, {}, {}, ssh, {});
service.handleTask = async () => {};
service.getDb = async () => ({ ...docs[0] });
service.updateDb = async (value) => {
Object.assign(docs[0], value);
return { ...docs[0] };
};
await service.setSshConfig();
return { service, root };
}
test('editing an SSH subscription removes the old alias key and configuration', async (t) => {
const initial = privateSubscription('owner_repo_main');
const f = await sshLifecycleFixture(t, initial);
const alias = 'owner_repo_feature/中文';
await f.service.update({ ...initial, alias });
const mapped = paths.getSubscriptionSshAlias(alias);
assert.equal(fs.existsSync(path.join(f.root, initial.alias)), false);
assert.equal(fs.existsSync(path.join(f.root, initial.alias + '.config')), false);
assert.equal(fs.readFileSync(path.join(f.root, mapped), 'utf8').trim(), 'fixture-key');
assert.ok(fs.existsSync(path.join(f.root, mapped + '.config')));
});
test('switching an SSH subscription to a public repository removes obsolete credentials', async (t) => {
const initial = privateSubscription('owner_repo.config');
const f = await sshLifecycleFixture(t, initial);
await f.service.update({ ...initial, type: 'public-repo' });
assert.deepEqual(fs.readdirSync(f.root), []);
});
test('ordinary and forced subscription deletion remove the current SSH key and configuration', async (t) => {
for (const force of [false, true]) {
const initial = privateSubscription('owner_repo_feature/中文');
const f = await sshLifecycleFixture(t, initial);
await f.service.remove([initial.id], { force });
assert.deepEqual(fs.readdirSync(f.root), []);
}
});
test('ordinary deletion cancels invalid historical SSH subscriptions by ID', async (t) => {
for (const schedule_type of ['crontab', 'interval']) {
const f = cancellationFixture(t, [
{
...privateSubscription('../outside'),
schedule_type,
interval_schedule: { type: 'hours', value: 1 },
},
]);
await f.service.remove([1], { force: false });
assert.deepEqual(f.events, [
[schedule_type === 'crontab' ? 'cancelCron' : 'cancelInterval', 1],
['destroy', [1]],
]);
}
});
test('invalid historical SSH rows do not block mixed batch deletion or disabling', async (t) => {
const docs = [
{ ...privateSubscription('healthy', 1), schedule_type: 'crontab' },
{ ...privateSubscription('../outside', 2), schedule_type: 'crontab' },
{
...privateSubscription('old alias', 3),
schedule_type: 'interval',
interval_schedule: { type: 'hours', value: 1 },
},
];
const cancellations = [
['cancelCron', 1],
['cancelCron', 2],
['cancelInterval', 3],
];
const removed = cancellationFixture(t, docs);
await removed.service.remove([1, 2, 3], {});
assert.deepEqual(removed.events, [...cancellations, ['destroy', [1, 2, 3]]]);
const disabled = cancellationFixture(t, docs);
await disabled.service.disabled([1, 2, 3]);
assert.deepEqual(disabled.events, cancellations);
assert.ok(disabled.docs.every((doc) => doc.is_disabled === 1));
});
test('cancellation bypass leaves alias validation for creation and forced deletion intact', async (t) => {
const doc = {
...privateSubscription('../outside'),
schedule_type: 'crontab',
};
const f = cancellationFixture(t, [doc]);
await assert.rejects(f.service.handleTask(doc), /Invalid subscription alias/);
await assert.rejects(f.service.create(doc), /Invalid subscription alias/);
await assert.rejects(f.service.enabled([1]), /Invalid subscription alias/);
await assert.rejects(
f.service.remove([1], { force: true }),
/Invalid subscription alias/,
);
assert.deepEqual(f.events, []);
});
test('valid subscription scheduling still creates quoted commands for both schedule types', async (t) => {
for (const schedule_type of ['crontab', 'interval']) {
const doc = {
...privateSubscription('owner_repo_feature/foo'),
schedule_type,
interval_schedule: { type: 'hours', value: 1 },
};
const f = cancellationFixture(t, [doc]);
await f.service.handleTask(doc, true, true);
const command = formatCommand(doc);
assert.deepEqual(
f.events,
schedule_type === 'crontab'
? [
['cancelCron', 1],
['createCron', command, true],
]
: [
['cancelInterval', 1],
['createInterval', command, { hours: 1 }, true],
],
);
}
});
+102 -1
View File
@@ -48,7 +48,7 @@ function fixture(initial) {
},
'../shared/i18n': { t: (x) => x, tf: (x, y) => x.replace('%s', y) },
'../shared/clientIp': {
getClientIp: () => '127.0.0.1',
getClientIp: (request) => request.socket.remoteAddress,
normalizeClientIp: (x) => x,
},
ip2region: class {
@@ -246,3 +246,104 @@ test('default credentials with historical metadata still require initialization'
);
assert.equal(f.auth.token, '');
});
test('one source cannot lock out another or revive a persisted global lock', async () => {
const f = fixture({ ...initialized(), retries: 40, lastlogon: Date.now() });
const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } };
const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } };
for (let i = 0; i < 3; i++) {
assert.equal(
(await f.user.login({ username: 'owner', password: 'wrong' }, attacker))
.code,
400,
);
}
assert.equal(
(
await f.user.login(
{ username: 'owner', password: 'old-password' },
attacker,
)
).code,
410,
);
assert.equal(
(await f.user.login({ username: 'owner', password: 'old-password' }, owner))
.code,
200,
);
// Another client's successful login does not reset the attacker's own budget.
assert.equal(
(await f.user.login({ username: 'owner', password: 'wrong' }, attacker))
.code,
410,
);
await f.user.resetAuthInfo({ retries: 0 });
assert.equal(
(
await f.user.login(
{ username: 'owner', password: 'old-password' },
attacker,
)
).code,
200,
);
});
test('TOTP guesses from another source do not lock the owner out', async () => {
const secret = authenticator.generateSecret();
const f = fixture({
...initialized(),
twoFactorActivated: true,
twoFactorSecret: secret,
});
const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } };
const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } };
await f.user.login({ username: 'owner', password: 'old-password' }, owner);
const good = authenticator.generate(secret);
const bad = good === '000000' ? '111111' : '000000';
for (let i = 0; i < 3; i++) {
assert.equal(
(
await f.user.twoFactorLogin(
{ username: 'owner', password: 'old-password', code: bad },
attacker,
)
).code,
430,
);
}
assert.equal(
(
await f.user.twoFactorLogin(
{ username: 'owner', password: 'old-password', code: bad },
attacker,
)
).code,
410,
);
assert.equal(
(
await f.user.twoFactorLogin(
{ username: 'owner', password: 'old-password', code: good },
owner,
)
).code,
200,
);
});
test('source backoff is capped and expires after inactivity', () => {
const { LoginThrottle } = load(path.resolve('back/shared/loginThrottle.ts'));
const throttle = new LoginThrottle();
let now = 1;
for (let i = 0; i < 20; i++) {
throttle.fail('attacker', now);
const wait = throttle.retryAfter('attacker', now);
assert.ok(wait <= 300);
now += (wait + 1) * 1000;
}
assert.equal(throttle.retryAfter('owner', now), 0);
assert.equal(throttle.retryAfter('attacker', now + 15 * 60 * 1000), 0);
assert.equal(throttle.fail('attacker', now + 15 * 60 * 1000), 1);
});
+126
View File
@@ -0,0 +1,126 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const source = fs.readFileSync(
path.join(__dirname, '../../src/pages/diff/index.tsx'),
'utf8',
);
const ast = ts.createSourceFile(
'diff.tsx',
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const callbacks = new Map();
function visit(node) {
if (
ts.isVariableDeclaration(node) &&
['getConfig', 'updateConfig', 'getSample'].includes(node.name.getText(ast))
) {
callbacks.set(node.name.getText(ast), node.initializer.getText(ast));
}
ts.forEachChild(node, visit);
}
visit(ast);
function fixture(current, editorContent) {
const calls = [];
const values = {};
const context = {
current,
origin: 'sample/notify.js',
currentValue: '',
editorRef: {
current:
editorContent === undefined
? null
: {
getModel: () => ({ modified: { getValue: () => editorContent } }),
},
},
config: { apiPrefix: '/panel/api/' },
request: {
get: async (url) => {
calls.push({ method: 'GET', url });
return { code: 200, data: 'file content' };
},
post: async (url, body) => {
calls.push({ method: 'POST', url, body });
return { code: 200 };
},
},
setCurrentValue: (value) => {
values.current = value;
},
setOriginValue: (value) => {
values.origin = value;
},
message: {
success: (value) => {
values.message = value;
},
},
intl: { get: (value) => value },
};
vm.createContext(context);
for (const [name, initializer] of callbacks) {
vm.runInContext(
ts.transpile(`var ${name} = ${initializer}`, {
target: ts.ScriptTarget.ES2020,
}),
context,
);
}
return { context, calls, values };
}
for (const [target, filename] of [
['config.sh', null],
['data/scripts/sendNotify.js', 'sendNotify.js'],
['data/scripts/notify.py', 'notify.py'],
['data/scripts/子目录/a b.js', '子目录/a b.js'],
]) {
test(`diff tool reads and saves ${target} through its resource API`, async () => {
const { context, calls, values } = fixture(target, 'updated\r\n');
context.getConfig();
context.updateConfig();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
calls[0].url,
filename
? `/panel/api/scripts/detail?file=${encodeURIComponent(filename)}`
: '/panel/api/configs/detail?path=config.sh',
);
assert.equal(
calls[1].url,
filename ? '/panel/api/scripts' : '/panel/api/configs/save',
);
assert.equal(
calls[1].body[filename ? 'filename' : 'name'],
filename || target,
);
assert.equal(calls[1].body.content, 'updated\n');
assert.equal(values.current, 'file content');
assert.equal(values.message, '保存成功');
});
}
test('mobile diff tool can save an empty notification script and load its sample', async () => {
const { context, calls, values } = fixture('data/scripts/sendNotify.js');
context.updateConfig();
context.getSample();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(calls[0].url, '/panel/api/scripts');
assert.equal(calls[0].body.filename, 'sendNotify.js');
assert.equal(calls[0].body.content, '');
assert.equal(
calls[1].url,
'/panel/api/configs/detail?path=sample%2Fnotify.js',
);
assert.equal(values.origin, 'file content');
});