Restrict absolute paths to log directory except /dev/null

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-09 10:26:21 +00:00
parent 51bc0dd8b1
commit 6e8b974d77
5 changed files with 64 additions and 10 deletions

View File

@ -478,7 +478,7 @@ export default class CronService {
let { id, command, log_path, log_name } = cron; let { id, command, log_path, log_name } = cron;
// Check if log_name is an absolute path (e.g., /dev/null) // Check if log_name is an absolute path
const isAbsolutePath = log_name && log_name.startsWith('/'); const isAbsolutePath = log_name && log_name.startsWith('/');
let uniqPath: string; let uniqPath: string;
@ -486,10 +486,37 @@ export default class CronService {
let logPath: string; let logPath: string;
if (isAbsolutePath) { if (isAbsolutePath) {
// Use absolute path directly for special files like /dev/null // Special case: /dev/null is allowed as-is to discard logs
if (log_name === '/dev/null') {
uniqPath = log_name;
absolutePath = log_name;
logPath = log_name;
} else {
// For other absolute paths, ensure they are within the safe log directory
const normalizedLogName = path.normalize(log_name!);
const normalizedLogPath = path.normalize(config.logPath);
if (!normalizedLogName.startsWith(normalizedLogPath)) {
this.logger.error(
`[panel][日志路径安全检查失败] 绝对路径必须在日志目录内: ${log_name}`,
);
// Fallback to auto-generated path for security
const fallbackUniqPath = await getUniqPath(command, `${id}`);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${fallbackUniqPath}`);
if (log_path?.split('/')?.every((x) => x !== fallbackUniqPath)) {
await fs.mkdir(logDirPath, { recursive: true });
}
logPath = `${fallbackUniqPath}/${logTime}.log`;
absolutePath = path.resolve(config.logPath, `${logPath}`);
uniqPath = fallbackUniqPath;
} else {
// Absolute path is safe, use it
uniqPath = log_name!; uniqPath = log_name!;
absolutePath = log_name!; absolutePath = log_name!;
logPath = log_name!; logPath = log_name!;
}
}
} else { } else {
// Sanitize log_name to prevent path traversal for relative paths // Sanitize log_name to prevent path traversal for relative paths
const sanitizedLogName = log_name const sanitizedLogName = log_name

View File

@ -1,6 +1,8 @@
import { Joi } from 'celebrate'; import { Joi } from 'celebrate';
import cron_parser from 'cron-parser'; import cron_parser from 'cron-parser';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
const validateSchedule = (value: string, helpers: any) => { const validateSchedule = (value: string, helpers: any) => {
if ( if (
@ -43,10 +45,25 @@ export const commonCronSchema = {
.allow(null) .allow(null)
.custom((value, helpers) => { .custom((value, helpers) => {
if (!value) return value; if (!value) return value;
// Allow absolute paths like /dev/null
// Check if it's an absolute path
if (value.startsWith('/')) { if (value.startsWith('/')) {
// Allow /dev/null as special case
if (value === '/dev/null') {
return value; return value;
} }
// For other absolute paths, ensure they are within the safe log directory
const normalizedValue = path.normalize(value);
const normalizedLogPath = path.normalize(config.logPath);
if (!normalizedValue.startsWith(normalizedLogPath)) {
return helpers.error('string.unsafePath');
}
return value;
}
// For relative names, enforce strict pattern // For relative names, enforce strict pattern
if (!/^[a-zA-Z0-9_-]+$/.test(value)) { if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return helpers.error('string.pattern.base'); return helpers.error('string.pattern.base');
@ -59,5 +76,6 @@ export const commonCronSchema = {
.messages({ .messages({
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符', 'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
'string.max': '日志名称不能超过100个字符', 'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}), }),
}; };

View File

@ -525,8 +525,10 @@
"日志名称": "Log Name", "日志名称": "Log Name",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports absolute paths like /dev/null", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports absolute paths like /dev/null",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports /dev/null to discard logs, other absolute paths must be within log directory",
"请输入自定义日志文件夹名称": "Please enter a custom log folder name", "请输入自定义日志文件夹名称": "Please enter a custom log folder name",
"请输入自定义日志文件夹名称或绝对路径": "Please enter a custom log folder name or absolute path", "请输入自定义日志文件夹名称或绝对路径": "Please enter a custom log folder name or absolute path",
"请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens", "日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens",
"日志名称不能超过100个字符": "Log name cannot exceed 100 characters" "日志名称不能超过100个字符": "Log name cannot exceed 100 characters"
} }

View File

@ -525,8 +525,10 @@
"日志名称": "日志名称", "日志名称": "日志名称",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内",
"请输入自定义日志文件夹名称": "请输入自定义日志文件夹名称", "请输入自定义日志文件夹名称": "请输入自定义日志文件夹名称",
"请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径", "请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径",
"请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符", "日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符",
"日志名称不能超过100个字符": "日志名称不能超过100个字符" "日志名称不能超过100个字符": "日志名称不能超过100个字符"
} }

View File

@ -184,14 +184,19 @@ const CronModal = ({
name="log_name" name="log_name"
label={intl.get('日志名称')} label={intl.get('日志名称')}
tooltip={intl.get( tooltip={intl.get(
'自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null', '自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内',
)} )}
rules={[ rules={[
{ {
validator: (_, value) => { validator: (_, value) => {
if (!value) return Promise.resolve(); if (!value) return Promise.resolve();
// Allow absolute paths // Allow /dev/null specifically
if (value.startsWith('/')) return Promise.resolve(); if (value === '/dev/null') return Promise.resolve();
// Warn about other absolute paths (server will validate)
if (value.startsWith('/')) {
// We can't validate the exact path on frontend, but inform user
return Promise.resolve();
}
// For relative names, enforce strict pattern // For relative names, enforce strict pattern
if (!/^[a-zA-Z0-9_-]+$/.test(value)) { if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return Promise.reject( return Promise.reject(
@ -207,7 +212,7 @@ const CronModal = ({
]} ]}
> >
<Input <Input
placeholder={intl.get('请输入自定义日志文件夹名称或绝对路径')} placeholder={intl.get('请输入自定义日志文件夹名称或 /dev/null')}
maxLength={200} maxLength={200}
/> />
</Form.Item> </Form.Item>