Files
qinglong/test/back/http-security.test.cjs
T
whyour 442ab2c616 fix(security): restrict task environment controls and app scopes (#3121)
* fix(security): restrict task environment controls and app scopes

* fix(security): preserve legacy scopes and add read-only access

* feat(security): add read-only scopes for remaining resources

* fix(security): reject apps scope grants in app API

* fix(security): protect system app and task file boundaries

* fix: block case-variant secret paths and task runner env overrides
2026-11-03 23:04:43 +08:00

562 lines
16 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 express = require('express');
const jwt = require('jsonwebtoken');
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', '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');
fs.writeFileSync(path.join(tmp, 'config/config.sh'), 'original');
const secret = 'http-security-test';
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
const auth = {
username: 'owner',
password: 'configured',
token: valid,
tokens: { desktop: [{ value: expired }] },
};
const configSource = fs.readFileSync(
path.join(__dirname, '../../back/config/index.ts'),
'utf8',
);
const whitelistSource = configSource
.slice(configSource.indexOf('apiWhiteList:'))
.match(/apiWhiteList:\s*\[([\s\S]*?)\]/)[1];
const config = {
api: { prefix: '/api' },
apiWhiteList: [...whitelistSource.matchAll(/['"]([^'"]+)['"]/g)].map(
(x) => x[1],
),
jwt: { secret },
rootPath: tmp,
dataPath: tmp,
configPath: path.join(tmp, 'config/'),
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',
};
const apps = [
{
scopes: ['configs'],
tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['configs:read'],
tokens: [{ value: 'config-read-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: ['scripts:read'],
tokens: [{ value: 'script-read-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['envs'],
tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['envs:read'],
tokens: [{ value: 'env-read-app', expiration: Date.now() / 1000 + 3600 }],
},
{
name: 'external-system',
scopes: ['system'],
tokens: [{ value: 'system-app', expiration: Date.now() / 1000 + 3600 }],
},
{
name: 'system',
scopes: ['system'],
tokens: [{ value: 'internal-system', expiration: Date.now() / 1000 + 3600 }],
},
];
for (const scope of ['crons', 'subscriptions', 'dependencies', 'logs', 'dashboard']) {
for (const suffix of ['', ':read']) {
apps.push({
scopes: [`${scope}${suffix}`],
tokens: [{
value: `${scope}${suffix}-app`,
expiration: Date.now() / 1000 + 3600,
}],
});
}
}
let resetCalls = 0;
let commandCalls = 0;
let envWriteCalls = 0;
let scopedMutationCalls = 0;
let initialized = 0;
const User = class {};
const Script = class {
getFile(folder, filename) {
return fs.promises.readFile(path.join(config.scriptPath, folder, filename), 'utf8');
}
checkFilePath(folder, filename) {
return path.join(config.scriptPath, folder || '', filename);
}
};
const script = new Script();
const user = {
initializeUser: async () => {
initialized++;
return { code: 200 };
},
getAuthInfo: async () => auth,
};
const mocks = {
'../config': config,
'../config/util': {
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': {
shareStore: { getAuthInfo: async () => auth, getApps: async () => apps },
},
'../config/serverEnv': { serveEnv: (_req, res) => res.end() },
'../services/user': User,
'../services/script': Script,
'../data/open': {},
'../data/system': {},
typedi: { Service: () => (x) => x },
};
const Config = load(path.join(__dirname, '../../back/services/config.ts'), {
...mocks,
typedi: { Service: () => (x) => x },
}).default;
const configService = new Config();
mocks['../services/config'] = Config;
const historyCache = new Map();
const History = load(
path.join(__dirname, '../../back/services/scriptHistory.ts'),
mocks,
historyCache,
).default;
const history = new History();
mocks.typedi = {
...mocks.typedi,
Container: {
get: (x) =>
x === User ? user : x === History ? history : x === Script ? script : configService,
},
};
mocks['../api'] = () => {
const router = express.Router();
load(path.join(__dirname, '../../back/api/user.ts'), mocks).default(router);
load(path.join(__dirname, '../../back/api/config.ts'), mocks).default(
router,
);
load(
path.join(__dirname, '../../back/api/script.ts'),
mocks,
historyCache,
).default(router);
router.get('/envs', (_req, res) => res.json({ code: 200 }));
router.post('/envs', (_req, res) => {
envWriteCalls++;
res.json({ code: 200 });
});
for (const route of [
'/crons', '/subscriptions', '/dependencies', '/logs', '/dashboard/overview',
]) {
router.get(route, (_req, res) => res.json({ code: 200 }));
}
for (const [method, route] of [
['put', '/crons/run'],
['put', '/subscriptions/run'],
['post', '/dependencies'],
['delete', '/logs'],
['post', '/dashboard/record'],
['get', '/crons/import'],
]) {
router[method](route, (_req, res) => {
scopedMutationCalls++;
res.json({ code: 200 });
});
}
router.post('/logs/download', (_req, res) =>
res.json({ code: 200, data: 'downloaded' }),
);
router.get('/system', (_req, res) => res.json({ code: 200 }));
router.put('/system/notify', (_req, res) => res.json({ code: 200 }));
router.put('/system/auth/reset', (_req, res) => {
resetCalls++;
res.json({ code: 200 });
});
router.put('/system/command-run', (_req, res) => {
commandCalls++;
res.json({ code: 200 });
});
return router;
};
const app = express();
load(path.join(__dirname, '../../back/loaders/express.ts'), mocks).default({
app,
});
const server = app.listen(0, '127.0.0.1');
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(tmp, { recursive: true, force: true });
});
await new Promise((resolve, reject) => {
server.on('listening', resolve);
server.on('error', reject);
});
const request = async (url, token, method = 'GET', body) => {
const response = await fetch(
`http://127.0.0.1:${server.address().port}${url}`,
{
method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
'Content-Type': 'application/json',
},
...(body ? { body: JSON.stringify(body) } : {}),
},
);
return { status: response.status, body: await response.json() };
};
for (const url of [
'/api/user/init',
'/open/user/init',
'/panel/api/user/init',
'/panel/open/user/init',
]) {
const response = await request(url, undefined, 'PUT', {
username: 'attacker',
password: 'changed',
});
assert.equal(response.body.code, 450, url);
}
assert.equal(initialized, 0);
assert.equal(
(
await request('/api/user/login', undefined, 'POST', {
username: 'x',
password: 'x'.repeat(20000),
})
).status,
413,
);
fs.writeFileSync(
path.join(tmp, 'upload/legacy.html'),
'<script>window.test=1</script>',
);
const legacyUpload = await fetch(
`http://127.0.0.1:${server.address().port}/api/static/legacy.html`,
);
assert.equal(legacyUpload.headers.get('x-content-type-options'), 'nosniff');
assert.equal(
legacyUpload.headers.get('content-security-policy'),
"sandbox; default-src 'none'",
);
for (const prefix of ['/api', '/open', '/panel/api', '/panel/open']) {
assert.equal(
(await request(`${prefix}/envs`, expired)).status,
401,
prefix,
);
assert.equal(
(await request(`${prefix}/envs`, valid)).body.code,
200,
prefix,
);
assert.equal((await request(`${prefix}/envs`)).status, 401, prefix);
}
assert.equal((await request('/open/envs', 'env-app')).body.code, 200);
assert.equal((await request('/open/envs', 'env-read-app')).body.code, 200);
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}/envs`, 'env-read-app', 'POST', [
{ name: 'SAFE_VALUE', value: 'kept' },
])).status,
401,
);
}
assert.equal(envWriteCalls, 0);
assert.equal(
(await request('/open/envs', 'env-app', 'POST', [
{ name: 'SAFE_VALUE', value: 'kept' },
])).body.code,
200,
);
assert.equal(envWriteCalls, 1);
for (const [scope, readPath, method, writePath] of [
['crons', '/crons', 'PUT', '/crons/run'],
['subscriptions', '/subscriptions', 'PUT', '/subscriptions/run'],
['dependencies', '/dependencies', 'POST', '/dependencies'],
['logs', '/logs', 'DELETE', '/logs'],
['dashboard', '/dashboard/overview', 'POST', '/dashboard/record'],
]) {
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}${readPath}`, `${scope}:read-app`)).body.code,
200,
);
assert.equal(
(await request(`${prefix}${writePath}`, `${scope}:read-app`, method))
.status,
401,
);
}
assert.equal(
(await request(`/open${writePath}`, `${scope}-app`, method)).body.code,
200,
);
}
assert.equal(scopedMutationCalls, 5);
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}/crons/import`, 'crons:read-app')).status,
401,
);
assert.equal(
(await request(`${prefix}/logs/download`, 'logs:read-app', 'POST'))
.body.data,
'downloaded',
);
}
const importedWithHead = await fetch(
`http://127.0.0.1:${server.address().port}/open/crons/import`,
{ method: 'HEAD', headers: { Authorization: 'Bearer crons:read-app' } },
);
assert.equal(importedWithHead.status, 401);
assert.equal(
(await request('/open/crons/import', 'crons-app')).body.code,
200,
);
assert.equal(scopedMutationCalls, 6);
assert.equal(
(await request('/open/configs/detail?path=normal.txt', 'env-app')).status,
401,
);
assert.equal(
(await request('/open/configs/detail?path=normal.txt', 'config-app')).body
.data,
'normal',
);
assert.equal(
(await request('/open/configs/detail?path=normal.txt', 'config-read-app'))
.body.data,
'normal',
);
for (const prefix of ['/open', '/panel/open']) {
for (const name of ['normal.txt', 'config.sh', 'task_before.sh']) {
assert.equal(
(await request(`${prefix}/configs/save`, 'config-read-app', 'POST', {
name,
content: 'untrusted',
})).status,
401,
);
}
}
assert.equal(fs.readFileSync(path.join(tmp, 'config/config.sh'), 'utf8'), 'original');
assert.equal((await request('/open/system', 'system-app')).body.code, 200);
assert.equal(
(await request('/open/system/notify', 'system-app', 'PUT', {
title: 'title',
content: 'content',
})).body.code,
200,
);
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}/system/auth/reset`, 'system-app', 'PUT', {
password: 'changed',
})).status,
401,
);
assert.equal(
(await request(`${prefix}/system/command-run`, 'system-app', 'PUT', {
command: 'id',
})).status,
401,
);
}
assert.equal(resetCalls, 0);
assert.equal(commandCalls, 0);
assert.equal(
(await request('/open/system/auth/reset', 'internal-system', 'PUT', {
retries: 0,
})).body.code,
200,
);
assert.equal(
(await request('/api/system/auth/reset', valid, 'PUT', { retries: 0 })).body.code,
200,
);
assert.equal(resetCalls, 2);
assert.equal(
(await request('/open/configs/detail?path=grpc/client.key', 'config-app'))
.body.code,
403,
);
assert.equal(
(
await request('/open/configs/save', 'config-app', 'POST', {
name: 'grpc/client.key',
content: 'changed',
})
).body.code,
403,
);
assert.equal(
fs.readFileSync(path.join(tmp, 'config/grpc/client.key'), 'utf8'),
'SENTINEL',
);
assert.equal(
(
await request('/Api/user/init', undefined, 'PUT', {
username: 'x',
password: 'y',
})
).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: 'config.sh',
content: 'owner config',
})).body.code,
200,
);
assert.equal(fs.readFileSync(path.join(tmp, 'config/config.sh'), 'utf8'), 'owner config');
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-read-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',
);
}
assert.equal(
(await request('/open/scripts/detail?file=test.js', 'script-read-app')).body
.data,
'authorized script',
);
const downloaded = await fetch(
`http://127.0.0.1:${server.address().port}/open/scripts/download`,
{
method: 'POST',
headers: {
Authorization: 'Bearer script-read-app',
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename: 'test.js' }),
},
);
assert.equal(downloaded.status, 200);
assert.equal(await downloaded.text(), 'authorized script');
for (const method of ['PUT', 'DELETE']) {
assert.equal(
(await request('/open/scripts', 'script-read-app', method, {
filename: 'test.js', content: 'unauthorized',
})).status,
401,
);
}
for (const path of ['run', 'stop', 'rename', 'history/restore']) {
assert.equal(
(await request(`/open/scripts/${path}`, 'script-read-app', 'PUT', {
filename: 'test.js', content: 'unauthorized',
})).status,
401,
);
}
});