diff --git a/back/api/open.ts b/back/api/open.ts index 8e59b58e..5f97b100 100644 --- a/back/api/open.ts +++ b/back/api/open.ts @@ -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(), }), }), diff --git a/cli/skills/qinglong-cli/references/openapi.md b/cli/skills/qinglong-cli/references/openapi.md index 4cd5fd70..eed52fc2 100644 --- a/cli/skills/qinglong-cli/references/openapi.md +++ b/cli/skills/qinglong-cli/references/openapi.md @@ -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. diff --git a/cli/test/linux/openapi.cjs b/cli/test/linux/openapi.cjs index 20271f65..ea4bdb6b 100644 --- a/cli/test/linux/openapi.cjs +++ b/cli/test/linux/openapi.cjs @@ -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)]); diff --git a/test/back/app-scope-security.test.cjs b/test/back/app-scope-security.test.cjs new file mode 100644 index 00000000..a48cb309 --- /dev/null +++ b/test/back/app-scope-security.test.cjs @@ -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); +});