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

This commit is contained in:
whyour
2026-11-03 01:21:50 +08:00
parent 866c7f4b98
commit 487b213dd8
4 changed files with 77 additions and 8 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
name: Joi.string().optional().allow('').disallow('system'),
scopes: Joi.array().items(Joi.string().required()),
scopes: Joi.array().items(Joi.string().required().invalid('apps')),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -47,7 +47,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
name: Joi.string().optional().allow(''),
scopes: Joi.array().items(Joi.string()),
scopes: Joi.array().items(Joi.string().invalid('apps')),
id: Joi.number().required(),
}),
}),
@@ -8,9 +8,9 @@ The catalogue covers 146 active routes in the current backend. `ql api routes --
常规使用 `ql login` 的应用凭据。scope 通常对应 /open 后首个路径段:crons、subscriptions、envs、configs、scripts、logs、dependencies、system、dashboard 等。旧 scope 保留原有权限;上述资源除 system 外可选用对应的 `:read` scope。`crons:read` 不包含会写入任务的 `GET /crons/import`;`scripts:read`、`logs:read` 包含使用 POST 的下载,不包含写入、删除或执行。`dashboard:read` 不允许 POST 统计上报。外部应用的 `system` scope 仅允许读取系统状态和发送通知。应用管理需要 apps;`GET /apps` 会返回 Client Secret,因此不提供 `apps:read`。user、health、update 等路由同样接受服务端权限校验。路由存在不代表现有应用已获授权。
认证步骤、环境变量优先级、应用登录、面板会话与双因素验证、退出行为,统一见 [认证参考](panel.md#authentication)。应用管理可用 apps 权限的应用或授权面板会话;QL_URL/QL_ACCESS_TOKEN 不保存、不刷新,不自动退回应用配置。
认证步骤、环境变量优先级、应用登录、面板会话与双因素验证、退出行为,统一见 [认证参考](panel.md#authentication)。应用管理可用已有 apps 权限的应用或授权面板会话;后端创建和更新应用时拒绝授予 apps 权限。QL_URL/QL_ACCESS_TOKEN 不保存、不刷新,不自动退回应用配置。
See the [authentication reference](panel.md#authentication) for application login, direct-token precedence, owner sessions/2FA and logout. App management accepts apps-scoped credentials or an authorized owner session. Direct tokens are not saved/refreshed and never fall back to application configuration.
See the [authentication reference](panel.md#authentication) for application login, direct-token precedence, owner sessions/2FA and logout. App management accepts existing apps-scoped credentials or an authorized owner session; the backend rejects granting apps scope when creating or updating an application. Direct tokens are not saved/refreshed and never fall back to application configuration.
Existing scopes retain their original access. Use the resource's `:read` scope for read-only access to crons, subscriptions, envs, configs, scripts, dependencies, logs, or dashboard. `crons:read` excludes the mutating `GET /crons/import`; `scripts:read` and `logs:read` include downloads (POST), but exclude writes, deletion, and execution. `dashboard:read` excludes statistics reporting (POST). External `system` applications may only read system status and send notifications. `GET /apps` returns Client Secrets, so there is no `apps:read` scope.
+4 -4
View File
@@ -28,12 +28,12 @@ const fs = require('node:fs/promises');
assert.equal(result.status, 0, args.slice(0, 2).join(' ') + ': ' + result.stderr);
return JSON.parse(result.stdout).data;
};
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps', '--show-secrets'], true);
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard', '--show-secrets'], true);
assert.ok(app.client_id && app.client_secret);
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: app.client_secret });
cli(['auth', 'status', '--scope', 'apps']);
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps']);
assert.ok(cli(['app', 'list']).every(item => !('client_secret' in item) && !('tokens' in item)));
cli(['auth', 'status', '--scope', 'crons']);
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard'], true);
assert.ok(cli(['app', 'list'], true).every(item => !('client_secret' in item) && !('tokens' in item)));
const task = cli(['task', 'create', '--name', 'cli-task', '--command', 'echo OPENAPI_FIXTURE', '--schedule', '0 0 * * *']);
cli(['task', 'update', String(task.id), '--name', 'cli-task-updated', '--command', 'echo OPENAPI_UPDATED', '--schedule', '0 1 * * *']);
cli(['task', 'disable', String(task.id)]); cli(['task', 'enable', String(task.id)]);
+69
View File
@@ -0,0 +1,69 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const express = require('express');
const { errors } = require('celebrate');
const path = require('node:path');
const load = require('../helpers/load-security-module.cjs');
test('application API rejects granting apps scope on create and update', async (t) => {
const calls = [];
class OpenService {}
const service = {
create: async (body) => {
calls.push(['create', body]);
return body;
},
update: async (body) => {
calls.push(['update', body]);
return body;
},
};
const route = load(path.join(__dirname, '../../back/api/open.ts'), {
'../services/open': OpenService,
typedi: { Container: { get: () => service } },
}).default;
const app = express();
app.use(express.json());
for (const prefix of ['/api', '/open']) {
const router = express.Router();
route(router);
app.use(prefix, router);
}
app.use(errors());
const server = app.listen(0, '127.0.0.1');
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
});
const request = async (prefix, method, body) => {
const response = await fetch(
`http://127.0.0.1:${server.address().port}${prefix}/apps`,
{
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
},
);
return response.status;
};
for (const prefix of ['/api', '/open']) {
assert.equal(
await request(prefix, 'POST', { name: 'new', scopes: ['crons', 'apps'] }),
400,
);
assert.equal(
await request(prefix, 'PUT', { id: 1, scopes: ['apps'] }),
400,
);
assert.equal(
await request(prefix, 'POST', { name: 'new', scopes: ['crons:read'] }),
200,
);
assert.equal(
await request(prefix, 'PUT', { id: 1, scopes: ['crons'] }),
200,
);
}
assert.equal(calls.length, 4);
});