From 93d7ec69dc5099d477e526c0e800078805f68274 Mon Sep 17 00:00:00 2001 From: whyour Date: Wed, 30 Sep 2026 20:31:20 +0800 Subject: [PATCH] fix: scope config file blacklist to config directory (#3082) --- back/api/script.ts | 17 ++- back/services/script.ts | 6 +- test/back/script-file-access.test.cjs | 199 ++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 test/back/script-file-access.test.cjs diff --git a/back/api/script.ts b/back/api/script.ts index 99a63ad3..69dc5f82 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -17,7 +17,16 @@ const route = Router(); function isPathAllowed(targetPath: string): boolean { const resolved = path.resolve(targetPath); return config.writePathList.some((x) => - Boolean(resolveFileAccess(x, [resolved], config.blackFileList)), + Boolean( + resolveFileAccess( + x, + [resolved], + // Panel configuration secrets must not restrict user script filenames. + path.resolve(x) === path.resolve(config.configPath) + ? config.blackFileList + : [], + ), + ), ); } @@ -55,11 +64,7 @@ export default (app: Router) => { ]; if (req.query.path) { if ( - !resolveFileAccess( - config.scriptPath, - [req.query.path as string], - config.blackFileList, - ) + !resolveFileAccess(config.scriptPath, [req.query.path as string]) ) { return res.send({ code: 403, message: t('暂无权限') }); } diff --git a/back/services/script.ts b/back/services/script.ts index 207fd0a1..5caa6116 100644 --- a/back/services/script.ts +++ b/back/services/script.ts @@ -66,11 +66,7 @@ export default class ScriptService { } public checkFilePath(filePath: string, fileName: string) { - return resolveFileAccess( - config.scriptPath, - [filePath || '', fileName], - config.blackFileList, - ); + return resolveFileAccess(config.scriptPath, [filePath || '', fileName]); } public async getFile(filePath: string, fileName: string) { diff --git a/test/back/script-file-access.test.cjs b/test/back/script-file-access.test.cjs new file mode 100644 index 00000000..e1efe25c --- /dev/null +++ b/test/back/script-file-access.test.cjs @@ -0,0 +1,199 @@ +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('script file operations allow token.json while protecting panel configuration', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-script-access-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + for (const dir of [ + 'config/grpc', + 'scripts/ZaiZaiCat-Checkin', + 'scripts/token.json', + 'bak', + 'tmp', + ]) + fs.mkdirSync(path.join(root, dir), { recursive: true }); + const config = { + scriptPath: path.join(root, 'scripts'), + configPath: path.join(root, 'config'), + tmpPath: path.join(root, 'tmp'), + bakPath: path.join(root, 'bak'), + blackFileList: ['token.json', 'auth.json', 'grpc'], + }; + config.writePathList = [config.configPath, config.scriptPath]; + const secret = path.join(config.configPath, 'token.json'); + fs.writeFileSync(secret, 'PANEL-SECRET'); + fs.symlinkSync(secret, path.join(config.scriptPath, 'secret-link')); + fs.symlinkSync( + config.configPath, + path.join(config.scriptPath, 'config-link'), + ); + fs.symlinkSync(secret, path.join(config.configPath, 'secret-alias')); + const mocks = { + '../config': config, + '../config/const': {}, + '../config/util': { + getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'), + fileExist: async (p) => fs.existsSync(p), + rmPath: (p) => fs.promises.rm(p, { recursive: true }), + readDir: async () => [], + }, + '../shared/utils': { + writeFileWithLock: (p, content) => fs.promises.writeFile(p, content), + }, + '../shared/i18n': { t: (x) => x }, + './sock': {}, + './cron': {}, + './schedule': {}, + '../shared/pLimit': {}, + typedi: { Service: () => (x) => x, Inject: () => () => {} }, + }; + const Script = load( + path.join(__dirname, '../../back/services/script.ts'), + mocks, + ).default; + const service = new Script(); + mocks['../services/script'] = Script; + mocks.typedi = { Container: { get: () => service } }; + const app = express.Router(); + load(path.join(__dirname, '../../back/api/script.ts'), mocks).default(app); + const router = app.stack.find((layer) => layer.name === 'router').handle; + const invoke = async (method, url, body = {}, query = {}, file) => { + const route = router.stack.find( + (layer) => layer.route?.path === url && layer.route.methods[method], + ).route; + let result; + await route.stack.at(-1).handle( + { body, query, file }, + { + send: (value) => { + result = value; + }, + download: (p) => { + result = { code: 200, data: fs.readFileSync(p, 'utf8') }; + }, + }, + (error) => { + throw error; + }, + ); + return result; + }; + for (const directory of ['', 'ZaiZaiCat-Checkin']) { + const filename = directory ? 'token.json' : 'auth.json'; + const body = { + path: directory, + filename, + content: '{"account":"initial"}', + }; + assert.equal((await invoke('post', '/', body)).code, 200); + assert.deepEqual( + await invoke('get', '/detail', {}, { path: directory, file: filename }), + { code: 200, data: body.content }, + ); + assert.equal( + (await invoke('put', '/', { ...body, content: '{"account":"updated"}' })) + .code, + 200, + ); + assert.deepEqual(await invoke('post', '/download', body), { + code: 200, + data: '{"account":"updated"}', + }); + assert.equal( + (await invoke('put', '/rename', { ...body, newFilename: 'renamed.json' })) + .code, + 200, + ); + assert.equal( + ( + await invoke('put', '/rename', { + ...body, + filename: 'renamed.json', + newFilename: filename, + }) + ).code, + 200, + ); + assert.equal((await invoke('delete', '/', body)).code, 200); + } + assert.equal( + (await invoke('get', '/', {}, { path: 'token.json' })).code, + 200, + ); + const upload = path.join(config.tmpPath, 'upload'); + fs.writeFileSync(upload, 'uploaded'); + assert.equal( + ( + await invoke( + 'post', + '/', + { filename: 'token.json', path: 'ZaiZaiCat-Checkin' }, + {}, + { path: upload }, + ) + ).code, + 200, + ); + assert.equal( + await service.getFile('ZaiZaiCat-Checkin', 'token.json'), + 'uploaded', + ); + for (const filename of [ + '../config/token.json', + secret, + 'secret-link', + 'config-link/token.json', + ]) { + assert.equal(service.checkFilePath('', filename), '', filename); + assert.equal( + (await invoke('post', '/download', { filename })).code, + 403, + filename, + ); + assert.equal( + (await invoke('put', '/', { filename, content: 'changed' })).code, + 403, + filename, + ); + if (!path.isAbsolute(filename)) + assert.equal( + (await invoke('post', '/', { filename, content: 'changed' })).code, + 403, + filename, + ); + } + for (const filename of [ + 'token.json', + 'auth.json', + 'grpc/client.key', + 'secret-alias', + ]) { + assert.equal( + ( + await invoke('post', '/', { + path: config.configPath, + filename, + content: 'changed', + }) + ).code, + 403, + filename, + ); + } + assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET'); + assert.equal( + ( + await invoke('post', '/', { + path: config.configPath, + filename: 'normal.txt', + content: 'normal', + }) + ).code, + 200, + ); +});