fix: secure file routes and dependency management

This commit is contained in:
whyour
2026-08-29 19:10:30 +08:00
parent 0e975d1d6d
commit 5f6049d80a
13 changed files with 525 additions and 79 deletions
+5 -7
View File
@@ -98,13 +98,11 @@ export default (app: Router) => {
route.get( route.get(
'/:file', '/:file',
async (req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response) => {
try { return res.send({
const configService = Container.get(ConfigService); code: 410,
await configService.getFile(req.params.file, res); message: t('接口已下线,请使用 /configs/detail 接口'),
} catch (e) { });
return next(e);
}
}, },
); );
}; };
+5 -19
View File
@@ -5,7 +5,6 @@ import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import { import {
getFileContentByName,
readDirs, readDirs,
removeAnsi, removeAnsi,
rmPath, rmPath,
@@ -89,24 +88,11 @@ export default (app: Router) => {
route.get( route.get(
'/:file', '/:file',
async (req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response) => {
try { return res.send({
const logService = Container.get(LogService); code: 410,
const finalPath = logService.checkFilePath( message: t('接口已下线,请使用 /logs/detail 接口'),
(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);
}
}, },
); );
+5 -19
View File
@@ -103,25 +103,11 @@ export default (app: Router) => {
route.get( route.get(
'/:file', '/:file',
celebrate({ (req: Request, res: Response) => {
params: Joi.object({ return res.send({
file: Joi.string().required(), code: 410,
}), message: t('接口已下线,请使用 /scripts/detail 接口'),
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);
}
}, },
); );
+4 -19
View File
@@ -14,6 +14,7 @@ import { AuthInfo } from '../data/system';
import path from 'path'; import path from 'path';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import { AppScope } from '../data/open'; import { AppScope } from '../data/open';
import protectedPathCase from '../middlewares/protectedPathCase';
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) { function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
const setting = value?.trim(); const setting = value?.trim();
@@ -36,25 +37,9 @@ export default ({ app }: { app: Application }) => {
app.set('trust proxy', resolveTrustProxy()); app.set('trust proxy', resolveTrustProxy());
app.use(cors()); app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks // Security: Reject case variations under protected API namespaces before
app.use((req, res, next) => { // authentication checks can interpret the request differently from routing.
const originalPath = req.path; app.use(protectedPathCase);
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();
});
// Rewrite URLs to strip baseUrl prefix if configured // Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness // This allows the rest of the app to work without baseUrl awareness
+23
View File
@@ -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();
}
+69
View File
@@ -16,6 +16,7 @@ import {
getPid, getPid,
killTask, killTask,
promiseExecSuccess, promiseExecSuccess,
concurrentRun,
getInstallCommand, getInstallCommand,
getUninstallCommand, getUninstallCommand,
getGetCommand, getGetCommand,
@@ -110,6 +111,11 @@ export default class DependenceService {
query: any = {}, query: any = {},
): Promise<Dependence[]> { ): Promise<Dependence[]> {
let condition = query; let condition = query;
const dependenceType =
type && DependenceTypes[type] !== undefined
? DependenceTypes[type]
: undefined;
await this.refreshInstalledStatuses(dependenceType);
if (type && DependenceTypes[type] !== undefined) { if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type]; 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( public installDependenceOneByOne(
docs: Dependence[], docs: Dependence[],
isInstall: boolean = true, isInstall: boolean = true,
+12 -4
View File
@@ -609,10 +609,18 @@ export default class SystemService {
if (!type || !['node', 'python3'].includes(type)) { if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: t('参数错误') }; return { code: 400, message: t('参数错误') };
} }
try { const finalPath = path.join(config.dependenceCachePath, type);
const finalPath = path.join(config.dependenceCachePath, type); await fs.promises.rm(finalPath, { recursive: true, force: true });
await fs.promises.rm(finalPath, { recursive: true }); await DependenceModel.update(
} catch (error) { } { status: DependenceStatus.installFailed },
{
where: {
type:
type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3,
status: DependenceStatus.installed,
},
},
);
return { code: 200 }; return { code: 200 };
} }
} }
+2 -2
View File
@@ -65,7 +65,7 @@
} }
}, },
"overrides": { "overrides": {
"sqlite3": "npm:@whyour/sqlite3@1.1.0", "sqlite3": "npm:@whyour/sqlite3@1.1.2",
"@codemirror/state": "6.5.4", "@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16" "@codemirror/view": "6.39.16"
} }
@@ -109,7 +109,7 @@
"request-ip": "3.3.0", "request-ip": "3.3.0",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"sockjs": "^0.3.24", "sockjs": "^0.3.24",
"sqlite3": "npm:@whyour/sqlite3@1.1.0", "sqlite3": "npm:@whyour/sqlite3@1.1.2",
"toad-scheduler": "^3.0.1", "toad-scheduler": "^3.0.1",
"typedi": "^0.10.0", "typedi": "^0.10.0",
"undici": "^7.9.0", "undici": "^7.9.0",
+9 -9
View File
@@ -1,7 +1,7 @@
lockfileVersion: '6.0' lockfileVersion: '6.0'
overrides: overrides:
sqlite3: npm:@whyour/sqlite3@1.1.0 sqlite3: npm:@whyour/sqlite3@1.1.2
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16 '@codemirror/view': 6.39.16
@@ -116,13 +116,13 @@ dependencies:
version: 3.3.0 version: 3.3.0
sequelize: sequelize:
specifier: ^6.37.5 specifier: ^6.37.5
version: 6.37.5(@whyour/sqlite3@1.1.0) version: 6.37.5(@whyour/sqlite3@1.1.2)
sockjs: sockjs:
specifier: ^0.3.24 specifier: ^0.3.24
version: 0.3.24 version: 0.3.24
sqlite3: sqlite3:
specifier: npm:@whyour/sqlite3@1.1.0 specifier: npm:@whyour/sqlite3@1.1.2
version: /@whyour/sqlite3@1.1.0 version: /@whyour/sqlite3@1.1.2
toad-scheduler: toad-scheduler:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.1 version: 3.0.1
@@ -3190,7 +3190,7 @@ packages:
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==} resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
dependencies: dependencies:
sqlite3: /@whyour/sqlite3@1.1.0 sqlite3: /@whyour/sqlite3@1.1.2
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
- supports-color - supports-color
@@ -5614,8 +5614,8 @@ packages:
- supports-color - supports-color
dev: true dev: true
/@whyour/sqlite3@1.1.0: /@whyour/sqlite3@1.1.2:
resolution: {integrity: sha512-5g9xsjkv9sUepCvBBKmev6JSKSKjp0ebpW25uSsuV5kurIYrd7AnkbffNMNIswxwsQJSLPewVEX5ROsQfETfnA==} resolution: {integrity: sha512-OY24/OJMqLHIGolo4GCFW4lrJKAoOB+ZqERXb7u7DKZQz7QR3f0GALE99BZWQBSZEVVZ/StVeAb2fCkMfTtuDQ==}
requiresBuild: true requiresBuild: true
peerDependenciesMeta: peerDependenciesMeta:
node-gyp: node-gyp:
@@ -13939,7 +13939,7 @@ packages:
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
dev: false dev: false
/sequelize@6.37.5(@whyour/sqlite3@1.1.0): /sequelize@6.37.5(@whyour/sqlite3@1.1.2):
resolution: {integrity: sha512-10WA4poUb3XWnUROThqL2Apq9C2NhyV1xHPMZuybNMCucDsbbFuKg51jhmyvvAUyUqCiimwTZamc3AHhMoBr2Q==} resolution: {integrity: sha512-10WA4poUb3XWnUROThqL2Apq9C2NhyV1xHPMZuybNMCucDsbbFuKg51jhmyvvAUyUqCiimwTZamc3AHhMoBr2Q==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
peerDependencies: peerDependencies:
@@ -13984,7 +13984,7 @@ packages:
retry-as-promised: 7.0.4 retry-as-promised: 7.0.4
semver: 7.6.3 semver: 7.6.3
sequelize-pool: 7.1.0 sequelize-pool: 7.1.0
sqlite3: /@whyour/sqlite3@1.1.0 sqlite3: /@whyour/sqlite3@1.1.2
toposort-class: 1.0.1 toposort-class: 1.0.1
uuid: 8.3.2 uuid: 8.3.2
validator: 13.12.0 validator: 13.12.0
+125
View File
@@ -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);
});
+82
View File
@@ -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} 接口`,
});
});
}
+58
View File
@@ -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,
});
}
});
+126
View File
@@ -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,
},
},
},
]);
});