mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-01 05:13:50 +08:00
fix: scope file access restrictions and honor custom data directories
This commit is contained in:
+3
-6
@@ -4,7 +4,6 @@ import { Logger } from 'winston';
|
|||||||
import config from '../config';
|
import config from '../config';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
import { join, basename } from 'path';
|
|
||||||
import { SAMPLE_FILES } from '../config/const';
|
import { SAMPLE_FILES } from '../config/const';
|
||||||
import { t } from '../shared/i18n';
|
import { t } from '../shared/i18n';
|
||||||
import ConfigService from '../services/config';
|
import ConfigService from '../services/config';
|
||||||
@@ -74,15 +73,13 @@ export default (app: Router) => {
|
|||||||
try {
|
try {
|
||||||
const { name, content } = req.body;
|
const { name, content } = req.body;
|
||||||
// Resolve path first to prevent traversal attacks
|
// Resolve path first to prevent traversal attacks
|
||||||
let basePath = config.configPath;
|
const scriptFile = name.startsWith('data/scripts/');
|
||||||
if (name.startsWith('data/scripts/')) {
|
const basePath = scriptFile ? config.scriptPath : config.configPath;
|
||||||
basePath = join(config.rootPath, 'data/scripts');
|
|
||||||
}
|
|
||||||
const cleanName = name.replace(/^data\/scripts\//, '');
|
const cleanName = name.replace(/^data\/scripts\//, '');
|
||||||
const normalized = resolveFileAccess(
|
const normalized = resolveFileAccess(
|
||||||
basePath,
|
basePath,
|
||||||
[cleanName],
|
[cleanName],
|
||||||
config.blackFileList,
|
scriptFile ? [] : config.blackFileList,
|
||||||
);
|
);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return res.send({ code: 403, message: t('文件无法访问') });
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default class ConfigService {
|
|||||||
const resolved = resolveFileAccess(
|
const resolved = resolveFileAccess(
|
||||||
scriptFile ? config.scriptPath : config.configPath,
|
scriptFile ? config.scriptPath : config.configPath,
|
||||||
[scriptFile ? filePath.slice('data/scripts/'.length) : filePath],
|
[scriptFile ? filePath.slice('data/scripts/'.length) : filePath],
|
||||||
config.blackFileList,
|
scriptFile ? [] : config.blackFileList,
|
||||||
);
|
);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return res.send({ code: 403, message: t('文件无法访问') });
|
return res.send({ code: 403, message: t('文件无法访问') });
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { resolveFileAccess } from '../shared/fileAccess';
|
import { resolveFileAccess } from '../shared/fileAccess';
|
||||||
import path from 'path';
|
|
||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
@@ -9,10 +8,6 @@ export default class LogService {
|
|||||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||||
|
|
||||||
public checkFilePath(filePath: string, fileName: string) {
|
public checkFilePath(filePath: string, fileName: string) {
|
||||||
return resolveFileAccess(
|
return resolveFileAccess(config.logPath, [filePath || '', fileName]);
|
||||||
config.logPath,
|
|
||||||
[filePath || '', fileName],
|
|
||||||
config.blackFileList,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
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');
|
||||||
|
|
||||||
|
function fixture(t, customData = false) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-access-scope-'));
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
const data = path.join(root, customData ? 'custom-data' : 'data');
|
||||||
|
const config = {
|
||||||
|
rootPath: root,
|
||||||
|
configPath: path.join(data, 'config'),
|
||||||
|
scriptPath: path.join(data, 'scripts'),
|
||||||
|
logPath: path.join(data, 'log'),
|
||||||
|
blackFileList: ['token.json', 'auth.json', 'grpc', 'env.js'],
|
||||||
|
};
|
||||||
|
for (const directory of [
|
||||||
|
config.configPath,
|
||||||
|
config.scriptPath,
|
||||||
|
config.logPath,
|
||||||
|
])
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
const secret = path.join(config.configPath, 'token.json');
|
||||||
|
fs.writeFileSync(secret, 'PANEL-SECRET');
|
||||||
|
for (const directory of [
|
||||||
|
config.configPath,
|
||||||
|
config.scriptPath,
|
||||||
|
config.logPath,
|
||||||
|
])
|
||||||
|
fs.symlinkSync(secret, path.join(directory, 'secret-link'));
|
||||||
|
const mocks = {
|
||||||
|
'../config': config,
|
||||||
|
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||||
|
'../config/util': {
|
||||||
|
getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'),
|
||||||
|
rmPath: (p) => fs.promises.rm(p),
|
||||||
|
removeAnsi: (s) => s,
|
||||||
|
},
|
||||||
|
'../shared/utils': {
|
||||||
|
writeFileWithLock: (p, s) => fs.promises.writeFile(p, s),
|
||||||
|
},
|
||||||
|
'../shared/i18n': { t: (s) => s },
|
||||||
|
'../config/const': {},
|
||||||
|
'../data/runningInstance': {
|
||||||
|
InstanceStatus: { running: 'running' },
|
||||||
|
RunningInstanceModel: { findOne: async () => null },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { config, mocks, secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(name, service, mocks) {
|
||||||
|
mocks.typedi = { Container: { get: () => service } };
|
||||||
|
const app = express.Router();
|
||||||
|
load(path.resolve(`back/api/${name}.ts`), mocks).default(app);
|
||||||
|
const router = app.stack.find((layer) => layer.name === 'router').handle;
|
||||||
|
return async (method, url, values) => {
|
||||||
|
const handler = router.stack
|
||||||
|
.find((layer) => layer.route?.path === url && layer.route.methods[method])
|
||||||
|
.route.stack.at(-1).handle;
|
||||||
|
let result;
|
||||||
|
await handler(
|
||||||
|
{ query: values, body: values },
|
||||||
|
{
|
||||||
|
send: (value) => {
|
||||||
|
result = value;
|
||||||
|
},
|
||||||
|
download: (p) => {
|
||||||
|
result = { code: 200, data: fs.readFileSync(p, 'utf8') };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const customData of [false, true]) {
|
||||||
|
test(`config script access scopes blacklist and uses the configured data directory (custom=${customData})`, async (t) => {
|
||||||
|
const { config, mocks, secret } = fixture(t, customData);
|
||||||
|
const Config = load(path.resolve('back/services/config.ts'), mocks).default;
|
||||||
|
mocks['../services/config'] = Config;
|
||||||
|
const invoke = api('config', new Config(), mocks);
|
||||||
|
const legacy = path.join(config.rootPath, 'data/scripts');
|
||||||
|
if (customData) {
|
||||||
|
fs.mkdirSync(legacy, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacy, 'notify.py'), 'legacy');
|
||||||
|
}
|
||||||
|
for (const filename of [
|
||||||
|
'token.json',
|
||||||
|
'auth.json',
|
||||||
|
'grpc/env.js',
|
||||||
|
'notify.py',
|
||||||
|
]) {
|
||||||
|
const file = path.join(config.scriptPath, filename);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, 'initial');
|
||||||
|
const name = `data/scripts/${filename}`;
|
||||||
|
assert.equal(
|
||||||
|
(await invoke('get', '/detail', { path: name })).data,
|
||||||
|
'initial',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await invoke('post', '/save', { name, content: 'updated' })).code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await invoke('get', '/detail', { path: name })).data,
|
||||||
|
'updated',
|
||||||
|
);
|
||||||
|
assert.equal(fs.readFileSync(file, 'utf8'), 'updated');
|
||||||
|
}
|
||||||
|
if (customData)
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(legacy, 'notify.py'), 'utf8'),
|
||||||
|
'legacy',
|
||||||
|
);
|
||||||
|
for (const name of [
|
||||||
|
'token.json',
|
||||||
|
'grpc/client.key',
|
||||||
|
'secret-link',
|
||||||
|
'data/scripts/secret-link',
|
||||||
|
'data/scripts/../config/token.json',
|
||||||
|
secret,
|
||||||
|
]) {
|
||||||
|
assert.equal(
|
||||||
|
(await invoke('get', '/detail', { path: name })).code,
|
||||||
|
403,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await invoke('post', '/save', { name, content: 'overwrite' })).code,
|
||||||
|
403,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
(await invoke('post', '/save', { name: 'normal.txt', content: 'normal' }))
|
||||||
|
.code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await invoke('get', '/detail', { path: 'normal.txt' })).data,
|
||||||
|
'normal',
|
||||||
|
);
|
||||||
|
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('log APIs allow configuration-like names while retaining directory boundaries', async (t) => {
|
||||||
|
const { config, mocks, secret } = fixture(t);
|
||||||
|
const Log = load(path.resolve('back/services/log.ts'), mocks).default;
|
||||||
|
mocks['../services/log'] = Log;
|
||||||
|
const invoke = api('log', new Log(), mocks);
|
||||||
|
for (const filename of ['token.json', 'grpc/run.log', 'env.js/run.log']) {
|
||||||
|
const file = path.join(config.logPath, filename);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, 'log content');
|
||||||
|
const values = {
|
||||||
|
path: path.dirname(filename) === '.' ? '' : path.dirname(filename),
|
||||||
|
file: path.basename(filename),
|
||||||
|
filename: path.basename(filename),
|
||||||
|
};
|
||||||
|
assert.equal((await invoke('get', '/detail', values)).data, 'log content');
|
||||||
|
assert.equal(
|
||||||
|
(await invoke('post', '/download', values)).data,
|
||||||
|
'log content',
|
||||||
|
);
|
||||||
|
assert.equal((await invoke('delete', '/', values)).code, 200);
|
||||||
|
assert.equal(fs.existsSync(file), false);
|
||||||
|
}
|
||||||
|
for (const filename of ['../config/token.json', 'secret-link', secret]) {
|
||||||
|
const values = { path: '', file: filename, filename };
|
||||||
|
for (const [method, route] of [
|
||||||
|
['get', '/detail'],
|
||||||
|
['post', '/download'],
|
||||||
|
['delete', '/'],
|
||||||
|
])
|
||||||
|
assert.equal((await invoke(method, route, values)).code, 403, filename);
|
||||||
|
}
|
||||||
|
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user