From 6e8b974d77f19a29ab24d6a65bdfc22ec4439a2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Nov 2025 10:26:21 +0000 Subject: [PATCH] Restrict absolute paths to log directory except /dev/null Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/services/cron.ts | 37 ++++++++++++++++++++++++++++++++----- back/validation/schedule.ts | 20 +++++++++++++++++++- src/locales/en-US.json | 2 ++ src/locales/zh-CN.json | 2 ++ src/pages/crontab/modal.tsx | 13 +++++++++---- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/back/services/cron.ts b/back/services/cron.ts index 8f598b34..cbae3cc0 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -478,7 +478,7 @@ export default class CronService { 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('/'); let uniqPath: string; @@ -486,10 +486,37 @@ export default class CronService { let logPath: string; if (isAbsolutePath) { - // Use absolute path directly for special files like /dev/null - uniqPath = log_name!; - absolutePath = log_name!; - logPath = log_name!; + // 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!; + absolutePath = log_name!; + logPath = log_name!; + } + } } else { // Sanitize log_name to prevent path traversal for relative paths const sanitizedLogName = log_name diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index 8d0a4f54..3a429ed1 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -1,6 +1,8 @@ import { Joi } from 'celebrate'; import cron_parser from 'cron-parser'; import { ScheduleType } from '../interface/schedule'; +import path from 'path'; +import config from '../config'; const validateSchedule = (value: string, helpers: any) => { if ( @@ -43,10 +45,25 @@ export const commonCronSchema = { .allow(null) .custom((value, helpers) => { if (!value) return value; - // Allow absolute paths like /dev/null + + // Check if it's an absolute path if (value.startsWith('/')) { + // Allow /dev/null as special case + if (value === '/dev/null') { + 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 if (!/^[a-zA-Z0-9_-]+$/.test(value)) { return helpers.error('string.pattern.base'); @@ -59,5 +76,6 @@ export const commonCronSchema = { .messages({ 'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符', 'string.max': '日志名称不能超过100个字符', + 'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null', }), }; diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 3412934e..7d1e7675 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -525,8 +525,10 @@ "日志名称": "Log Name", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "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 /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 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", "日志名称不能超过100个字符": "Log name cannot exceed 100 characters" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 0a7d81b9..97e97f20 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -525,8 +525,10 @@ "日志名称": "日志名称", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null", + "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内", "请输入自定义日志文件夹名称": "请输入自定义日志文件夹名称", "请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径", + "请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null", "日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符", "日志名称不能超过100个字符": "日志名称不能超过100个字符" } diff --git a/src/pages/crontab/modal.tsx b/src/pages/crontab/modal.tsx index 79fb781d..d010adbe 100644 --- a/src/pages/crontab/modal.tsx +++ b/src/pages/crontab/modal.tsx @@ -184,14 +184,19 @@ const CronModal = ({ name="log_name" label={intl.get('日志名称')} tooltip={intl.get( - '自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null', + '自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内', )} rules={[ { validator: (_, value) => { if (!value) return Promise.resolve(); - // Allow absolute paths - if (value.startsWith('/')) return Promise.resolve(); + // Allow /dev/null specifically + 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 if (!/^[a-zA-Z0-9_-]+$/.test(value)) { return Promise.reject( @@ -207,7 +212,7 @@ const CronModal = ({ ]} >