diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index 561bc1d9..1afa6e88 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -94,8 +94,8 @@ const messages: Record> = { '日志设置为忽略': 'Log set to ignore', '定时规则不能为空': 'Schedule rule cannot be empty', '无效的定时规则': 'Invalid schedule rule', - '日志名称只能包含字母、数字、下划线和连字符': - 'Log name can only contain letters, numbers, underscores, and hyphens', + '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符': + 'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators', '日志名称不能超过100个字符': 'Log name cannot exceed 100 characters', '错误的用户名密码,请重试': 'Incorrect username or password, please try again', '该 IP 已被列入黑名单': 'This IP address has been blocked', diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index 0e1df1b7..ac0632c5 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -75,7 +75,7 @@ export const commonCronSchema = { } if ( - !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test( + !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w\p{Script=Han}.-]+\/)*[\w\p{Script=Han}.-]+\/?$/u.test( value, ) ) { @@ -87,7 +87,7 @@ export const commonCronSchema = { return value; }) .messages({ - 'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符', + '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 5d6d11e7..dd38d0b6 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -309,7 +309,7 @@ "日志删除频率": "Log Deletion Frequency", "日志名称": "Log Name", "日志名称不能超过100个字符": "Log name cannot exceed 100 characters", - "日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens", + "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators", "日志文件": "Log files", "日志管理": "Log Management", "时": "hour(s)", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index b0493a74..667d9b9d 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -308,7 +308,7 @@ "日志删除频率": "日志删除频率", "日志名称": "日志名称", "日志名称不能超过100个字符": "日志名称不能超过100个字符", - "日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符", + "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符", "日志文件": "日志文件", "日志管理": "日志管理", "时": "时", diff --git a/src/pages/crontab/modal.tsx b/src/pages/crontab/modal.tsx index 3313f458..cf9f9582 100644 --- a/src/pages/crontab/modal.tsx +++ b/src/pages/crontab/modal.tsx @@ -212,12 +212,14 @@ const CronModal = ({ return Promise.reject(intl.get('日志名称不能超过100个字符')); } if ( - !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test( + !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w\p{Script=Han}.-]+\/)*[\w\p{Script=Han}.-]+\/?$/u.test( value, ) ) { return Promise.reject( - intl.get('日志名称只能包含字母、数字、下划线和连字符'), + intl.get( + '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符', + ), ); } return Promise.resolve(); diff --git a/test/back/cron-log-name.test.cjs b/test/back/cron-log-name.test.cjs new file mode 100644 index 00000000..2c7c721b --- /dev/null +++ b/test/back/cron-log-name.test.cjs @@ -0,0 +1,110 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const ts = require('typescript'); +const loadModule = require('../helpers/load-security-module.cjs'); + +const { commonCronSchema } = loadModule( + path.join(__dirname, '../../back/validation/schedule.ts'), + { '../config': { logPath: '/ql/data/log/' } }, +); + +// Execute the actual form validator without mounting the entire task modal. +const source = ts.createSourceFile( + 'modal.tsx', + fs.readFileSync( + path.join(__dirname, '../../src/pages/crontab/modal.tsx'), + 'utf8', + ), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +let validateForm; +function visit(node) { + if ( + ts.isJsxOpeningElement(node) && + node.attributes.properties.some( + (attr) => + ts.isJsxAttribute(attr) && + attr.name.text === 'name' && + attr.initializer?.text === 'log_name', + ) + ) { + const rules = node.attributes.properties.find( + (attr) => ts.isJsxAttribute(attr) && attr.name.text === 'rules', + ); + const validator = rules.initializer.expression.elements[0].properties.find( + (property) => property.name.text === 'validator', + ).initializer; + const { outputText } = ts.transpileModule( + `const validate = ${validator.getText(source)};`, + { compilerOptions: { target: ts.ScriptTarget.ES2017 } }, + ); + validateForm = new Function('intl', `${outputText}\nreturn validate;`)({ + get: (key) => key, + }); + } + ts.forEachChild(node, visit); +} +visit(source); +assert.equal(typeof validateForm, 'function'); + +test('form and API accept Chinese log names and existing supported names', async () => { + for (const value of [ + '', + null, + undefined, + '测试', + '任务_測試-2026.log', + '分组/每日签到/', + '𠮷/扩展汉字', + 'legacy_name-123.log', + 'legacy/path/', + '/ql/data/log/中文日志', + '/dev/null', + '中'.repeat(100), + ]) { + await validateForm(undefined, value); + assert.equal( + commonCronSchema.log_name.validate(value).error, + undefined, + value, + ); + } +}); + +test('form and API still reject unsafe relative names and excessive length', async () => { + for (const value of [ + '.', + '..', + '../测试', + '测试/../日志', + '测试/./日志', + '测试//日志', + '测试\\日志', + '测试 日志', + '测试;echo', + '测试$(id)', + '测试`id`', + '测试\n日志', + '测试\0日志', + '测试😀', + '中'.repeat(101), + ]) { + await assert.rejects(validateForm(undefined, value), undefined, value); + assert.ok(commonCronSchema.log_name.validate(value).error, value); + } +}); + +test('API restricts absolute log paths to the configured directory or /dev/null', () => { + for (const value of [ + '/tmp/测试', + '/ql/data/log/../测试', + '/ql/data/log-other/测试', + ]) { + const { error } = commonCronSchema.log_name.validate(value); + assert.equal(error.details[0].type, 'string.unsafePath', value); + } +});