mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 00:42:01 +08:00
fix(security): reject apps scope grants in app API
This commit is contained in:
+2
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user