diff --git a/back/api/config.ts b/back/api/config.ts index 23084cfe..6eb4d997 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -98,13 +98,11 @@ export default (app: Router) => { route.get( '/:file', - async (req: Request, res: Response, next: NextFunction) => { - try { - const configService = Container.get(ConfigService); - await configService.getFile(req.params.file, res); - } catch (e) { - return next(e); - } + (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /configs/detail 接口'), + }); }, ); }; diff --git a/back/api/log.ts b/back/api/log.ts index aed4708c..1820f4cc 100644 --- a/back/api/log.ts +++ b/back/api/log.ts @@ -5,7 +5,6 @@ import { Logger } from 'winston'; import config from '../config'; import { t } from '../shared/i18n'; import { - getFileContentByName, readDirs, removeAnsi, rmPath, @@ -89,24 +88,11 @@ export default (app: Router) => { route.get( '/:file', - async (req: Request, res: Response, next: NextFunction) => { - try { - const logService = Container.get(LogService); - const finalPath = logService.checkFilePath( - (req.query.path as string) || '', - (req.params.file as string) || '', - ); - if (!finalPath || blacklist.includes(req.query.path as string)) { - return res.send({ - code: 403, - message: t('暂无权限'), - }); - } - const content = await getFileContentByName(finalPath); - res.send({ code: 200, data: content }); - } catch (e) { - return next(e); - } + (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /logs/detail 接口'), + }); }, ); diff --git a/back/api/script.ts b/back/api/script.ts index 9be941d6..11ba9137 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -103,25 +103,11 @@ export default (app: Router) => { route.get( '/:file', - celebrate({ - params: Joi.object({ - file: Joi.string().required(), - }), - query: Joi.object({ - path: Joi.string().optional().allow(''), - }).unknown(true), - }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const scriptService = Container.get(ScriptService); - const content = await scriptService.getFile( - req.query?.path as string || '', - req.params.file, - ); - res.send({ code: 200, data: content }); - } catch (e) { - return next(e); - } + (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /scripts/detail 接口'), + }); }, ); diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 676e59ed..12bca534 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -14,6 +14,7 @@ import { AuthInfo } from '../data/system'; import path from 'path'; import { t } from '../shared/i18n'; import { AppScope } from '../data/open'; +import protectedPathCase from '../middlewares/protectedPathCase'; function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) { const setting = value?.trim(); @@ -36,25 +37,9 @@ export default ({ app }: { app: Application }) => { app.set('trust proxy', resolveTrustProxy()); app.use(cors()); - // Security: Path normalization middleware to prevent case variation attacks - app.use((req, res, next) => { - const originalPath = req.path; - const normalizedPath = originalPath.toLowerCase(); - - // Block requests with case variations on protected paths - if ( - originalPath !== normalizedPath && - (normalizedPath.startsWith('/api/') || - normalizedPath.startsWith('/open/')) - ) { - return res.status(400).json({ - code: 400, - message: 'Invalid path format', - }); - } - - next(); - }); + // Security: Reject case variations under protected API namespaces before + // authentication checks can interpret the request differently from routing. + app.use(protectedPathCase); // Rewrite URLs to strip baseUrl prefix if configured // This allows the rest of the app to work without baseUrl awareness diff --git a/back/middlewares/protectedPathCase.ts b/back/middlewares/protectedPathCase.ts new file mode 100644 index 00000000..21d2b62e --- /dev/null +++ b/back/middlewares/protectedPathCase.ts @@ -0,0 +1,23 @@ +import type { NextFunction, Request, Response } from 'express'; + +export default function protectedPathCase( + req: Request, + res: Response, + next: NextFunction, +) { + const originalPath = req.path; + const normalizedPath = originalPath.toLowerCase(); + + if ( + originalPath !== normalizedPath && + (normalizedPath.startsWith('/api/') || + normalizedPath.startsWith('/open/')) + ) { + return res.status(400).json({ + code: 400, + message: 'Invalid path format', + }); + } + + return next(); +} diff --git a/back/services/dependence.ts b/back/services/dependence.ts index d384ec76..c33ff43f 100644 --- a/back/services/dependence.ts +++ b/back/services/dependence.ts @@ -16,6 +16,7 @@ import { getPid, killTask, promiseExecSuccess, + concurrentRun, getInstallCommand, getUninstallCommand, getGetCommand, @@ -110,6 +111,11 @@ export default class DependenceService { query: any = {}, ): Promise { let condition = query; + const dependenceType = + type && DependenceTypes[type] !== undefined + ? DependenceTypes[type] + : undefined; + await this.refreshInstalledStatuses(dependenceType); if (type && DependenceTypes[type] !== undefined) { condition.type = DependenceTypes[type]; } @@ -132,6 +138,69 @@ export default class DependenceService { } } + private async refreshInstalledStatuses(type?: DependenceTypes) { + const cacheDependenceTypes = [ + DependenceTypes.nodejs, + DependenceTypes.python3, + ]; + if (type !== undefined && !cacheDependenceTypes.includes(type)) { + return; + } + + const docs = await DependenceModel.findAll({ + where: { + status: DependenceStatus.installed, + type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type, + }, + }); + const checks = await concurrentRun( + docs.map((doc) => async () => { + return (await this.isDependenceInstalled(doc)) ? undefined : doc.id; + }), + 5, + ); + const missingIds = (checks || []).filter( + (id): id is number => id !== undefined, + ); + + if (missingIds.length) { + await DependenceModel.update( + { status: DependenceStatus.installFailed }, + { where: { id: missingIds } }, + ); + } + } + + private async isDependenceInstalled(dependency: Dependence) { + let depName = dependency.name.trim(); + const depVersionStr = versionDependenceCommandTypes[dependency.type]; + let depVersion = ''; + if (depName.includes(depVersionStr)) { + const symbolRegx = new RegExp( + `(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`, + ); + const [, parsedName, parsedVersion] = depName.match(symbolRegx) || []; + if (parsedVersion && parsedName) { + depName = parsedName; + depVersion = parsedVersion; + } + } + + const depInfo = ( + await promiseExecSuccess(getGetCommand(dependency.type, depName)) + ) + .replace(/\s{2,}/, ' ') + .replace(/\s+$/, ''); + const nameMatches = + (dependency.type === DependenceTypes.nodejs && + depInfo.split(' ')?.[0] === depName) || + dependency.type === DependenceTypes.python3; + + return Boolean( + depInfo && nameMatches && (!depVersion || depInfo.includes(depVersion)), + ); + } + public installDependenceOneByOne( docs: Dependence[], isInstall: boolean = true, diff --git a/back/services/system.ts b/back/services/system.ts index 9cb644b9..6bfec73e 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -609,10 +609,18 @@ export default class SystemService { if (!type || !['node', 'python3'].includes(type)) { return { code: 400, message: t('参数错误') }; } - try { - const finalPath = path.join(config.dependenceCachePath, type); - await fs.promises.rm(finalPath, { recursive: true }); - } catch (error) { } + const finalPath = path.join(config.dependenceCachePath, type); + await fs.promises.rm(finalPath, { recursive: true, force: true }); + await DependenceModel.update( + { status: DependenceStatus.installFailed }, + { + where: { + type: + type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3, + status: DependenceStatus.installed, + }, + }, + ); return { code: 200 }; } } diff --git a/package.json b/package.json index a068f533..56ff1e32 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ } }, "overrides": { - "sqlite3": "npm:@whyour/sqlite3@1.1.0", + "sqlite3": "npm:@whyour/sqlite3@1.1.2", "@codemirror/state": "6.5.4", "@codemirror/view": "6.39.16" } @@ -109,7 +109,7 @@ "request-ip": "3.3.0", "sequelize": "^6.37.5", "sockjs": "^0.3.24", - "sqlite3": "npm:@whyour/sqlite3@1.1.0", + "sqlite3": "npm:@whyour/sqlite3@1.1.2", "toad-scheduler": "^3.0.1", "typedi": "^0.10.0", "undici": "^7.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c273f87d..c4247703 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,7 +1,7 @@ lockfileVersion: '6.0' overrides: - sqlite3: npm:@whyour/sqlite3@1.1.0 + sqlite3: npm:@whyour/sqlite3@1.1.2 '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.16 @@ -116,13 +116,13 @@ dependencies: version: 3.3.0 sequelize: specifier: ^6.37.5 - version: 6.37.5(@whyour/sqlite3@1.1.0) + version: 6.37.5(@whyour/sqlite3@1.1.2) sockjs: specifier: ^0.3.24 version: 0.3.24 sqlite3: - specifier: npm:@whyour/sqlite3@1.1.0 - version: /@whyour/sqlite3@1.1.0 + specifier: npm:@whyour/sqlite3@1.1.2 + version: /@whyour/sqlite3@1.1.2 toad-scheduler: specifier: ^3.0.1 version: 3.0.1 @@ -3190,7 +3190,7 @@ packages: resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==} engines: {node: '>= 18'} dependencies: - sqlite3: /@whyour/sqlite3@1.1.0 + sqlite3: /@whyour/sqlite3@1.1.2 transitivePeerDependencies: - encoding - supports-color @@ -5614,8 +5614,8 @@ packages: - supports-color dev: true - /@whyour/sqlite3@1.1.0: - resolution: {integrity: sha512-5g9xsjkv9sUepCvBBKmev6JSKSKjp0ebpW25uSsuV5kurIYrd7AnkbffNMNIswxwsQJSLPewVEX5ROsQfETfnA==} + /@whyour/sqlite3@1.1.2: + resolution: {integrity: sha512-OY24/OJMqLHIGolo4GCFW4lrJKAoOB+ZqERXb7u7DKZQz7QR3f0GALE99BZWQBSZEVVZ/StVeAb2fCkMfTtuDQ==} requiresBuild: true peerDependenciesMeta: node-gyp: @@ -13939,7 +13939,7 @@ packages: engines: {node: '>= 10.0.0'} dev: false - /sequelize@6.37.5(@whyour/sqlite3@1.1.0): + /sequelize@6.37.5(@whyour/sqlite3@1.1.2): resolution: {integrity: sha512-10WA4poUb3XWnUROThqL2Apq9C2NhyV1xHPMZuybNMCucDsbbFuKg51jhmyvvAUyUqCiimwTZamc3AHhMoBr2Q==} engines: {node: '>=10.0.0'} peerDependencies: @@ -13984,7 +13984,7 @@ packages: retry-as-promised: 7.0.4 semver: 7.6.3 sequelize-pool: 7.1.0 - sqlite3: /@whyour/sqlite3@1.1.0 + sqlite3: /@whyour/sqlite3@1.1.2 toposort-class: 1.0.1 uuid: 8.3.2 validator: 13.12.0 diff --git a/test/back/dependence-cache-status.test.cjs b/test/back/dependence-cache-status.test.cjs new file mode 100644 index 00000000..a23cd30a --- /dev/null +++ b/test/back/dependence-cache-status.test.cjs @@ -0,0 +1,125 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +test('dependency listing marks cache entries missing from disk for reinstall', async (t) => { + const moduleStubs = new Map(); + const stubModule = (modulePath, exports) => { + const resolved = require.resolve(modulePath); + moduleStubs.set(resolved, require.cache[resolved]); + require.cache[resolved] = { + id: resolved, + filename: resolved, + loaded: true, + exports, + children: [], + paths: [], + }; + }; + + const DependenceStatus = { + installing: 0, + installed: 1, + installFailed: 2, + }; + const DependenceTypes = { nodejs: 0, python3: 1, linux: 2 }; + const docs = [ + { id: 1, name: 'missing-package', type: 0, status: 1 }, + { id: 2, name: 'present-package', type: 0, status: 1 }, + ]; + const updates = []; + const DependenceModel = { + findAll: async ({ where }) => { + if (where.status === DependenceStatus.installed) { + return docs; + } + return docs.map((doc) => ({ + ...doc, + status: updates.some((update) => update.ids.includes(doc.id)) + ? DependenceStatus.installFailed + : doc.status, + })); + }, + update: async ({ status }, { where }) => { + updates.push({ status, ids: where.id }); + }, + }; + + stubModule('../../back/config', { + __esModule: true, + default: {}, + }); + stubModule('../../back/data/dependence', { + Dependence: class Dependence {}, + DependenceModel, + DependenceStatus, + DependenceTypes, + versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' }, + }); + stubModule('../../back/config/util', { + concurrentRun: async (tasks) => Promise.all(tasks.map((task) => task())), + detectOS: async () => 'Alpine', + fileExist: async () => false, + getGetCommand: (_type, name) => `check:${name}`, + getInstallCommand: () => '', + getPid: async () => 0, + getUninstallCommand: () => '', + killTask: async () => {}, + promiseExecSuccess: async (command) => + command === 'check:present-package' ? 'present-package 1.0.0\n' : '', + }); + stubModule('../../back/config/const', { + LINUX_DEPENDENCE_COMMAND: { Alpine: {} }, + }); + stubModule('../../back/shared/pLimit', { + __esModule: true, + default: {}, + }); + stubModule('../../back/shared/i18n', { + t: (message) => message, + tf: (message) => message, + }); + stubModule('../../back/services/sock', { + __esModule: true, + default: class SockService {}, + }); + + const servicePath = require.resolve('../../back/services/dependence'); + const originalService = require.cache[servicePath]; + delete require.cache[servicePath]; + t.after(() => { + if (originalService) { + require.cache[servicePath] = originalService; + } else { + delete require.cache[servicePath]; + } + for (const [resolved, original] of moduleStubs) { + if (original) { + require.cache[resolved] = original; + } else { + delete require.cache[resolved]; + } + } + }); + + const DependenceService = require('../../back/services/dependence').default; + const service = new DependenceService({}, {}); + const result = await service.dependencies({ + searchValue: '', + type: 'nodejs', + status: '', + }); + + assert.deepEqual(updates, [ + { status: DependenceStatus.installFailed, ids: [1] }, + ]); + assert.deepEqual( + result.map(({ id, status }) => ({ id, status })), + [ + { id: 1, status: DependenceStatus.installFailed }, + { id: 2, status: DependenceStatus.installed }, + ], + ); + + await service.dependencies({ searchValue: '', type: 'linux', status: '' }); + assert.equal(updates.length, 1); +}); diff --git a/test/back/deprecated-file-routes.test.cjs b/test/back/deprecated-file-routes.test.cjs new file mode 100644 index 00000000..76330db5 --- /dev/null +++ b/test/back/deprecated-file-routes.test.cjs @@ -0,0 +1,82 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const express = require('express'); + +function mockModule(modulePath, exports) { + const filename = require.resolve(modulePath); + require.cache[filename] = { + id: filename, + filename, + loaded: true, + exports, + children: [], + paths: [], + }; +} + +mockModule('../../back/config', { + __esModule: true, + default: { + bakPath: '/tmp', + blackFileList: [], + configPath: '/tmp', + logPath: '/tmp', + logs: { level: 'info' }, + rootPath: '/tmp', + scriptPath: '/tmp', + systemLogPath: '/tmp', + writePathList: ['/tmp'], + }, +}); +mockModule('../../back/shared/i18n', { + t: (message) => message, +}); +mockModule('../../back/config/util', { + fileExist: async () => false, + readDir: async () => [], + readDirs: async () => [], + removeAnsi: (content) => content, + rmPath: async () => {}, +}); +mockModule('../../back/shared/utils', { + writeFileWithLock: async () => {}, +}); +for (const service of ['config', 'script', 'log']) { + mockModule(`../../back/services/${service}`, { + __esModule: true, + default: class {}, + }); +} +mockModule('../../back/data/runningInstance', { + InstanceStatus: { running: 'running' }, + RunningInstanceModel: { findOne: async () => null }, +}); + +const deprecatedRoutes = [ + ['config', '/configs/detail'], + ['script', '/scripts/detail'], + ['log', '/logs/detail'], +]; + +for (const [moduleName, replacement] of deprecatedRoutes) { + test(`${moduleName} filename route points callers to its detail API`, () => { + const app = express.Router(); + require(`../../back/api/${moduleName}`).default(app); + const router = app.stack.find((layer) => layer.name === 'router').handle; + const deprecatedRoute = router.stack.find( + (layer) => layer.route?.path === '/:file', + ); + const handler = deprecatedRoute.route.stack.at(-1).handle; + let responseBody; + + handler( + { params: { file: 'Example.js' }, query: {} }, + { send: (body) => (responseBody = body) }, + ); + + assert.deepEqual(responseBody, { + code: 410, + message: `接口已下线,请使用 ${replacement} 接口`, + }); + }); +} diff --git a/test/back/protected-path-case.test.cjs b/test/back/protected-path-case.test.cjs new file mode 100644 index 00000000..f4e290bc --- /dev/null +++ b/test/back/protected-path-case.test.cjs @@ -0,0 +1,58 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const protectedPathCase = require( + '../../back/middlewares/protectedPathCase', +).default; + +function runMiddleware(path) { + let status; + let body; + let nextCalled = false; + const response = { + status(value) { + status = value; + return this; + }, + json(value) { + body = value; + return this; + }, + }; + + protectedPathCase({ path }, response, () => { + nextCalled = true; + }); + + return { status, body, nextCalled }; +} + +test('rejects case variations under protected API namespaces', () => { + for (const path of [ + '/Api/configs/detail', + '/api/Configs/detail', + '/OPEN/scripts/detail', + '/open/scripts/Detail', + ]) { + assert.deepEqual(runMiddleware(path), { + status: 400, + body: { code: 400, message: 'Invalid path format' }, + nextCalled: false, + }); + } +}); + +test('allows normalized protected paths and unrelated paths', () => { + for (const path of [ + '/api/configs/detail', + '/open/scripts/detail', + '/OpenApi/status', + '/assets/AppBundle.js', + ]) { + assert.deepEqual(runMiddleware(path), { + status: undefined, + body: undefined, + nextCalled: true, + }); + } +}); diff --git a/test/back/system-dependence-cache.test.cjs b/test/back/system-dependence-cache.test.cjs new file mode 100644 index 00000000..c06dd3fb --- /dev/null +++ b/test/back/system-dependence-cache.test.cjs @@ -0,0 +1,126 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +test('clearing a dependency cache marks installed entries for reinstall', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-dependence-')); + const nodeCache = path.join(directory, 'node'); + await fs.mkdir(nodeCache); + await fs.writeFile(path.join(nodeCache, 'package'), 'cached'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + + const moduleStubs = new Map(); + const stubModule = (modulePath, exports) => { + const resolved = require.resolve(modulePath); + moduleStubs.set(resolved, require.cache[resolved]); + require.cache[resolved] = { + id: resolved, + filename: resolved, + loaded: true, + exports, + children: [], + paths: [], + }; + }; + const updates = []; + const DependenceStatus = { installed: 1, installFailed: 2 }; + const DependenceTypes = { nodejs: 0, python3: 1 }; + + stubModule('../../back/config', { + __esModule: true, + default: { dependenceCachePath: directory }, + }); + stubModule('../../back/config/const', { + NotificationModeStringMap: {}, + TASK_COMMAND: 'task', + }); + stubModule('../../back/config/util', { + getPid: async () => 0, + killTask: async () => {}, + parseContentVersion: () => '', + parseVersion: () => '', + promiseExec: async () => '', + readDirs: async () => [], + rmPath: async () => {}, + setSystemTimezone: async () => true, + updateLinuxMirrorFile: async () => {}, + }); + stubModule('../../back/data/dependence', { + DependenceModel: { + update: async (values, options) => updates.push({ values, options }), + }, + DependenceStatus, + DependenceTypes, + }); + stubModule('../../back/data/notify', {}); + stubModule('../../back/data/system', { + AuthDataType: {}, + SystemModel: {}, + }); + stubModule('../../back/shared/pLimit', { + __esModule: true, + default: {}, + }); + stubModule('../../back/shared/i18n', { + setLang: () => {}, + t: (message) => message, + }); + stubModule('../../back/services/notify', { + __esModule: true, + default: class NotificationService {}, + }); + stubModule('../../back/services/schedule', { + __esModule: true, + default: class ScheduleService {}, + }); + stubModule('../../back/services/sock', { + __esModule: true, + default: class SockService {}, + }); + + const servicePath = require.resolve('../../back/services/system'); + const originalService = require.cache[servicePath]; + delete require.cache[servicePath]; + t.after(() => { + if (originalService) { + require.cache[servicePath] = originalService; + } else { + delete require.cache[servicePath]; + } + for (const [resolved, original] of moduleStubs) { + if (original) { + require.cache[resolved] = original; + } else { + delete require.cache[resolved]; + } + } + }); + + const SystemService = require('../../back/services/system').default; + const service = new SystemService({}, {}, {}); + assert.deepEqual(await service.cleanDependence('node'), { code: 200 }); + assert.deepEqual(await service.cleanDependence('python3'), { code: 200 }); + await assert.rejects(fs.stat(nodeCache), { code: 'ENOENT' }); + assert.deepEqual(updates, [ + { + values: { status: DependenceStatus.installFailed }, + options: { + where: { + type: DependenceTypes.nodejs, + status: DependenceStatus.installed, + }, + }, + }, + { + values: { status: DependenceStatus.installFailed }, + options: { + where: { + type: DependenceTypes.python3, + status: DependenceStatus.installed, + }, + }, + }, + ]); +});