From 2cab112391376258efbb0ce7ef689df7c10de8b2 Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 6 Oct 2026 18:21:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8A=A0=E5=9B=BA=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E8=BE=B9=E7=95=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=85=BC=E5=AE=B9=E6=80=A7=20(#3084)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: harden scoped API inputs and isolate login throttling * fix: close script path gaps and harden security boundaries * fix: preserve subscription aliases and avoid extension eval * fix: allow cancelling invalid historical subscriptions * fix: make script parent validation explicit for CodeQL * fix: limit command execution and clarify trusted path branches * fix: separate diff tool script access from config APIs * fix: clean up obsolete subscription SSH credentials --- back/api/config.ts | 13 +- back/api/env.ts | 31 +- back/api/open.ts | 2 + back/api/script.ts | 35 +- back/api/system.ts | 23 +- back/config/grpcCerts.ts | 138 ++-- back/config/subscription.ts | 49 +- back/loaders/initTask.ts | 10 +- back/services/config.ts | 9 +- back/services/cron.ts | 12 +- back/services/env.ts | 7 +- back/services/sshKey.ts | 52 +- back/services/subscription.ts | 46 +- back/services/system.ts | 30 +- back/services/user.ts | 27 +- back/shared/cronCommand.ts | 11 + back/shared/logPath.ts | 11 + back/shared/loginThrottle.ts | 47 ++ back/shared/shellQuote.ts | 5 + back/shared/subscriptionPath.ts | 45 ++ shell/update.sh | 28 +- src/pages/diff/index.tsx | 41 +- test/back/advisory-input-security.test.cjs | 415 ++++++++++++ test/back/code-scanning-security.test.cjs | 189 ++++++ test/back/draft-path-security.test.cjs | 234 +++++++ test/back/env-import-security.test.cjs | 91 +++ test/back/file-access-scope.test.cjs | 15 +- test/back/grpc-mtls-security.test.cjs | 135 ++++ test/back/http-security.test.cjs | 91 ++- test/back/stop-race.test.cjs | 6 +- .../subscription-review-regression.test.cjs | 607 ++++++++++++++++++ test/back/user-security.test.cjs | 103 ++- test/front/diff-file-routing.test.cjs | 126 ++++ 33 files changed, 2468 insertions(+), 216 deletions(-) create mode 100644 back/shared/cronCommand.ts create mode 100644 back/shared/logPath.ts create mode 100644 back/shared/loginThrottle.ts create mode 100644 back/shared/shellQuote.ts create mode 100644 back/shared/subscriptionPath.ts create mode 100644 test/back/advisory-input-security.test.cjs create mode 100644 test/back/code-scanning-security.test.cjs create mode 100644 test/back/draft-path-security.test.cjs create mode 100644 test/back/env-import-security.test.cjs create mode 100644 test/back/grpc-mtls-security.test.cjs create mode 100644 test/back/subscription-review-regression.test.cjs create mode 100644 test/front/diff-file-routing.test.cjs diff --git a/back/api/config.ts b/back/api/config.ts index 6313d59f..c7f6d3ad 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -72,14 +72,13 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const { name, content } = req.body; - // Resolve path first to prevent traversal attacks - const scriptFile = name.startsWith('data/scripts/'); - const basePath = scriptFile ? config.scriptPath : config.configPath; - const cleanName = name.replace(/^data\/scripts\//, ''); + if (name.startsWith('data/scripts/')) { + return res.send({ code: 403, message: t('文件无法访问') }); + } const normalized = resolveFileAccess( - basePath, - [cleanName], - scriptFile ? [] : config.blackFileList, + config.configPath, + [name], + config.blackFileList, ); if (!normalized) { return res.send({ code: 403, message: t('文件无法访问') }); diff --git a/back/api/env.ts b/back/api/env.ts index 894e4066..469088df 100644 --- a/back/api/env.ts +++ b/back/api/env.ts @@ -1,24 +1,16 @@ import { Joi, celebrate } from 'celebrate'; import { NextFunction, Request, Response, Router } from 'express'; -import fs from 'fs'; import multer from 'multer'; import { Container } from 'typedi'; import { Logger } from 'winston'; -import config from '../config'; -import { safeJSONParse } from '../config/util'; import { t } from '../shared/i18n'; import EnvService from '../services/env'; const route = Router(); -const storage = multer.diskStorage({ - destination: function (req, file, cb) { - cb(null, config.scriptPath); - }, - filename: function (req, file, cb) { - cb(null, file.originalname); - }, +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024, files: 1 }, }); -const upload = multer({ storage: storage }); const labelSchema = Joi.array() .items(Joi.string().trim().required()) .min(1) @@ -282,12 +274,23 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); - const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8'); - const parseContent = safeJSONParse(fileContent); + let parseContent: unknown; + try { + parseContent = JSON.parse(req.file?.buffer.toString('utf8') || ''); + } catch { + return res.status(400).send({ code: 400, message: t('参数错误') }); + } const data = Array.isArray(parseContent) ? parseContent : [parseContent]; - if (data.every((x) => x.name && x.value)) { + if ( + data.length > 0 && + data.every((x) => + x && typeof x.name === 'string' && + /^[a-zA-Z_][0-9a-zA-Z_]*$/.test(x.name) && + typeof x.value === 'string' && x.value.length > 0, + ) + ) { const result = await envService.create( data.map((x) => ({ name: x.name, diff --git a/back/api/open.ts b/back/api/open.ts index c704d961..8e59b58e 100644 --- a/back/api/open.ts +++ b/back/api/open.ts @@ -3,6 +3,7 @@ import { Container } from 'typedi'; import OpenService from '../services/open'; import { Logger } from 'winston'; import { celebrate, Joi } from 'celebrate'; +import { rateLimit } from 'express-rate-limit'; const route = Router(); export default (app: Router) => { @@ -100,6 +101,7 @@ export default (app: Router) => { route.get( '/auth/token', + rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }), celebrate({ query: { client_id: Joi.string().required(), diff --git a/back/api/script.ts b/back/api/script.ts index 69dc5f82..84f1a48e 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -7,7 +7,7 @@ import { Logger } from 'winston'; import config from '../config'; import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; -import path, { join, parse } from 'path'; +import path, { dirname, join, parse, resolve, sep } from 'path'; import ScriptService from '../services/script'; import { t } from '../shared/i18n'; import multer from 'multer'; @@ -200,7 +200,25 @@ export default (app: Router) => { if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) { return res.send({ code: 403, message: t('暂无权限') }); } - await fs.mkdir(path, { recursive: true }); + // Check the canonical parent directly at the directory creation boundary. + const parentPath = resolve(dirname(filePath)); + let parentCreated = false; + for (const writableRoot of config.writePathList) { + const root = resolve(writableRoot); + const rootPrefix = root.endsWith(sep) ? root : root + sep; + if (parentPath === root) { + await fs.mkdir(root, { recursive: true }); + } else if (parentPath.startsWith(rootPrefix)) { + await fs.mkdir(parentPath, { recursive: true }); + } else { + continue; + } + parentCreated = true; + break; + } + if (!parentCreated) { + return res.send({ code: 403, message: t('暂无权限') }); + } const fileExists = await fileExist(filePath); if (fileExists) { await fs.copyFile( @@ -373,12 +391,21 @@ export default (app: Router) => { if (!isPathAllowed(filePath)) { return res.send({ code: 403, message: t('暂无权限') }); } - const logPath = join(config.logPath, path, `${name}.swap`); + const logPath = resolveFileAccess(config.logPath, [ + path, + `${name}.swap`, + ]); + if (!logPath) { + return res.send({ code: 403, message: t('暂无权限') }); + } const scriptService = Container.get(ScriptService); const result = await scriptService.stopScript(filePath, pid); setTimeout(() => { - rmPath(logPath); + const cleanupPath = resolveFileAccess(config.logPath, [logPath]); + if (cleanupPath) { + void rmPath(cleanupPath); + } }, 3000); res.send(result); } catch (e) { diff --git a/back/api/system.ts b/back/api/system.ts index 831725a6..e58592e5 100644 --- a/back/api/system.ts +++ b/back/api/system.ts @@ -8,15 +8,12 @@ import { celebrate, Joi } from 'celebrate'; import UserService from '../services/user'; import { t } from '../shared/i18n'; import { isDefaultAuthInfo } from '../shared/auth'; -import { - getUniqPath, - handleLogPath, - parseVersion, - promiseExec, -} from '../config/util'; +import { getUniqPath, handleLogPath, parseVersion } from '../config/util'; +import { findCronId } from '../shared/cronCommand'; import dayjs from 'dayjs'; import multer from 'multer'; import { logStreamManager } from '../shared/logStreamManager'; +import { rateLimit } from 'express-rate-limit'; const route = Router(); const storage = multer.diskStorage({ @@ -249,6 +246,7 @@ export default (app: Router) => { route.put( '/command-run', + rateLimit({ windowMs: 60 * 1000, limit: 60 }), celebrate({ body: Joi.object({ command: Joi.string().required(), @@ -258,8 +256,13 @@ export default (app: Router) => { try { const systemService = Container.get(SystemService); const command = req.body.command; - const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`; - let id = await promiseExec(idStr); + const crontab = await fs + .readFile(config.crontabFile, 'utf8') + .catch((e) => { + if (e.code === 'ENOENT') return ''; + throw e; + }); + const id = findCronId(crontab, command); const uniqPath = await getUniqPath(command, id); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logPath = `${uniqPath}/${logTime}.log`; @@ -274,7 +277,9 @@ export default (app: Router) => { onEnd: async (cp, endTime, diff) => { // Close the stream after task completion try { - await logStreamManager.closeStream(await handleLogPath(logPath)); + await logStreamManager.closeStream( + await handleLogPath(logPath), + ); } finally { res.end(); } diff --git a/back/config/grpcCerts.ts b/back/config/grpcCerts.ts index 15bd88c5..b8c20abb 100644 --- a/back/config/grpcCerts.ts +++ b/back/config/grpcCerts.ts @@ -1,6 +1,5 @@ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import * as fs from 'fs/promises'; -import * as os from 'os'; import path from 'path'; import config from './index'; import { fileExist } from './util'; @@ -24,71 +23,120 @@ const clientCertPath = path.join(certDir, 'client.crt'); let cachedConfig: GrpcTlsConfig | null = null; -function run(cmd: string, execOpts?: Record): string { - const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any; - return (execSync(cmd, opts) as string).trim(); -} - -async function tmpFile(prefix: string): Promise { - const dir = (await fileExist(certDir)) ? certDir : os.tmpdir(); - await fs.mkdir(dir, { recursive: true }); - return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`); +function run(args: string[]): string { + return execFileSync('openssl', args, { + stdio: 'pipe', + timeout: 30000, + encoding: 'utf-8', + }).trim(); } async function generateAllCerts(): Promise { Logger.info('[boot] Generating gRPC mTLS certificates...'); - - const caKeyTmp = await tmpFile('ca_key'); - const caCertTmp = await tmpFile('ca_cert'); - const serverKeyTmp = await tmpFile('server_key'); - const serverCsrTmp = await tmpFile('server_csr'); - const serverExtTmp = await tmpFile('server_ext'); - const clientKeyTmp = await tmpFile('client_key'); - const clientCsrTmp = await tmpFile('client_csr'); - const clientExtTmp = await tmpFile('client_ext'); - const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl'); + await fs.mkdir(certDir, { recursive: true, mode: 0o700 }); + // Atomically reserve a private workspace for keys, CSRs and OpenSSL side files. + const workspace = await fs.mkdtemp(path.join(certDir, '.generate-')); + const caKeyTmp = path.join(workspace, 'ca.key'); + const caCertTmp = path.join(workspace, 'ca.crt'); + const serverKeyTmp = path.join(workspace, 'server.key'); + const serverCsrTmp = path.join(workspace, 'server.csr'); + const serverExtTmp = path.join(workspace, 'server.ext'); + const clientKeyTmp = path.join(workspace, 'client.key'); + const clientCsrTmp = path.join(workspace, 'client.csr'); + const clientExtTmp = path.join(workspace, 'client.ext'); const cleanup = async () => { - for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp, - clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) { - try { await fs.unlink(f); } catch {} - } + await fs.rm(workspace, { recursive: true, force: true }); }; try { - // 1. CA(私钥直接存盘,证书写入临时文件供签发使用) - run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`); - run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`); + run(['genrsa', '-out', caKeyTmp, '2048']); + run([ + 'req', + '-new', + '-x509', + '-days', + '3650', + '-key', + caKeyTmp, + '-out', + caCertTmp, + '-subj', + '/CN=qinglong-ca/O=qinglong/C=CN', + ]); const caKey = await fs.readFile(caKeyTmp, 'utf-8'); const caCert = await fs.readFile(caCertTmp, 'utf-8'); - await fs.mkdir(certDir, { recursive: true }); await fs.writeFile(caKeyPath, caKey, { mode: 0o600 }); - // 2. 服务端 - run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`); - run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`); - await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n'); - const serverCert = run( - `openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`, + run(['genrsa', '-out', serverKeyTmp, '2048']); + run([ + 'req', + '-new', + '-key', + serverKeyTmp, + '-out', + serverCsrTmp, + '-subj', + '/CN=grpc-server', + ]); + await fs.writeFile( + serverExtTmp, + 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n', ); + const serverCert = run([ + 'x509', + '-req', + '-days', + '3650', + '-in', + serverCsrTmp, + '-CA', + caCertTmp, + '-CAkey', + caKeyTmp, + '-CAserial', + path.join(workspace, 'ca.srl'), + '-CAcreateserial', + '-extfile', + serverExtTmp, + ]); const serverKey = await fs.readFile(serverKeyTmp, 'utf-8'); - // 3. 客户端 - run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`); - run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`); + run(['genrsa', '-out', clientKeyTmp, '2048']); + run([ + 'req', + '-new', + '-key', + clientKeyTmp, + '-out', + clientCsrTmp, + '-subj', + '/CN=grpc-client', + ]); await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n'); - const clientCert = run( - `openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`, - ); + const clientCert = run([ + 'x509', + '-req', + '-days', + '3650', + '-in', + clientCsrTmp, + '-CA', + caCertTmp, + '-CAkey', + caKeyTmp, + '-CAserial', + path.join(workspace, 'ca.srl'), + '-CAcreateserial', + '-extfile', + clientExtTmp, + ]); const clientKey = await fs.readFile(clientKeyTmp, 'utf-8'); - await cleanup(); Logger.info('[boot] gRPC mTLS certificates generated successfully'); - return { caCert, serverCert, serverKey, clientCert, clientKey }; - } catch (e) { + } finally { await cleanup(); - throw e; } } diff --git a/back/config/subscription.ts b/back/config/subscription.ts index 0973948b..88ce358e 100644 --- a/back/config/subscription.ts +++ b/back/config/subscription.ts @@ -1,5 +1,7 @@ import { Subscription } from '../data/subscription'; import isNil from 'lodash/isNil'; +import { shellQuote } from '../shared/shellQuote'; +import { getSubscriptionSshAlias } from '../shared/subscriptionPath'; export function formatUrl(doc: Subscription) { let url = doc.url; @@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) { if (doc.type === 'private-repo') { if (doc.pull_type === 'ssh-key') { host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1'); - url = doc.url!.replace(host, doc.alias); + url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias)); } else { host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1'); const { username, password } = doc.pull_option as any; @@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) { } export function formatCommand(doc: Subscription, url?: string) { - let command = `SUB_ID=${doc.id} ql `; - let _url = url || formatUrl(doc).url; - const { - type, - whitelist, - blacklist, - dependences, - branch, - extensions, - proxy, - autoAddCron, - autoDelCron, - } = doc; - if (type === 'file') { - command += `raw "${_url}" "${proxy || ''}" "${ - isNil(autoAddCron) ? true : Boolean(autoAddCron) - }" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; - } else { - command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${ - dependences || '' - }" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${ - isNil(autoAddCron) ? true : Boolean(autoAddCron) - }" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; - } - return command; + const args = + doc.type === 'file' + ? ['raw', url || formatUrl(doc).url, doc.proxy || ''] + : [ + 'repo', + url || formatUrl(doc).url, + doc.whitelist || '', + doc.blacklist || '', + doc.dependences || '', + doc.branch || '', + doc.extensions || '', + doc.proxy || '', + ]; + args.push( + String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)), + String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)), + ); + return `SUB_ID=${shellQuote(String(doc.id))} ql ${args + .map((arg) => shellQuote(arg!)) + .join(' ')}`; } diff --git a/back/loaders/initTask.ts b/back/loaders/initTask.ts index ed98c0a0..608f00e9 100644 --- a/back/loaders/initTask.ts +++ b/back/loaders/initTask.ts @@ -7,6 +7,8 @@ import config from '../config'; import { fileExist } from '../config/util'; import { join } from 'path'; import { t } from '../shared/i18n'; +import Logger from './logger'; +import { assertSubscriptionAlias } from '../shared/subscriptionPath'; export default async () => { const systemService = Container.get(SystemService); @@ -70,6 +72,12 @@ export default async () => { await subscriptionService.setSshConfig(); const subs = await subscriptionService.list(); for (const sub of subs) { - subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled); + try { + const doc = sub.get({ plain: true }); + assertSubscriptionAlias(doc.alias); + await subscriptionService.handleTask(doc, !sub.is_disabled); + } catch (error) { + Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error); + } } }; diff --git a/back/services/config.ts b/back/services/config.ts index 50becf1a..6410291b 100644 --- a/back/services/config.ts +++ b/back/services/config.ts @@ -12,14 +12,13 @@ export default class ConfigService { public async getFile(filePath: string, res: Response) { let content = ''; - if (!filePath) { + if (!filePath || filePath.startsWith('data/scripts/')) { return res.send({ code: 403, message: t('文件无法访问') }); } - const scriptFile = filePath.startsWith('data/scripts/'); const resolved = resolveFileAccess( - scriptFile ? config.scriptPath : config.configPath, - [scriptFile ? filePath.slice('data/scripts/'.length) : filePath], - scriptFile ? [] : config.blackFileList, + config.configPath, + [filePath], + config.blackFileList, ); if (!resolved) { return res.send({ code: 403, message: t('文件无法访问') }); diff --git a/back/services/cron.ts b/back/services/cron.ts index b89b617a..2adc6edd 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -7,6 +7,7 @@ import { import { Service, Inject } from 'typedi'; import winston from 'winston'; import config from '../config'; +import { resolveLogPath } from '../shared/logPath'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import { RunningInstanceModel, @@ -246,6 +247,9 @@ export default class CronService { last_execution_time: number; exit_code?: number; }) { + if (log_path && log_path !== '/dev/null') { + resolveLogPath(config.logPath, log_path); + } let options: any = { status, pid, @@ -925,7 +929,9 @@ export default class CronService { truncated: false, }; } - const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); + const absolutePath = doc.log_path + ? resolveLogPath(config.logPath, doc.log_path) + : ''; const logFileExist = doc.log_path && (await fileExist(absolutePath)); if (logFileExist) { const chunk = await readLogChunk(`${absolutePath}`, options); @@ -955,12 +961,12 @@ export default class CronService { public async logs(id: number) { const doc = await this.getDb({ id }); - if (!doc || !doc.log_path) { + if (!doc || !doc.log_path || doc.log_path === '/dev/null') { return []; } const relativeDir = path.dirname(`${doc.log_path}`); - const dir = path.resolve(config.logPath, relativeDir); + const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path)); const dirExist = await fileExist(dir); if (dirExist) { let files = await fs.readdir(dir); diff --git a/back/services/env.ts b/back/services/env.ts index 5efd148b..351fa594 100644 --- a/back/services/env.ts +++ b/back/services/env.ts @@ -262,10 +262,9 @@ export default class EnvService { .map((x) => x.value) .join('&') .replace(/\\/g, '\\\\')}`; - js_env_string += `process.env.${key}=\`${_env_value.replace( - /\`/g, - '\\`', - )}\`;\n`; + js_env_string += `process.env[${JSON.stringify(key)}]=${JSON.stringify( + group.map((x) => x.value).join('&'), + )};\n`; py_env_string += `os.environ['${key}']='''${_env_value.replace( /\'/g, "\\'", diff --git a/back/services/sshKey.ts b/back/services/sshKey.ts index 2683e7ed..521d5b08 100644 --- a/back/services/sshKey.ts +++ b/back/services/sshKey.ts @@ -8,6 +8,10 @@ import { formatUrl } from '../config/subscription'; import config from '../config'; import { fileExist, rmPath } from '../config/util'; import { writeFileWithLock } from '../shared/utils'; +import { + getSubscriptionSshAlias, + resolveSubscriptionPath, +} from '../shared/subscriptionPath'; @Service() export default class SshKeyService { @@ -42,7 +46,7 @@ export default class SshKeyService { key: string, ): Promise { try { - const filePath = path.join(this.sshPath, alias); + const filePath = resolveSubscriptionPath(this.sshPath, alias); try { await rmPath(filePath); } catch { } @@ -54,7 +58,7 @@ export default class SshKeyService { private async removePrivateKeyFile(alias: string): Promise { try { - const filePath = path.join(this.sshPath, alias); + const filePath = resolveSubscriptionPath(this.sshPath, alias); await rmPath(filePath); } catch (error) { this.logger.error('删除私钥文件失败', error); @@ -66,18 +70,19 @@ export default class SshKeyService { host: string, proxy?: string, ) { + if (!/^[a-zA-Z0-9.-]+$/.test(host) || + (proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) { + throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 }); + } if (host === 'github.com') { host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`; } const proxyStr = proxy ? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n` : ''; - const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join( - this.sshPath, - alias, - )}\n StrictHostKeyChecking no\n${proxyStr}`; + const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`; await writeFileWithLock( - `${path.join(this.sshPath, `${alias}.config`)}`, + `${resolveSubscriptionPath(this.sshPath, alias, '.config')}`, config, { encoding: 'utf8', @@ -88,7 +93,7 @@ export default class SshKeyService { private async removeSshConfig(alias: string) { try { - const filePath = path.join(this.sshPath, `${alias}.config`); + const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config'); await rmPath(filePath); } catch (error) { this.logger.error(`删除ssh配置文件${alias}失败`, error); @@ -101,6 +106,7 @@ export default class SshKeyService { host: string, proxy?: string, ): Promise { + alias = getSubscriptionSshAlias(alias); await this.generatePrivateKeyFile(alias, key); await this.generateSingleSshConfig(alias, host, proxy); } @@ -110,6 +116,7 @@ export default class SshKeyService { host: string, proxy?: string, ): Promise { + alias = getSubscriptionSshAlias(alias); await this.removePrivateKeyFile(alias); await this.removeSshConfig(alias); } @@ -117,15 +124,19 @@ export default class SshKeyService { public async setSshConfig(docs: Subscription[]) { for (const doc of docs) { if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') { - const { alias, proxy } = doc; - const { host } = formatUrl(doc); - await this.removePrivateKeyFile(alias); - await this.removeSshConfig(alias); - await this.generatePrivateKeyFile( - alias, - (doc.pull_option as any).private_key, - ); - await this.generateSingleSshConfig(alias, host, proxy); + try { + const alias = getSubscriptionSshAlias(doc.alias); + const { host } = formatUrl(doc); + await this.removePrivateKeyFile(alias); + await this.removeSshConfig(alias); + await this.generatePrivateKeyFile( + alias, + (doc.pull_option as any).private_key, + ); + await this.generateSingleSshConfig(alias, host, doc.proxy); + } catch (error) { + this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error); + } } } } @@ -144,12 +155,9 @@ export default class SshKeyService { private async generateGlobalSshConfig(alias: string) { // Create a config that matches all hosts, making this key globally available - const config = `Host *\n IdentityFile ${path.join( - this.sshPath, - alias, - )}\n StrictHostKeyChecking no\n`; + const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`; await writeFileWithLock( - `${path.join(this.sshPath, `${alias}.config`)}`, + `${resolveSubscriptionPath(this.sshPath, alias, '.config')}`, config, { encoding: 'utf8', diff --git a/back/services/subscription.ts b/back/services/subscription.ts index 16fe2b9e..a2e153ba 100644 --- a/back/services/subscription.ts +++ b/back/services/subscription.ts @@ -20,7 +20,7 @@ import { } from '../config/util'; import fs from 'fs/promises'; import { FindOptions, Op } from 'sequelize'; -import path, { join } from 'path'; +import path from 'path'; import ScheduleService, { TaskCallbacks } from './schedule'; import { SimpleIntervalSchedule } from 'toad-scheduler'; import SockService from './sock'; @@ -30,6 +30,11 @@ import dayjs from 'dayjs'; import { LOG_END_SYMBOL } from '../config/const'; import { formatCommand, formatUrl } from '../config/subscription'; import { CrontabModel } from '../data/cron'; +import { + assertSubscriptionAlias, + resolveSubscriptionPath, +} from '../shared/subscriptionPath'; +import { resolveLogPath } from '../shared/logPath'; import CrontabService from './cron'; import taskLimit from '../shared/pLimit'; import { logStreamManager } from '../shared/logStreamManager'; @@ -88,9 +93,11 @@ export default class SubscriptionService { needCreate = true, runImmediately = false, ) { - const { url } = formatUrl(doc); - - doc.command = formatCommand(doc, url as string); + // Cancelling by ID must also work for invalid historical subscriptions. + if (needCreate) { + const { url } = formatUrl(doc); + doc.command = formatCommand(doc, url as string); + } if (doc.schedule_type === 'crontab') { this.scheduleService.cancelCronTask(doc as any); @@ -222,6 +229,7 @@ export default class SubscriptionService { } public async create(payload: Subscription): Promise { + assertSubscriptionAlias(payload.alias); const tab = new Subscription(payload); const doc = await this.insert(tab); await this.handleTask(doc.get({ plain: true })); @@ -234,14 +242,27 @@ export default class SubscriptionService { } public async update(payload: Subscription): Promise { + assertSubscriptionAlias(payload.alias); const doc = await this.getDb({ id: payload.id }); const tab = new Subscription({ ...doc, ...payload }); const newDoc = await this.updateDb(tab); await this.handleTask(newDoc, !newDoc.is_disabled); + await this.removeSshConfigForSubscription(doc); await this.setSshConfig(); return newDoc; } + private async removeSshConfigForSubscription(doc: Subscription) { + if (doc.type !== 'private-repo' || doc.pull_type !== 'ssh-key') return; + // Invalid historical rows must remain deletable without deriving unsafe paths. + try { + assertSubscriptionAlias(doc.alias); + } catch { + return; + } + await this.sshKeyService.removeSSHKey(doc.alias, ''); + } + public async updateDb(payload: Subscription): Promise { await SubscriptionModel.update(payload, { where: { id: payload.id } }); return await this.getDb({ id: payload.id }); @@ -262,6 +283,7 @@ export default class SubscriptionService { last_running_time: number; last_execution_time: number; }) { + if (log_path) resolveLogPath(config.logPath, log_path); const options: any = { status, pid, @@ -280,10 +302,20 @@ export default class SubscriptionService { public async remove(ids: number[], query: { force?: boolean }) { const docs = await SubscriptionModel.findAll({ where: { id: ids } }); + // Validate all deletion targets before changing rows or removing any files. + if (query?.force === true) { + for (const doc of docs) { + resolveSubscriptionPath(config.scriptPath, doc.alias); + resolveSubscriptionPath(config.repoPath, doc.alias); + } + } for (const doc of docs) { await this.handleTask(doc.get({ plain: true }), false); } await SubscriptionModel.destroy({ where: { id: ids } }); + for (const doc of docs) { + await this.removeSshConfigForSubscription(doc); + } await this.setSshConfig(); if (query?.force === true) { @@ -292,8 +324,8 @@ export default class SubscriptionService { await this.crontabService.remove(crons.map((x) => x.id!)); } for (const doc of docs) { - const filePath = join(config.scriptPath, doc.alias); - const repoPath = join(config.repoPath, doc.alias); + const filePath = resolveSubscriptionPath(config.scriptPath, doc.alias); + const repoPath = resolveSubscriptionPath(config.repoPath, doc.alias); await rmPath(filePath); await rmPath(repoPath); } @@ -399,7 +431,7 @@ export default class SubscriptionService { if (doc.log_path) { const relativeDir = path.dirname(`${doc.log_path}`); - const dir = path.resolve(config.logPath, relativeDir); + const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path)); const _exist = await fileExist(dir); if (_exist) { let files = await fs.readdir(dir); diff --git a/back/services/system.ts b/back/services/system.ts index 6bfec73e..a8538b16 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -1,4 +1,7 @@ import { spawn } from 'cross-spawn'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { shellQuote } from '../shared/shellQuote'; import { Response } from 'express'; import fs from 'fs'; import { Agent, request } from 'undici'; @@ -139,7 +142,7 @@ export default class SystemService { if (info.dependenceProxy) { await fs.promises.writeFile( config.dependenceProxyFile, - `export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`, + `export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`, ); } else { await fs.promises.rm(config.dependenceProxyFile); @@ -155,7 +158,7 @@ export default class SystemService { }); let cmd = 'pnpm config delete registry'; if (info.nodeMirror) { - cmd = `pnpm config set registry ${info.nodeMirror}`; + cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`; } let command = `cd && ${cmd}`; const docs = await DependenceModel.findAll({ @@ -204,7 +207,7 @@ export default class SystemService { }); let cmd = 'pip config unset global.index-url'; if (info.pythonMirror) { - cmd = `pip3 config set global.index-url ${info.pythonMirror}`; + cmd = `pip3 config set global.index-url ${shellQuote(info.pythonMirror)}`; } await promiseExec(cmd); return { code: 200, data: info }; @@ -357,7 +360,10 @@ export default class SystemService { } public async reloadSystem(target?: 'system' | 'data') { - const cmd = `real_time=true ql reload ${target || ''}`; + if (target && !['system', 'data'].includes(target)) { + return { code: 400, message: t('参数错误') }; + } + const cmd = `real_time=true ql reload ${shellQuote(target || '')}`; const cp = spawn(cmd, { shell: '/bin/bash', detached: true, @@ -438,11 +444,15 @@ export default class SystemService { if (type && type.length) { dataDirs = dataDirs.concat(type.filter((x) => x !== 'base')); } - const dataPaths = dataDirs.map((dir) => `data/${dir}`); - await promiseExec( - `cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile - } ${dataPaths.join(' ')}`, - ); + const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps', + 'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d']); + if (dataDirs.some((dir) => !allowed.has(dir))) { + return res.status(400).send({ code: 400, message: t('参数错误') }); + } + const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`); + await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], { + cwd: path.dirname(config.dataPath), + }); res.download(config.dataTgzFile); } catch (error: any) { return res.send({ code: 400, message: error.message }); @@ -565,7 +575,7 @@ export default class SystemService { try { await fs.promises.writeFile( config.langEnvFile, - `export QL_LANG='${lang}'\n`, + `export QL_LANG=${shellQuote(lang)}\n`, ); } catch (error) { this.logger.error(`Failed to write lang_env.sh: ${error}`); diff --git a/back/services/user.ts b/back/services/user.ts index b7f5cf9b..da982793 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -33,9 +33,11 @@ import { verifyPassword, } from '../shared/password'; import { serializeAuthMutation } from '../shared/authMutation'; +import { LoginThrottle } from '../shared/loginThrottle'; @Service() export default class UserService { + private loginThrottle = new LoginThrottle(); @Inject((type) => NotificationService) private notificationService!: NotificationService; @@ -106,11 +108,8 @@ export default class UserService { return { code: 403, message: t('该 IP 已被列入黑名单') }; } - const retriesTime = Math.pow(3, retries) * 1000; - if (retries > 2 && timestamp - lastlogon < retriesTime) { - const waitTime = Math.ceil( - (retriesTime - (timestamp - lastlogon)) / 1000, - ); + const waitTime = this.loginThrottle.retryAfter(ip, timestamp); + if (waitTime > 0) { return { code: 410, message: tf('失败次数过多,请%s秒后重试', waitTime), @@ -133,6 +132,7 @@ export default class UserService { } if (passwordMatches) { + this.loginThrottle.reset(ip); const data = createRandomString(50, 100); const expiration = twoFactorActivated ? '60d' : '20d'; let token = jwt.sign({ data }, config.jwt.secret, { @@ -206,8 +206,9 @@ export default class UserService { }, }; } else { + const failures = this.loginThrottle.fail(ip, timestamp); await this.updateAuthInfo(content, { - retries: retries + 1, + retries: failures, lastlogon: timestamp, lastip: ip, lastaddr: address, @@ -239,8 +240,8 @@ export default class UserService { }, }); this.getLoginLog(); - if (retries > 2) { - const waitTime = Math.round(Math.pow(3, retries + 1)); + if (failures > 3) { + const waitTime = this.loginThrottle.retryAfter(ip, timestamp); return { code: 410, message: tf('失败次数过多,请%s秒后重试', waitTime), @@ -439,8 +440,8 @@ export default class UserService { const authInfo = await this.getAuthInfo(); const { isTwoFactorChecking, twoFactorSecret } = authInfo; const now = Date.now(); - const retries = authInfo.retries || 0; - if (retries > 2 && now - authInfo.lastlogon < Math.pow(3, retries) * 1000) { + const source = getClientIp(req); + if (this.loginThrottle.retryAfter(source, now) > 0) { return { code: 410, message: t('失败次数过多,请稍后重试') }; } if ( @@ -472,9 +473,8 @@ export default class UserService { .join(' '); } await this.updateAuthInfo(authInfo, { - retries: retries + 1, + retries: this.loginThrottle.fail(source, now), lastlogon: now, - isTwoFactorChecking: retries + 1 < 5, lastip: ip, lastaddr: address, platform: req.platform, @@ -690,5 +690,8 @@ export default class UserService { }); } await this.updateAuthInfo(authInfo, payload); + if (retries === 0 || password !== undefined || username !== undefined) { + this.loginThrottle.clear(); + } } } diff --git a/back/shared/cronCommand.ts b/back/shared/cronCommand.ts new file mode 100644 index 00000000..bc4d6b2f --- /dev/null +++ b/back/shared/cronCommand.ts @@ -0,0 +1,11 @@ +// Match command text literally: looking up a task must never execute its input. +export function findCronId(crontab: string, command: string): string { + if (!command) return ''; + for (const line of crontab.split('\n')) { + const id = /\bID=(\d+)\s/.exec(line); + if (id && line.includes(command, id.index + id[0].length)) { + return id[1]; + } + } + return ''; +} diff --git a/back/shared/logPath.ts b/back/shared/logPath.ts new file mode 100644 index 00000000..ac5d3ccd --- /dev/null +++ b/back/shared/logPath.ts @@ -0,0 +1,11 @@ +import { resolveFileAccess } from './fileAccess'; + +/** Check stored paths too: old database rows are not trusted input. */ +export function resolveLogPath(root: string, value: string): string { + const target = resolveFileAccess(root, [value]); + if (!target) + throw Object.assign(new Error('Log path is outside the log directory'), { + status: 400, + }); + return target; +} diff --git a/back/shared/loginThrottle.ts b/back/shared/loginThrottle.ts new file mode 100644 index 00000000..73987de1 --- /dev/null +++ b/back/shared/loginThrottle.ts @@ -0,0 +1,47 @@ +const RETRY_TTL = 15 * 60 * 1000; +const MAX_SOURCES = 10000; +const MAX_RETRY_SECONDS = 5 * 60; + +/** Source-specific, bounded backoff; persisted account history is not a lock. */ +export class LoginThrottle { + private sources = new Map< + string, + { failures: number; lastFailure: number } + >(); + + private get(source: string, now: number) { + const state = this.sources.get(source); + if (state && now - state.lastFailure >= RETRY_TTL) { + this.sources.delete(source); + return undefined; + } + return state; + } + + retryAfter(source: string, now: number): number { + const state = this.get(source, now); + if (!state || state.failures < 3) return 0; + const seconds = Math.min(3 ** state.failures, MAX_RETRY_SECONDS); + return Math.max( + 0, + Math.ceil((seconds * 1000 - (now - state.lastFailure)) / 1000), + ); + } + + fail(source: string, now: number): number { + const failures = Math.min((this.get(source, now)?.failures || 0) + 1, 6); + this.sources.delete(source); + if (this.sources.size >= MAX_SOURCES) { + this.sources.delete(this.sources.keys().next().value!); + } + this.sources.set(source, { failures, lastFailure: now }); + return failures; + } + + reset(source: string) { + this.sources.delete(source); + } + clear() { + this.sources.clear(); + } +} diff --git a/back/shared/shellQuote.ts b/back/shared/shellQuote.ts new file mode 100644 index 00000000..64281d41 --- /dev/null +++ b/back/shared/shellQuote.ts @@ -0,0 +1,5 @@ +/** Quote one literal POSIX shell argument, including quotes and substitutions. */ +export function shellQuote(value: string): string { + if (value.includes('\0')) throw new Error('Invalid shell argument'); + return "'" + value.replace(/'/g, "'\\''") + "'"; +} diff --git a/back/shared/subscriptionPath.ts b/back/shared/subscriptionPath.ts new file mode 100644 index 00000000..278c67d6 --- /dev/null +++ b/back/shared/subscriptionPath.ts @@ -0,0 +1,45 @@ +import { resolveFileAccess } from './fileAccess'; +import { createHash } from 'crypto'; + +export function assertSubscriptionAlias(alias: string): void { + if ( + typeof alias !== 'string' || + alias.length > 200 || + !/^[^\s\\\p{C}]+$/u.test(alias) || + alias.startsWith('~') || + alias.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw Object.assign(new Error('Invalid subscription alias'), { + status: 400, + }); + } +} + +/** Keep ordinary SSH names and use a reserved namespace for path-like aliases. + * The database alias and its script/log directories remain unchanged. */ +export function getSubscriptionSshAlias(alias: string): string { + assertSubscriptionAlias(alias); + if (/^[\p{L}\p{N}_.+-]+$/u.test(alias) && !alias.endsWith('.config')) { + return alias; + } + return `~sub_${createHash('sha256').update(alias).digest('hex')}`; +} + +export function resolveSubscriptionPath( + root: string, + alias: string, + suffix = '', +): string { + // Internal SSH filenames cannot collide with user-supplied aliases. + if (!/^~sub_[a-f0-9]{64}$/.test(alias)) { + assertSubscriptionAlias( + alias.startsWith('~global_') ? alias.slice(8) : alias, + ); + } + const target = resolveFileAccess(root, [alias + suffix]); + if (!target) + throw Object.assign(new Error('Invalid subscription path'), { + status: 400, + }); + return target; +} diff --git a/shell/update.sh b/shell/update.sh index 466f941e..f5069cf6 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -429,23 +429,19 @@ gen_list_repo() { cd ${repo_path} - local cmd="find ." - local index=0 - if [[ $6 ]]; then - file_extensions="$6" - if [[ $file_extensions =~ "|" ]]; then - file_extensions=$(echo $file_extensions | sed 's/|/ /g') + local extension_string="${6:-$file_extensions}" + extension_string="${extension_string//|/ }" + extension_string="${extension_string//$'\n'/ }" + local -a extensions=() find_args=() + IFS=$' \t\n' read -r -a extensions <<< "$extension_string" + local extension + for extension in "${extensions[@]}"; do + if [[ ${#find_args[@]} -gt 0 ]]; then + find_args+=(-o) fi - fi - for extension in $file_extensions; do - if [[ $index -eq 0 ]]; then - cmd="${cmd} -name \"*.${extension}\"" - else - cmd="${cmd} -o -name \"*.${extension}\"" - fi - let index+=1 + find_args+=(-name "*.${extension}") done - files=$(eval $cmd | sed 's/^..//') + files=$(find . "${find_args[@]}" | sed 's/^..//') if [[ $path ]]; then files=$(echo "$files" | egrep "$path") fi @@ -458,7 +454,7 @@ gen_list_repo() { if [[ $dependence ]]; then cd ${repo_path} - results=$(eval $cmd | sed 's/^..//' | egrep "$dependence") + results=$(find . "${find_args[@]}" | sed 's/^..//' | egrep "$dependence") for _file in ${results}; do file_path=$(dirname $_file) make_dir "${dir_scripts}/${uniq_path}/${file_path}" diff --git a/src/pages/diff/index.tsx b/src/pages/diff/index.tsx index 3b42d91e..540a759a 100644 --- a/src/pages/diff/index.tsx +++ b/src/pages/diff/index.tsx @@ -25,13 +25,16 @@ const Diff = () => { const [language, setLanguage] = useState('shell'); const getConfig = () => { - request - .get(`${config.apiPrefix}configs/detail?path=${encodeURIComponent(current)}`) - .then(({ code, data }) => { - if (code === 200) { - setCurrentValue(data); - } - }); + const url = current.startsWith('data/scripts/') + ? `${config.apiPrefix}scripts/detail?file=${encodeURIComponent( + current.slice('data/scripts/'.length), + )}` + : `${config.apiPrefix}configs/detail?path=${encodeURIComponent(current)}`; + request.get(url).then(({ code, data }) => { + if (code === 200) { + setCurrentValue(data); + } + }); }; const getSample = () => { @@ -49,16 +52,20 @@ const Diff = () => { ? editorRef.current.getModel().modified.getValue().replace(/\r\n/g, '\n') : currentValue; - request - .post(`${config.apiPrefix}configs/save`, { - content, - name: current, - }) - .then(({ code, data }) => { - if (code === 200) { - message.success(intl.get('保存成功')); - } - }); + const saving = current.startsWith('data/scripts/') + ? request.post(`${config.apiPrefix}scripts`, { + content, + filename: current.slice('data/scripts/'.length), + }) + : request.post(`${config.apiPrefix}configs/save`, { + content, + name: current, + }); + saving.then(({ code }) => { + if (code === 200) { + message.success(intl.get('保存成功')); + } + }); }; const getFiles = () => { diff --git a/test/back/advisory-input-security.test.cjs b/test/back/advisory-input-security.test.cjs new file mode 100644 index 00000000..79477f1b --- /dev/null +++ b/test/back/advisory-input-security.test.cjs @@ -0,0 +1,415 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const vm = require('node:vm'); +const { execFileSync } = require('node:child_process'); +const load = require('../helpers/load-security-module.cjs'); +const decorators = { Service: () => (x) => x, Inject: () => () => {} }; +const logger = { info() {}, warn() {}, error() {} }; +const translate = { t: (x) => x, tf: (x) => x }; +function temporary(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-advisory-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +test('environment values remain literal when the generated JavaScript loads', async () => { + const output = {}; + const Env = load(path.resolve('back/services/env.ts'), { + typedi: decorators, + '../config': { envFile: 'sh', jsEnvFile: 'js', pyEnvFile: 'py' }, + '../data/env': { EnvStatus: { normal: 0 } }, + '../data': {}, + '../shared/utils': { + writeFileWithLock: async (file, text) => (output[file] = text), + }, + }).default; + const service = new Env(logger); + const values = [ + '${globalThis.injected = true}', + 'quotes\'"` \\ newline\n中文\u2028', + '${1 + 2}', + ]; + service.envs = async () => + values.map((value, i) => ({ name: `VALUE_${i}`, value })); + await service.set_envs(); + const context = { process: { env: {} } }; + vm.runInNewContext(output.js, context); + assert.equal(context.injected, undefined); + values.forEach((value, i) => + assert.equal(context.process.env[`VALUE_${i}`], value), + ); +}); + +test('subscription shell parameters round trip without expansion or command substitution', () => { + const { formatCommand } = load(path.resolve('back/config/subscription.ts')); + const payload = + 'a $(printf INJECTED) `printf INJECTED` " ; echo BAD; # \' 中文\nnext'; + for (const type of ['file', 'repo']) { + const fields = + type === 'file' + ? ['url', 'proxy'] + : [ + 'url', + 'whitelist', + 'blacklist', + 'dependences', + 'branch', + 'extensions', + 'proxy', + ]; + for (const field of fields) { + const doc = { + id: 12, + type, + url: 'https://example.invalid/repo', + [field]: payload, + }; + const command = formatCommand(doc); + const stdout = execFileSync('/bin/bash', [ + '-c', + `ql() { printf '%s\\0' "$@"; }; ${command}`, + ]); + const args = stdout.toString().split('\0').slice(0, -1); + assert.equal( + args[fields.indexOf(field) + 1], + payload, + `${type}:${field}`, + ); + assert.equal(args[0], type === 'file' ? 'raw' : 'repo'); + assert.equal(args.length, fields.length + 3); + } + } +}); + +test('SSH alias traversal and symlinks cannot write or remove outside files', async (t) => { + const base = temporary(t); + const root = path.join(base, 'ssh'); + fs.mkdirSync(root); + const outside = path.join(base, 'outside'); + fs.writeFileSync(outside, 'keep'); + fs.symlinkSync(outside, path.join(root, 'link')); + const Ssh = load(path.resolve('back/services/sshKey.ts'), { + typedi: decorators, + '../config': { sshdPath: root }, + '../config/util': { + rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }), + }, + '../shared/utils': { + writeFileWithLock: (p, value) => fs.promises.writeFile(p, value), + }, + }).default; + const service = Object.assign(Object.create(Ssh.prototype), { + sshPath: root, + logger, + }); + for (const alias of [ + '../outside', + '..', + '.', + '/tmp/outside', + 'link', + 'x\nHost *', + ]) { + await service.generatePrivateKeyFile(alias, 'overwrite'); + await service.removePrivateKeyFile(alias); + await assert.rejects(service.generateSingleSshConfig(alias, 'github.com')); + assert.equal(fs.readFileSync(outside, 'utf8'), 'keep'); + } + await service.generatePrivateKeyFile('仓库-main', 'private-key'); + await service.generateSingleSshConfig( + '仓库-main', + 'github.com', + '127.0.0.1:1080', + ); + assert.equal( + fs.readFileSync(path.join(root, '仓库-main'), 'utf8').trim(), + 'private-key', + ); + await service.generatePrivateKeyFile('~global_default', 'global-key'); + await service.generateGlobalSshConfig('~global_default'); + assert.match( + fs.readFileSync(path.join(root, '~global_default.config'), 'utf8'), + /Host \*/, + ); + await assert.rejects( + service.generateSingleSshConfig('safe', 'host\nProxyCommand bad'), + ); + await assert.rejects( + service.generateSingleSshConfig('safe', 'host', '$(printf BAD)'), + ); +}); + +function cronService(config, updates = []) { + const Cron = load(path.resolve('back/services/cron.ts'), { + typedi: decorators, + '../config': config, + '../data/cron': { + CrontabStatus: { running: 0, idle: 1, queued: 3 }, + CrontabModel: { update: async (v) => updates.push(v) }, + }, + '../data/runningInstance': { RunningInstanceModel: {}, InstanceStatus: {} }, + '../config/util': { fileExist: async (p) => fs.existsSync(p) }, + '../config/const': {}, + '../schedule/client': {}, + '../shared/pLimit': {}, + '../shared/utils': {}, + '../shared/i18n': translate, + '../shared/logStreamManager': {}, + '../shared/cronSchedule': {}, + }).default; + return new Cron(logger); +} +function subscriptionService(config, model = {}) { + const Subscription = load(path.resolve('back/services/subscription.ts'), { + typedi: decorators, + '../config': config, + '../data/subscription': { + SubscriptionModel: model, + SubscriptionStatus: {}, + }, + '../data/cron': {}, + '../config/util': { fileExist: async (p) => fs.existsSync(p) }, + '../config/const': {}, + '../shared/i18n': translate, + '../shared/pLimit': {}, + '../shared/logStreamManager': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + return new Subscription(logger, {}, {}, {}, {}); +} + +test('stored cron/subscription paths cannot read or enumerate outside the log root', async (t) => { + const base = temporary(t); + const root = path.join(base, 'log'); + fs.mkdirSync(root); + fs.mkdirSync(path.join(base, 'outside')); + const secret = path.join(base, 'outside', 'secret'); + fs.writeFileSync(secret, 'SECRET'); + fs.symlinkSync(path.dirname(secret), path.join(root, 'escape')); + fs.writeFileSync(path.join(root, 'normal.log'), 'NORMAL'); + const updates = []; + const cron = cronService({ logPath: root }, updates); + const subscription = subscriptionService( + { logPath: root }, + { update: async (v) => updates.push(v) }, + ); + for (const log_path of [secret, '../outside/secret', 'escape/secret']) { + cron.getDb = subscription.getDb = async () => ({ log_path, status: 1 }); + await assert.rejects(cron.log(1), /outside the log directory/); + await assert.rejects(cron.logs(1), /outside the log directory/); + await assert.rejects(subscription.logs(1), /outside the log directory/); + await assert.rejects( + cron.status({ ids: [1], status: 0, log_path }), + /outside the log directory/, + ); + await assert.rejects( + subscription.status({ ids: [1], status: 0, log_path }), + /outside the log directory/, + ); + } + assert.equal(updates.length, 0); + cron.getDb = subscription.getDb = async () => ({ + log_path: 'normal.log', + status: 1, + }); + assert.equal((await cron.log(1)).content, 'NORMAL'); + assert.ok((await cron.logs(1)).some((x) => x.filename === 'normal.log')); + assert.ok( + (await subscription.logs(1)).some((x) => x.filename === 'normal.log'), + ); +}); + +test('subscription aliases are checked before persistence and forced deletion', async (t) => { + const base = temporary(t); + for (const dir of ['scripts', 'repo']) fs.mkdirSync(path.join(base, dir)); + let writes = 0; + const service = subscriptionService( + { + scriptPath: path.join(base, 'scripts'), + repoPath: path.join(base, 'repo'), + }, + { + findAll: async () => [{ alias: '../victim' }], + destroy: async () => writes++, + }, + ); + service.insert = service.updateDb = async () => writes++; + await assert.rejects( + service.create({ alias: '../victim' }), + /Invalid subscription alias/, + ); + await assert.rejects( + service.update({ alias: '../victim' }), + /Invalid subscription alias/, + ); + await assert.rejects( + service.remove([1], { force: true }), + /Invalid subscription alias/, + ); + assert.equal(writes, 0); +}); + +function systemService(config, overrides = {}) { + const System = load(path.resolve('back/services/system.ts'), { + typedi: decorators, + '../config': config, + '../config/const': {}, + '../data/dependence': { + DependenceModel: { findAll: async () => [] }, + DependenceTypes: {}, + DependenceStatus: {}, + }, + '../data/system': {}, + '../shared/pLimit': {}, + '../shared/i18n': { ...translate, setLang() {} }, + './notify': {}, + './schedule': {}, + './sock': {}, + '../config/util': {}, + ...overrides, + }).default; + return new System(logger, {}, {}); +} + +test('backup rejects unrecognized paths before spawning; valid export uses argv without a shell', async (t) => { + const base = temporary(t); + const calls = []; + const service = systemService( + { + dataPath: path.join(base, 'data'), + dataTgzFile: path.join(base, 'backup file.tgz'), + }, + { + child_process: { + execFile: (...args) => { + calls.push(args.slice(0, -1)); + args.at(-1)(null, '', ''); + }, + }, + '../config/util': { + promiseExec: () => assert.fail('must not invoke shell'), + }, + }, + ); + let downloaded = false; + const res = { + status() { + return this; + }, + send(body) { + return body; + }, + download() { + downloaded = true; + }, + }; + for (const entry of [ + '$(printf BAD)', + '../outside', + '/etc', + 'scripts;echo BAD', + '--checkpoint-action=exec=bad', + ]) { + assert.equal((await service.exportData(res, [entry])).code, 400); + } + assert.equal(calls.length, 0); + await service.exportData(res, ['base', 'scripts', 'config', 'scripts']); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], 'tar'); + assert.deepEqual(calls[0][1].slice(2), [ + '--', + 'data/db', + 'data/upload', + 'data/scripts', + 'data/config', + ]); + assert.equal(calls[0][2].shell, undefined); + assert.equal(downloaded, true); +}); + +test('proxy, language and mirror settings stay literal at their shell boundaries', async (t) => { + const base = temporary(t); + const commands = []; + const service = systemService( + { + dependenceProxyFile: path.join(base, 'proxy.sh'), + langEnvFile: path.join(base, 'lang.sh'), + }, + { + '../config/util': { + promiseExec: async (command) => commands.push(command), + }, + }, + ); + service.getSystemConfig = async () => ({ info: {} }); + service.updateAuthDb = async () => {}; + service.scheduleService = { runTask: (command) => commands.push(command) }; + const value = 'x\'$(printf BAD)`printf BAD`"; echo BAD; #'; + await service.updateDependenceProxy({ dependenceProxy: value }); + await service.updateLanguage({ lang: value }); + const stdout = execFileSync('/bin/bash', [ + '-c', + 'source "$1"; source "$2"; printf "%s\\0%s" "$http_proxy" "$QL_LANG"', + 'bash', + path.join(base, 'proxy.sh'), + path.join(base, 'lang.sh'), + ]).toString(); + assert.equal(stdout, value + '\0' + value); + await service.updatePythonMirror({ pythonMirror: value }); + await service.updateNodeMirror({ nodeMirror: value }); + for (const command of commands) { + const stdout = execFileSync('/bin/bash', [ + '-c', + `pip3() { printf '%s\\0' "$@"; }; pnpm() { printf '%s\\0' "$@"; }; ${command}`, + ]).toString(); + assert.equal(stdout.split('\0').at(-2), value); + } + assert.equal((await service.reloadSystem('$(printf BAD)')).code, 400); +}); + +test('the ignored-log sentinel remains supported without exposing device files', async (t) => { + const root = temporary(t); + const cron = cronService({ logPath: root }); + cron.getDb = async () => ({ log_path: '/dev/null', log_name: '/dev/null' }); + assert.equal((await cron.log(1)).status, 'ignored'); + assert.deepEqual(await cron.logs(1), []); + await cron.status({ ids: [], status: 0, log_path: '/dev/null' }); + await assert.rejects( + cron.status({ ids: [], status: 0, log_path: '/dev/zero' }), + ); +}); + +test('normal backup creates an archive containing the selected data directories', async (t) => { + const base = temporary(t); + const dataPath = path.join(base, 'data'); + for (const name of ['db', 'upload', 'scripts']) + fs.mkdirSync(path.join(dataPath, name), { recursive: true }); + fs.writeFileSync(path.join(dataPath, 'scripts/example.js'), 'literal script'); + const dataTgzFile = path.join(base, 'backup with spaces.tgz'); + const service = systemService({ dataPath, dataTgzFile }); + let downloaded; + await service.exportData( + { + download(file) { + downloaded = file; + }, + send(error) { + assert.fail(JSON.stringify(error)); + }, + }, + ['scripts'], + ); + assert.equal(downloaded, dataTgzFile); + const content = execFileSync('tar', [ + '-xzOf', + dataTgzFile, + 'data/scripts/example.js', + ]).toString(); + assert.equal(content, 'literal script'); +}); diff --git a/test/back/code-scanning-security.test.cjs b/test/back/code-scanning-security.test.cjs new file mode 100644 index 00000000..6b8de3e1 --- /dev/null +++ b/test/back/code-scanning-security.test.cjs @@ -0,0 +1,189 @@ +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 { execFileSync } = require('node:child_process'); +const express = require('express'); +const load = require('../helpers/load-security-module.cjs'); + +test('Python environment generation escapes backslashes and triple quotes together', async () => { + const output = {}; + const Env = load(path.resolve('back/services/env.ts'), { + typedi: { Service: () => (x) => x, Inject: () => () => {} }, + '../config': { envFile: 'sh', jsEnvFile: 'js', pyEnvFile: 'py' }, + '../data/env': { EnvStatus: { normal: 0 } }, + '../data': {}, + '../shared/utils': { + writeFileWithLock: async (file, text) => (output[file] = text), + }, + }).default; + const values = [ + "\\'''; injected = True; #", + "'''", + '\\\\\\', + 'quotes\'"`\n中文\u2028', + '${1+2}', + ]; + const service = new Env({}); + service.envs = async () => + values.map((value, i) => ({ name: `VALUE_${i}`, value })); + await service.set_envs(); + const result = execFileSync( + 'python3', + [ + '-c', + output.py + + '\nimport json\nprint(json.dumps([os.environ["VALUE_"+str(i)] for i in range(5)]))\nassert "injected" not in globals()', + ], + { encoding: 'utf8' }, + ); + assert.deepEqual(JSON.parse(result), values); +}); + +test('cron lookup treats regex and shell metacharacters as literal command text', () => { + const { findCronId } = load(path.resolve('back/shared/cronCommand.ts')); + const command = 'task file[1].js --value "$(touch ignored)"'; + const crontab = + `* * * * * real_time=false no_tee=true ID=17 ${command}\n` + + '* * * * * real_time=false no_tee=true ID=18 task other.js\n'; + assert.equal(findCronId(crontab, command), '17'); + assert.equal(findCronId(crontab, 'task file1.js'), ''); + assert.equal(findCronId(crontab, '.*'), ''); + assert.equal(findCronId(crontab, 'task other.js'), '18'); + assert.equal(findCronId('', command), ''); + assert.equal(findCronId(crontab, ''), ''); +}); + +test('command route does not execute input while looking up the cron id', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-command-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const marker = path.join(root, 'unexpected'); + const command = `task demo.js "$(touch ${marker})"`; + const crontabFile = path.join(root, 'crontab'); + fs.writeFileSync( + crontabFile, + `* * * * * real_time=false no_tee=true ID=42 ${command}\n`, + ); + const seen = []; + const app = express.Router(); + load(path.resolve('back/api/system.ts'), { + '../config': { crontabFile, tmpPath: root }, + '../services/system': {}, + '../services/user': {}, + '../shared/i18n': { t: (x) => x }, + '../shared/logStreamManager': { logStreamManager: {} }, + typedi: { + Container: { get: () => ({ run: async (value) => seen.push(value) }) }, + }, + '../config/util': { + getUniqPath: async (_command, id) => { + assert.equal(id, '42'); + return 'test'; + }, + promiseExec: async (cmd) => { + return execFileSync('/bin/sh', ['-c', cmd], { encoding: 'utf8' }); + }, + }, + }).default(app); + const router = app.stack.find((x) => x.name === 'router').handle; + const route = router.stack.find( + (x) => x.route?.path === '/command-run', + ).route; + await route.stack + .at(-1) + .handle({ body: { command } }, { setHeader() {} }, (e) => { + throw e; + }); + assert.equal(fs.existsSync(marker), false); + assert.equal(seen.length, 1); + assert.equal(seen[0].command, command); +}); + +test('command execution is limited before running across both API mounts', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-command-limit-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + let runs = 0; + const app = express(); + app.use(express.json()); + const router = express.Router(); + load(path.resolve('back/api/system.ts'), { + '../config': { crontabFile: path.join(root, 'missing'), tmpPath: root }, + '../services/system': {}, + '../services/user': {}, + '../shared/i18n': { t: (x) => x }, + '../shared/logStreamManager': { + logStreamManager: { closeStream: async () => {} }, + }, + '../config/util': { + getUniqPath: async () => 'test', + handleLogPath: async () => path.join(root, 'unused.log'), + }, + typedi: { + Container: { + get: () => ({ + run: async (value, callbacks) => { + runs++; + await callbacks.onEnd(); + }, + }), + }, + }, + }).default(router); + app.use('/api', router); + app.use('/open', router); + const server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + const base = `http://127.0.0.1:${server.address().port}`; + for (let i = 0; i < 61; i++) { + const response = await fetch( + `${base}/${i % 2 ? 'api' : 'open'}/system/command-run`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: 'echo safe' }), + }, + ); + await response.text(); + assert.equal(response.status, i < 60 ? 200 : 429, `request ${i + 1}`); + } + assert.equal(runs, 60); +}); + +test('anonymous token exchange is limited per source across both API mounts', async (t) => { + let attempts = 0; + const app = express(); + const router = express.Router(); + load(path.resolve('back/api/open.ts'), { + '../services/open': {}, + typedi: { + Container: { + get: () => ({ + authToken: async () => { + attempts++; + return { code: 401 }; + }, + }), + }, + }, + }).default(router); + app.use('/api', router); + app.use('/open', router); + const server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + const base = `http://127.0.0.1:${server.address().port}`; + for (let i = 0; i < 101; i++) { + const response = await fetch( + `${base}/${ + i % 2 ? 'api' : 'open' + }/auth/token?client_id=id&client_secret=wrong`, + ); + await response.text(); + assert.equal(response.status, i < 100 ? 200 : 429, `request ${i + 1}`); + } + assert.equal(attempts, 100); +}); diff --git a/test/back/draft-path-security.test.cjs b/test/back/draft-path-security.test.cjs new file mode 100644 index 00000000..b0f6b683 --- /dev/null +++ b/test/back/draft-path-security.test.cjs @@ -0,0 +1,234 @@ +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, api = 'script') { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-draft-path-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + for (const dir of [ + 'scripts', + 'config/grpc', + 'log', + 'tmp', + 'bak', + 'outside', + ]) { + fs.mkdirSync(path.join(root, dir), { recursive: true }); + } + const config = { blackFileList: ['auth.json', 'token.json', 'grpc'] }; + for (const [key, dir] of Object.entries({ + scriptPath: 'scripts', + configPath: 'config', + logPath: 'log', + tmpPath: 'tmp', + bakPath: 'bak', + })) + config[key] = path.join(root, dir); + config.writePathList = [config.scriptPath, config.configPath]; + const stopped = []; + const removed = []; + const service = { + stopScript: async (...args) => { + stopped.push(args); + return { code: 200 }; + }, + }; + const app = express.Router(); + load(path.resolve(`back/api/${api}.ts`), { + '../config': config, + '../config/const': {}, + '../services/script': {}, + '../services/config': {}, + '../shared/i18n': { t: (x) => x }, + typedi: { Container: { get: () => service } }, + '../shared/utils': { + writeFileWithLock: (p, s) => fs.promises.writeFile(p, s), + }, + '../config/util': { + fileExist: async (p) => fs.existsSync(p), + rmPath: async (p) => { + removed.push(p); + await fs.promises.rm(p, { recursive: true, force: true }); + }, + }, + }).default(app); + const router = app.stack.find((x) => x.name === 'router').handle; + const invoke = async (method, url, body) => { + const route = router.stack.find( + (x) => x.route?.path === url && x.route.methods[method], + ).route; + let result; + await route.stack + .at(-1) + .handle({ body }, { send: (x) => (result = x) }, (e) => { + throw e; + }); + return result; + }; + return { root, config, stopped, removed, invoke }; +} + +test('script creation only creates the validated destination parent', async (t) => { + const { root, config, invoke } = fixture(t); + const outside = path.join(root, 'outside', 'unrequested'); + const result = await invoke('post', '/', { + path: `${config.scriptPath}/../outside/unrequested`, + filename: '../../scripts/safe.txt', + content: 'safe', + }); + assert.equal(result.code, 200); + assert.equal( + fs.readFileSync(path.join(config.scriptPath, 'safe.txt'), 'utf8'), + 'safe', + ); + assert.equal(fs.existsSync(outside), false); + assert.equal( + ( + await invoke('post', '/', { + path: `${config.scriptPath}/../../escape`, + filename: 'poc.txt', + content: 'blocked', + }) + ).code, + 403, + ); +}); + +test('script creation supports root and nested parents in both writable directories', async (t) => { + const { config, invoke } = fixture(t); + for (const root of [config.scriptPath, config.configPath]) { + for (const filename of ['ordinary.js', 'nested/deeper/script.js']) { + assert.equal( + (await invoke('post', '/', { + path: root, + filename, + content: 'safe', + })).code, + 200, + ); + assert.equal(fs.readFileSync(path.join(root, filename), 'utf8'), 'safe'); + } + } +}); + +test('script parent creation rejects sibling prefixes and external symlinks', async (t) => { + const { root, config, invoke } = fixture(t); + const sibling = config.scriptPath + '-sibling'; + const outside = path.join(root, 'outside'); + fs.mkdirSync(sibling); + fs.symlinkSync(outside, path.join(config.scriptPath, 'escape')); + for (const parent of [sibling, path.join(config.scriptPath, 'escape')]) { + assert.equal( + (await invoke('post', '/', { + path: parent, + filename: 'nested/poc.js', + content: 'blocked', + })).code, + 403, + ); + } + assert.equal(fs.existsSync(path.join(sibling, 'nested')), false); + assert.equal(fs.existsSync(path.join(outside, 'nested')), false); +}); + +test('stopping scripts rejects logs outside the log root before any side effect', async (t) => { + const { root, config, stopped, invoke } = fixture(t); + const callbacks = []; + t.mock.method(global, 'setTimeout', (fn) => { + callbacks.push(fn); + return {}; + }); + fs.mkdirSync(path.join(config.configPath, 'victim.swap')); + fs.mkdirSync(path.join(config.scriptPath, 'linked')); + fs.symlinkSync( + path.join(root, 'outside'), + path.join(config.logPath, 'linked'), + ); + for (const dir of ['../config', 'linked']) { + assert.equal( + (await invoke('put', '/stop', { filename: 'victim.js', path: dir })).code, + 403, + ); + } + assert.equal(stopped.length, 0); + assert.equal(callbacks.length, 0); +}); + +test('log cleanup revalidates delayed paths and still removes ordinary debug logs', async (t) => { + const { root, config, removed, stopped, invoke } = fixture(t); + const callbacks = []; + t.mock.method(global, 'setTimeout', (fn) => { + callbacks.push(fn); + return {}; + }); + fs.mkdirSync(path.join(config.scriptPath, 'nested')); + fs.mkdirSync(path.join(config.logPath, 'nested')); + fs.mkdirSync(path.join(root, 'outside', 'victim.swap')); + fs.mkdirSync(path.join(config.logPath, 'normal.swap')); + assert.equal( + (await invoke('put', '/stop', { filename: 'normal.js' })).code, + 200, + ); + assert.equal( + (await invoke('put', '/stop', { filename: 'victim.js', path: 'nested' })) + .code, + 200, + ); + fs.rmdirSync(path.join(config.logPath, 'nested')); + fs.symlinkSync( + path.join(root, 'outside'), + path.join(config.logPath, 'nested'), + ); + callbacks.forEach((fn) => fn()); + assert.equal(stopped.length, 2); + assert.deepEqual(removed, [path.join(config.logPath, 'normal.swap')]); + assert.equal(fs.existsSync(path.join(root, 'outside', 'victim.swap')), true); +}); + +test('draft configuration traversal and normalized secret names cannot write files', async (t) => { + const { config, invoke } = fixture(t, 'config'); + fs.writeFileSync(path.join(config.configPath, 'auth.json'), 'secret'); + fs.symlinkSync( + path.join(config.configPath, 'auth.json'), + path.join(config.configPath, 'alias'), + ); + for (const name of [ + 'auth.json', + 'sub/../auth.json', + 'sub/../token.json', + 'grpc/client.key', + '../../shell/preload/env.sh', + '../scripts/poc.js', + 'data/scripts/../config/auth.json', + 'alias', + ]) { + assert.equal( + (await invoke('post', '/save', { name, content: 'changed' })).code, + 403, + name, + ); + } + assert.equal( + fs.readFileSync(path.join(config.configPath, 'auth.json'), 'utf8'), + 'secret', + ); + assert.equal( + (await invoke('post', '/save', { name: 'config.sh', content: 'normal' })) + .code, + 200, + ); + assert.equal( + ( + await invoke('post', '/save', { + name: 'data/scripts/normal.js', + content: 'normal', + }) + ).code, + 403, + ); + assert.equal(fs.existsSync(path.join(config.scriptPath, 'normal.js')), false); +}); diff --git a/test/back/env-import-security.test.cjs b/test/back/env-import-security.test.cjs new file mode 100644 index 00000000..cefa7111 --- /dev/null +++ b/test/back/env-import-security.test.cjs @@ -0,0 +1,91 @@ +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'); + +test('environment import never writes upload filenames, including rejected files', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-env-import-')); + fs.writeFileSync(path.join(root, 'sendNotify.js'), 'ORIGINAL'); + const created = []; + const Env = class {}; + const app = express(); + load(path.resolve('back/api/env.ts'), { + '../config': { scriptPath: root }, + '../config/util': { + safeJSONParse: (value) => { + try { + return JSON.parse(value); + } catch { + return {}; + } + }, + }, + '../services/env': Env, + '../shared/i18n': { t: (s) => s }, + typedi: { + Container: { + get: () => ({ + create: async (values) => { + created.push(values); + return values; + }, + }), + }, + }, + }).default(app); + app.use((err, _req, res, _next) => + res.status(400).json({ code: 400, message: err.message }), + ); + const server = app.listen(0, '127.0.0.1'); + t.after(async () => { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(root, { recursive: true, force: true }); + }); + await new Promise((resolve, reject) => { + server.on('listening', resolve); + server.on('error', reject); + }); + const upload = async (content, filename = 'sendNotify.js') => { + const form = new FormData(); + form.append('env', new Blob([content]), filename); + const response = await fetch( + `http://127.0.0.1:${server.address().port}/envs/upload`, + { method: 'POST', body: form }, + ); + return response.json(); + }; + for (const payload of [ + 'globalThis.injected=true;', + 'null', + '[]', + '[{"name":"BAD;CODE","value":"x"}]', + '[{"name":"X","value":{}}]', + ]) { + assert.equal((await upload(payload)).code, 400); + assert.equal( + fs.readFileSync(path.join(root, 'sendNotify.js'), 'utf8'), + 'ORIGINAL', + ); + } + assert.equal((await upload('x'.repeat(5 * 1024 * 1024 + 1))).code, 400); + assert.equal(created.length, 0); + assert.equal( + ( + await upload( + '[{"name":"TOKEN","value":"literal ${value}","remarks":"ok"}]', + '../sendNotify.js', + ) + ).code, + 200, + ); + assert.equal(created.length, 1); + assert.equal(created[0][0].value, 'literal ${value}'); + assert.deepEqual(fs.readdirSync(root), ['sendNotify.js']); + assert.equal( + fs.readFileSync(path.join(root, 'sendNotify.js'), 'utf8'), + 'ORIGINAL', + ); +}); diff --git a/test/back/file-access-scope.test.cjs b/test/back/file-access-scope.test.cjs index cbabd3de..f1dfd99d 100644 --- a/test/back/file-access-scope.test.cjs +++ b/test/back/file-access-scope.test.cjs @@ -81,7 +81,7 @@ function api(name, service, mocks) { } for (const customData of [false, true]) { - test(`config script access scopes blacklist and uses the configured data directory (custom=${customData})`, async (t) => { + test(`config APIs reject script compatibility paths (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; @@ -101,19 +101,12 @@ for (const customData of [false, true]) { 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('get', '/detail', { path: name })).code, 403); assert.equal( (await invoke('post', '/save', { name, content: 'updated' })).code, - 200, + 403, ); - assert.equal( - (await invoke('get', '/detail', { path: name })).data, - 'updated', - ); - assert.equal(fs.readFileSync(file, 'utf8'), 'updated'); + assert.equal(fs.readFileSync(file, 'utf8'), 'initial'); } if (customData) assert.equal( diff --git a/test/back/grpc-mtls-security.test.cjs b/test/back/grpc-mtls-security.test.cjs new file mode 100644 index 00000000..d574d761 --- /dev/null +++ b/test/back/grpc-mtls-security.test.cjs @@ -0,0 +1,135 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const childProcess = require('node:child_process'); +const grpc = require('@grpc/grpc-js'); +const load = require('../helpers/load-security-module.cjs'); +const logger = { info() {}, debug() {}, warn() {}, error() {} }; + +function fixture(t, extra = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ql-grpc's-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const module = load(path.resolve('back/config/grpcCerts.ts'), { + './index': { configPath: root }, + './util': { fileExist: async (p) => fs.existsSync(p) }, + '../loaders/logger': logger, + ...extra, + }); + return { root, module }; +} + +test('gRPC keys use a private reserved workspace and clean up all OpenSSL side files', async (t) => { + const workspaces = new Set(); + const { root, module } = fixture(t, { + child_process: { + ...childProcess, + execFileSync: (file, args, opts) => { + const temporary = args.find((arg) => arg.includes('/.generate-')); + const workspace = path.dirname(temporary); + workspaces.add(workspace); + assert.equal(fs.statSync(workspace).mode & 0o777, 0o700); + return childProcess.execFileSync(file, args, opts); + }, + }, + }); + const certs = await module.initGrpcCerts(); + assert.match(certs.serverCert, /BEGIN CERTIFICATE/); + assert.equal(workspaces.size, 1); + for (const dir of workspaces) assert.equal(fs.existsSync(dir), false); + assert.deepEqual(fs.readdirSync(path.join(root, 'grpc')).sort(), [ + 'ca.crt', + 'ca.key', + 'client.crt', + 'client.key', + 'server.crt', + 'server.key', + ]); + for (const file of ['ca.key', 'client.key', 'server.key']) { + assert.equal( + fs.statSync(path.join(root, 'grpc', file)).mode & 0o777, + 0o600, + ); + } + assert.strictEqual(await module.initGrpcCerts(), certs); +}); + +test('gRPC certificate generation cleans partial files on OpenSSL errors', async (t) => { + const { root, module } = fixture(t, { + child_process: { + execFileSync: (_file, args) => { + fs.writeFileSync(args[2], 'partial private key'); + throw new Error('openssl failed'); + }, + }, + }); + await assert.rejects(module.initGrpcCerts(), /openssl failed/); + assert.deepEqual(fs.readdirSync(path.join(root, 'grpc')), []); +}); + +test('real gRPC server rejects plaintext and TLS without client certificates, accepts mTLS', async (t) => { + const { module } = fixture(t); + const certs = await module.initGrpcCerts(); + let port; + let checks = 0; + const Grpc = load(path.resolve('back/services/grpc.ts'), { + '../config': { bindHostGrpc: '127.0.0.1', grpcPort: 0 }, + '../config/grpcCerts': { initGrpcCerts: async () => certs }, + '../loaders/logger': logger, + './metrics': { metricsService: { record() {} } }, + typedi: { Service: () => (x) => x }, + '@grpc/grpc-js': { + ...grpc, + Server: class extends grpc.Server { + bindAsync(address, creds, cb) { + super.bindAsync(address, creds, (e, boundPort) => { + port = boundPort; + cb(e, boundPort); + }); + } + }, + }, + '../schedule/addCron': {}, + '../schedule/delCron': {}, + '../schedule/api': {}, + '../schedule/health': { + check: (_call, cb) => { + checks++; + cb(null, { status: 1 }); + }, + }, + }).GrpcServerService; + const server = new Grpc(); + await server.initialize(); + t.after(() => server.shutdown()); + const HealthClient = load(path.resolve('back/protos/health.ts')).HealthClient; + const request = async (credentials) => { + const client = new HealthClient(`localhost:${port}`, credentials, { + 'grpc.enable_http_proxy': 0, + }); + try { + return await new Promise((resolve, reject) => { + client.check({}, { deadline: Date.now() + 2000 }, (e, reply) => + e ? reject(e) : resolve(reply), + ); + }); + } finally { + client.close(); + } + }; + await assert.rejects(request(grpc.credentials.createInsecure())); + await assert.rejects( + request(grpc.credentials.createSsl(Buffer.from(certs.caCert))), + ); + assert.equal(checks, 0); + const reply = await request( + grpc.credentials.createSsl( + Buffer.from(certs.caCert), + Buffer.from(certs.clientKey), + Buffer.from(certs.clientCert), + ), + ); + assert.equal(reply.status, 1); + assert.equal(checks, 1); +}); diff --git a/test/back/http-security.test.cjs b/test/back/http-security.test.cjs index 3c9b6b8f..8935e85a 100644 --- a/test/back/http-security.test.cjs +++ b/test/back/http-security.test.cjs @@ -9,7 +9,7 @@ const load = require('../helpers/load-security-module.cjs'); test('HTTP authentication protects init, scopes, expired sessions and config secrets', async (t) => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-http-security-')); - for (const dir of ['config/grpc', 'scripts', 'upload', 'tmp']) + for (const dir of ['config/grpc', 'scripts', 'upload', 'tmp', 'bak']) fs.mkdirSync(path.join(tmp, dir), { recursive: true }); fs.writeFileSync(path.join(tmp, 'config/grpc/client.key'), 'SENTINEL'); fs.writeFileSync(path.join(tmp, 'config/normal.txt'), 'normal'); @@ -40,6 +40,8 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec scriptPath: path.join(tmp, 'scripts/'), uploadPath: path.join(tmp, 'upload'), tmpPath: path.join(tmp, 'tmp'), + bakPath: path.join(tmp, 'bak'), + writePathList: [path.join(tmp, 'scripts')], blackFileList: ['auth.json', 'grpc'], baseUrl: '/panel', }; @@ -48,6 +50,16 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec scopes: ['configs'], tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }], }, + { + scopes: ['configs', 'scripts'], + tokens: [ + { value: 'script-config-app', expiration: Date.now() / 1000 + 3600 }, + ], + }, + { + scopes: ['scripts'], + tokens: [{ value: 'script-app', expiration: Date.now() / 1000 + 3600 }], + }, { scopes: ['envs'], tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }], @@ -68,6 +80,8 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec getToken: (r) => (r.headers.authorization || '').replace(/^Bearer /, ''), getPlatform: () => 'desktop', getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'), + fileExist: (p) => fs.existsSync(p), + rmPath: (p) => fs.promises.rm(p), }, '../shared/i18n': { t: (x) => x }, '../shared/store': { @@ -75,6 +89,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec }, '../config/serverEnv': { serveEnv: (_req, res) => res.end() }, '../services/user': User, + '../services/script': class {}, '../data/open': {}, '../data/system': {}, '../shared/utils': { @@ -96,6 +111,9 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec load(path.join(__dirname, '../../back/api/config.ts'), mocks).default( router, ); + load(path.join(__dirname, '../../back/api/script.ts'), mocks).default( + router, + ); router.get('/envs', (_req, res) => res.json({ code: 200 })); return router; }; @@ -210,4 +228,75 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec ).status, 400, ); + const scriptName = 'data/scripts/test.js'; + for (const prefix of ['/open', '/panel/open']) { + const denied = await request( + `${prefix}/configs/save`, + 'config-app', + 'POST', + { + name: scriptName, + content: 'overwrite', + }, + ); + assert.equal(denied.body.code, 403); + assert.equal(fs.existsSync(path.join(tmp, 'scripts/test.js')), false); + } + assert.equal( + ( + await request('/open/configs/save', 'script-config-app', 'POST', { + name: scriptName, + content: 'authorized script', + }) + ).body.code, + 403, + ); + assert.equal(fs.existsSync(path.join(tmp, 'scripts/test.js')), false); + assert.equal( + (await request(`/open/configs/detail?path=${scriptName}`, 'config-app')) + .body.code, + 403, + ); + assert.equal( + ( + await request('/open/configs/save', 'config-app', 'POST', { + name: 'normal.txt', + content: 'normal config', + }) + ).body.code, + 200, + ); + assert.equal( + ( + await request('/api/configs/save', valid, 'POST', { + name: scriptName, + content: 'owner script', + }) + ).body.code, + 403, + ); + for (const prefix of ['/open', '/panel/open']) { + assert.equal( + ( + await request(`${prefix}/scripts`, 'config-app', 'POST', { + filename: 'test.js', + content: 'unauthorized', + }) + ).status, + 401, + ); + assert.equal( + ( + await request(`${prefix}/scripts`, 'script-app', 'POST', { + filename: 'test.js', + content: 'authorized script', + }) + ).body.code, + 200, + ); + assert.equal( + fs.readFileSync(path.join(tmp, 'scripts/test.js'), 'utf8'), + 'authorized script', + ); + } }); diff --git a/test/back/stop-race.test.cjs b/test/back/stop-race.test.cjs index 5662b46d..94f5a148 100644 --- a/test/back/stop-race.test.cjs +++ b/test/back/stop-race.test.cjs @@ -1,9 +1,13 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); const { Sequelize, DataTypes } = require('sequelize'); const load = require('../helpers/load-security-module.cjs'); async function fixture(t, onKill = async () => {}) { + const logPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-stop-log-')); + t.after(() => fs.rmSync(logPath, { recursive: true, force: true })); const db = new Sequelize({ dialect: 'sqlite', storage: ':memory:', @@ -41,7 +45,7 @@ async function fixture(t, onKill = async () => {}) { let service; const killed = []; const CronService = load(path.resolve('back/services/cron.ts'), { - '../config': {}, + '../config': { logPath }, '../data/cron': { CrontabModel: crons, CrontabStatus: { queued: 3, running: 0, idle: 1 }, diff --git a/test/back/subscription-review-regression.test.cjs b/test/back/subscription-review-regression.test.cjs new file mode 100644 index 00000000..1d3bdece --- /dev/null +++ b/test/back/subscription-review-regression.test.cjs @@ -0,0 +1,607 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const ts = require('typescript'); +const load = require('../helpers/load-security-module.cjs'); +const paths = load(path.resolve('back/shared/subscriptionPath.ts')); +const { formatUrl, formatCommand } = load( + path.resolve('back/config/subscription.ts'), +); + +function temporary(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-sub-review-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function privateSubscription(alias, id = 1) { + return { + id, + type: 'private-repo', + pull_type: 'ssh-key', + alias, + url: 'git@example.com:owner/repo.git', + pull_option: { private_key: 'fixture-key' }, + }; +} + +function sshService(root, warnings = []) { + const Ssh = load(path.resolve('back/services/sshKey.ts'), { + typedi: { Service: () => (x) => x, Inject: () => () => {} }, + '../config': { sshdPath: root }, + '../config/util': { + fileExist: async (p) => fs.existsSync(p), + rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }), + }, + }).default; + return Object.assign(Object.create(Ssh.prototype), { + sshPath: root, + logger: { error() {}, warn: (...args) => warnings.push(args) }, + }); +} + +test('aliases generated by the frontend for valid branches remain accepted', () => { + const text = fs.readFileSync( + path.resolve('src/pages/subscription/modal.tsx'), + 'utf8', + ); + const source = text.slice( + text.indexOf('const formatAlias ='), + text.indexOf('\n\n const IntervalSelect'), + ); + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020 }, + }).outputText; + const formatAlias = new Function( + 'repoUrlRegx', + 'fileUrlRegx', + 'type', + js + '\nreturn formatAlias;', + )( + /([^\/\:]+\/[^\/]+)(?=\.git)/, + /([^\/\:]+\/[^\/\.]+)\.[a-z]+/, + 'public-repo', + ); + for (const branch of [ + 'main', + 'release+hotfix', + 'feature/foo', + 'feature/foo/bar', + 'release@v2', + 'release#v2', + ]) { + const alias = formatAlias('https://example.com/owner/repo.git', branch); + assert.equal(alias, `owner_repo_${branch}`); + assert.doesNotThrow(() => paths.assertSubscriptionAlias(alias)); + } +}); + +test('nested aliases stay inside the root and cannot claim internal SSH names', (t) => { + const root = temporary(t); + const scripts = path.join(root, 'scripts'); + fs.mkdirSync(scripts); + assert.equal( + paths.resolveSubscriptionPath(scripts, 'owner_repo_feature/foo'), + path.join(scripts, 'owner_repo_feature/foo'), + ); + fs.symlinkSync(root, path.join(scripts, 'escape')); + assert.throws(() => paths.resolveSubscriptionPath(scripts, 'escape/outside')); + for (const alias of [ + '.', + '..', + '../outside', + '/outside', + 'x/../outside', + 'x/./a', + 'x//a', + 'x/', + 'x\\a', + 'x\0a', + 'x\nHost *', + 'x y', + '~global_secret', + '~sub_' + 'a'.repeat(64), + ]) { + assert.throws(() => paths.assertSubscriptionAlias(alias), alias); + } + assert.throws(() => paths.assertSubscriptionAlias('a'.repeat(201))); +}); + +test('SSH URL, key and configuration use the same stable name for nested aliases', async (t) => { + const root = temporary(t); + const ssh = sshService(root); + const docs = [ + privateSubscription('owner_repo_release+hotfix'), + privateSubscription('owner_repo_feature/foo', 2), + privateSubscription('owner_repo_feature_foo', 3), + privateSubscription('owner_repo.config', 4), + ]; + const snapshot = structuredClone(docs); + await ssh.setSshConfig(docs); + assert.deepEqual(docs, snapshot); + const mapped = paths.getSubscriptionSshAlias(docs[1].alias); + assert.match(mapped, /^~sub_[a-f0-9]{64}$/); + assert.notEqual(mapped, paths.getSubscriptionSshAlias(docs[2].alias)); + assert.equal(paths.getSubscriptionSshAlias(docs[0].alias), docs[0].alias); + for (const doc of docs) { + const name = paths.getSubscriptionSshAlias(doc.alias); + const key = path.join(root, name); + const config = path.join(root, `${name}.config`); + assert.equal(fs.readFileSync(key, 'utf8').trim(), 'fixture-key'); + assert.equal(fs.statSync(key).mode & 0o777, 0o400); + assert.equal(fs.statSync(config).mode & 0o777, 0o600); + assert.ok(fs.readFileSync(config, 'utf8').startsWith(`Host ${name}\n`)); + assert.equal(formatUrl(doc).url, `git@${name}:owner/repo.git`); + const parsed = execFileSync('ssh', ['-G', '-F', config, name], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + assert.match(parsed, /^hostname example\.com$/m); + assert.ok(parsed.includes(`identityfile ${key}\n`)); + } + await ssh.removeSSHKey(docs[1].alias, 'example.com'); + assert.equal(fs.existsSync(path.join(root, mapped)), false); + assert.equal(fs.existsSync(path.join(root, `${mapped}.config`)), false); + await ssh.addSSHKey('replacement', docs[1].alias, 'example.com'); + assert.equal( + fs.readFileSync(path.join(root, mapped), 'utf8').trim(), + 'replacement', + ); +}); + +test('startup retains historical aliases and isolates bad rows and asynchronous scheduling failures', async (t) => { + const root = temporary(t); + const warnings = []; + const ssh = sshService(root, warnings); + const docs = [ + privateSubscription('owner_repo_release+hotfix', 1), + privateSubscription('owner_repo_feature/foo', 2), + privateSubscription('../outside', 3), + { + id: 4, + type: 'public-repo', + alias: 'failed-job', + url: 'https://example.com/repo.git', + }, + { + id: 5, + type: 'public-repo', + alias: 'healthy-job', + url: 'https://example.com/repo.git', + }, + ]; + const registered = []; + const types = Object.fromEntries( + ['system', 'schedule', 'subscription', 'ssh'].map((key) => [key, class {}]), + ); + const services = new Map([ + [types.system, { getSystemConfig: async () => ({}) }], + [ + types.schedule, + { cancelIntervalTask: async () => {}, createIntervalTask: () => {} }, + ], + [types.ssh, ssh], + [ + types.subscription, + { + setSshConfig: () => ssh.setSshConfig(docs), + list: async () => docs.map((doc) => ({ ...doc, get: () => doc })), + handleTask: async (doc) => { + if (doc.id === 4) throw new Error('unavailable scheduler'); + registered.push(doc.id); + }, + }, + ], + ]); + const initTask = load(path.resolve('back/loaders/initTask.ts'), { + '../config': { rootPath: root }, + '../config/util': { fileExist: async () => false }, + '../shared/i18n': { t: (x) => x }, + './logger': { warn: (...args) => warnings.push(args) }, + '../services/system': types.system, + '../services/schedule': types.schedule, + '../services/subscription': types.subscription, + '../services/sshKey': types.ssh, + typedi: { Container: { get: (key) => services.get(key) } }, + }).default; + await initTask(); + assert.deepEqual(registered, [1, 2, 5]); + assert.equal(warnings.length, 3); + assert.equal( + fs.existsSync(path.join(root, 'owner_repo_release+hotfix.config')), + true, + ); +}); + +test('subscription create and update preserve branch aliases without rewriting records', async (t) => { + const root = temporary(t); + const Subscription = load(path.resolve('back/services/subscription.ts'), { + '../config': { logPath: root }, + '../data/subscription': { + Subscription: class { + constructor(value) { + Object.assign(this, value); + } + }, + SubscriptionModel: {}, + }, + '../data/cron': {}, + '../config/util': {}, + '../config/const': {}, + '../shared/i18n': { t: (x) => x, tf: (x) => x }, + '../shared/pLimit': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + const service = new Subscription({}, {}, {}, {}, {}); + const tasks = []; + service.insert = async (value) => ({ ...value, get: () => value }); + service.getDb = async () => ({ id: 1, alias: 'owner_repo_release+hotfix' }); + service.updateDb = async (value) => value; + service.handleTask = async (doc) => tasks.push(doc.alias); + service.setSshConfig = async () => {}; + for (const alias of ['owner_repo_release+hotfix', 'owner_repo_feature/foo']) { + await service.create({ id: 1, alias }); + await service.update({ id: 1, alias }); + } + assert.deepEqual(tasks, [ + 'owner_repo_release+hotfix', + 'owner_repo_release+hotfix', + 'owner_repo_feature/foo', + 'owner_repo_feature/foo', + ]); +}); + +function shellFunction(name) { + const source = fs.readFileSync(path.resolve('shell/update.sh'), 'utf8'); + const start = source.indexOf(`${name}() {`); + assert.ok(start >= 0); + return source.slice(start, source.indexOf('\n}', start) + 2); +} + +function updateFixture(t) { + const root = temporary(t); + const template = path.join(root, 'template'); + for (const dir of ['template', 'repo', 'scripts', 'lists']) + fs.mkdirSync(path.join(root, dir)); + for (const file of ['keep.js', 'keep.py', 'omit.js', 'other.txt', 'dep.js']) + fs.writeFileSync(path.join(template, file), file); + for (const file of ['notify.js', 'notify.py', 'crontab']) + fs.writeFileSync(path.join(root, file), ''); + const helpers = [ + 'update_repo', + 'diff_scripts', + 'gen_list_repo', + 'get_uniq_path', + ] + .map(shellFunction) + .join('\n'); + return { + root, + run: (extensions, dependence = 'dep') => { + const doc = { + id: 1, + type: 'public-repo', + alias: 'owner_repo', + url: 'https://example.invalid/owner/repo.git', + whitelist: 'keep', + blacklist: 'omit', + dependences: dependence, + extensions, + autoAddCron: false, + autoDelCron: false, + }; + const code = `dir_repo="$QA_ROOT/repo"; dir_scripts="$QA_ROOT/scripts"; dir_list_tmp="$QA_ROOT/lists" +file_notify_js="$QA_ROOT/notify.js"; file_notify_py="$QA_ROOT/notify.py"; list_crontab_user="$QA_ROOT/crontab" +cmd_task=task; file_extensions='js py' +${helpers} +t() { :; }; make_dir() { mkdir -p "$1"; }; diff_cron() { :; } +git_clone_scripts() { mkdir -p "$2"; cp "$QA_ROOT/template/"* "$2/"; exit_status=0; } +ql() { get_uniq_path "$2" "$6"; update_repo "${'${@:2}'}"; } +${formatCommand(doc)}`; + execFileSync('/bin/bash', ['-c', code], { + env: { ...process.env, QA_ROOT: root }, + encoding: 'utf8', + timeout: 5000, + }); + return path.join(root, 'scripts', 'owner_repo'); + }, + }; +} + +test('real repository update supports extension separators, filtering and dependency copies', (t) => { + for (const extension of ['js|py', 'js py', 'js\tpy', 'js\npy', '']) { + const f = updateFixture(t); + const scripts = f.run(extension); + assert.deepEqual(fs.readdirSync(scripts).sort(), [ + 'dep.js', + 'keep.js', + 'keep.py', + 'notify.js', + 'notify.py', + ]); + assert.deepEqual( + fs + .readFileSync( + path.join(f.root, 'lists', 'owner_repo_scripts.list'), + 'utf8', + ) + .trim() + .split('\n') + .sort(), + ['owner_repo/keep.js', 'owner_repo/keep.py'], + ); + } +}); + +test('real repository update never evaluates extension input, including dependency discovery', (t) => { + const f = updateFixture(t); + const marker = path.join(f.root, 'executed'); + const payloads = [ + 'js|$(touch${IFS}' + marker + ')', + 'js|`touch${IFS}' + marker + '`', + 'js|py";touch${IFS}' + marker + ';#', + 'js|$(touch${IFS}' + marker + ')\npy', + ]; + for (const payload of payloads) { + const scripts = f.run(payload); + assert.equal(fs.existsSync(marker), false, payload); + assert.equal( + fs.readFileSync(path.join(scripts, 'keep.js'), 'utf8'), + 'keep.js', + ); + assert.equal( + fs.readFileSync(path.join(scripts, 'dep.js'), 'utf8'), + 'dep.js', + ); + } +}); + +test('force deletion supports historical nested aliases while preserving outside targets', async (t) => { + const root = temporary(t); + const config = { + scriptPath: path.join(root, 'scripts'), + repoPath: path.join(root, 'repo'), + }; + const alias = 'owner_repo_feature/foo'; + for (const dir of [config.scriptPath, config.repoPath]) { + fs.mkdirSync(path.join(dir, alias), { recursive: true }); + fs.writeFileSync(path.join(dir, alias, 'file'), 'fixture'); + } + const outside = path.join(root, 'outside'); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'file'), 'keep'); + let destroyed = 0; + const doc = { id: 1, alias, get: () => doc }; + const Subscription = load(path.resolve('back/services/subscription.ts'), { + '../config': config, + '../data/subscription': { + SubscriptionModel: { + findAll: async () => [doc], + destroy: async () => destroyed++, + }, + }, + '../data/cron': { CrontabModel: { findAll: async () => [] } }, + '../config/util': { + rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }), + }, + '../config/const': {}, + '../shared/i18n': { t: (x) => x, tf: (x) => x }, + '../shared/pLimit': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + const service = new Subscription({}, {}, {}, {}, {}); + service.handleTask = async () => {}; + service.setSshConfig = async () => {}; + await service.remove([1], { force: true }); + assert.equal(destroyed, 1); + for (const dir of [config.scriptPath, config.repoPath]) + assert.equal(fs.existsSync(path.join(dir, alias)), false); + fs.symlinkSync(outside, path.join(config.scriptPath, 'escape')); + doc.alias = 'escape/file'; + await assert.rejects( + service.remove([1], { force: true }), + /Invalid subscription path/, + ); + assert.equal(destroyed, 1); + assert.equal(fs.readFileSync(path.join(outside, 'file'), 'utf8'), 'keep'); + assert.equal(paths.getSubscriptionSshAlias('仓库-main'), '仓库-main'); +}); + +function cancellationFixture(t, subscriptions) { + const root = temporary(t); + const events = []; + const docs = subscriptions.map((value) => { + const doc = { ...value }; + doc.get = () => ({ ...doc }); + return doc; + }); + const Subscription = load(path.resolve('back/services/subscription.ts'), { + '../config': { scriptPath: root, repoPath: root }, + '../data/subscription': { + SubscriptionModel: { + findAll: async ({ where }) => + docs.filter((doc) => where.id.includes(doc.id)), + destroy: async ({ where }) => events.push(['destroy', where.id]), + update: async (value, { where }) => { + for (const doc of docs) { + if (where.id.includes(doc.id)) Object.assign(doc, value); + } + }, + }, + }, + '../data/cron': {}, + '../config/util': { rmPath: async () => events.push(['removeFile']) }, + '../config/const': {}, + '../shared/i18n': { t: (x) => x, tf: (x) => x }, + '../shared/pLimit': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + const schedule = { + cancelCronTask: (doc) => events.push(['cancelCron', doc.id]), + cancelIntervalTask: (doc) => events.push(['cancelInterval', doc.id]), + createCronTask: async (doc, callbacks, immediately) => + events.push(['createCron', doc.command, immediately]), + createIntervalTask: async (doc, interval, immediately) => + events.push(['createInterval', doc.command, interval, immediately]), + }; + const service = new Subscription( + {}, schedule, {}, { removeSSHKey: async () => {} }, {}, + ); + service.setSshConfig = async () => {}; + return { service, docs, events }; +} + +async function sshLifecycleFixture(t, initial) { + const root = temporary(t); + const ssh = sshService(root); + let docs = [{ ...initial, get() { return { ...this }; } }]; + const SubscriptionService = load(path.resolve('back/services/subscription.ts'), { + '../config': { scriptPath: root, repoPath: root }, + '../data/subscription': { + Subscription: class { constructor(value) { Object.assign(this, value); } }, + SubscriptionModel: { + findAll: async () => docs, + destroy: async () => { docs = []; }, + }, + }, + '../data/cron': { CrontabModel: { findAll: async () => [] } }, + '../config/util': { + rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }), + }, + '../config/const': {}, + '../shared/i18n': { t: (x) => x, tf: (x) => x }, + '../shared/pLimit': {}, + './schedule': {}, './sock': {}, './sshKey': {}, './cron': {}, + }).default; + const service = new SubscriptionService({}, {}, {}, ssh, {}); + service.handleTask = async () => {}; + service.getDb = async () => ({ ...docs[0] }); + service.updateDb = async (value) => { + Object.assign(docs[0], value); + return { ...docs[0] }; + }; + await service.setSshConfig(); + return { service, root }; +} + +test('editing an SSH subscription removes the old alias key and configuration', async (t) => { + const initial = privateSubscription('owner_repo_main'); + const f = await sshLifecycleFixture(t, initial); + const alias = 'owner_repo_feature/中文'; + await f.service.update({ ...initial, alias }); + const mapped = paths.getSubscriptionSshAlias(alias); + assert.equal(fs.existsSync(path.join(f.root, initial.alias)), false); + assert.equal(fs.existsSync(path.join(f.root, initial.alias + '.config')), false); + assert.equal(fs.readFileSync(path.join(f.root, mapped), 'utf8').trim(), 'fixture-key'); + assert.ok(fs.existsSync(path.join(f.root, mapped + '.config'))); +}); + +test('switching an SSH subscription to a public repository removes obsolete credentials', async (t) => { + const initial = privateSubscription('owner_repo.config'); + const f = await sshLifecycleFixture(t, initial); + await f.service.update({ ...initial, type: 'public-repo' }); + assert.deepEqual(fs.readdirSync(f.root), []); +}); + +test('ordinary and forced subscription deletion remove the current SSH key and configuration', async (t) => { + for (const force of [false, true]) { + const initial = privateSubscription('owner_repo_feature/中文'); + const f = await sshLifecycleFixture(t, initial); + await f.service.remove([initial.id], { force }); + assert.deepEqual(fs.readdirSync(f.root), []); + } +}); + +test('ordinary deletion cancels invalid historical SSH subscriptions by ID', async (t) => { + for (const schedule_type of ['crontab', 'interval']) { + const f = cancellationFixture(t, [ + { + ...privateSubscription('../outside'), + schedule_type, + interval_schedule: { type: 'hours', value: 1 }, + }, + ]); + await f.service.remove([1], { force: false }); + assert.deepEqual(f.events, [ + [schedule_type === 'crontab' ? 'cancelCron' : 'cancelInterval', 1], + ['destroy', [1]], + ]); + } +}); + +test('invalid historical SSH rows do not block mixed batch deletion or disabling', async (t) => { + const docs = [ + { ...privateSubscription('healthy', 1), schedule_type: 'crontab' }, + { ...privateSubscription('../outside', 2), schedule_type: 'crontab' }, + { + ...privateSubscription('old alias', 3), + schedule_type: 'interval', + interval_schedule: { type: 'hours', value: 1 }, + }, + ]; + const cancellations = [ + ['cancelCron', 1], + ['cancelCron', 2], + ['cancelInterval', 3], + ]; + const removed = cancellationFixture(t, docs); + await removed.service.remove([1, 2, 3], {}); + assert.deepEqual(removed.events, [...cancellations, ['destroy', [1, 2, 3]]]); + const disabled = cancellationFixture(t, docs); + await disabled.service.disabled([1, 2, 3]); + assert.deepEqual(disabled.events, cancellations); + assert.ok(disabled.docs.every((doc) => doc.is_disabled === 1)); +}); + +test('cancellation bypass leaves alias validation for creation and forced deletion intact', async (t) => { + const doc = { + ...privateSubscription('../outside'), + schedule_type: 'crontab', + }; + const f = cancellationFixture(t, [doc]); + await assert.rejects(f.service.handleTask(doc), /Invalid subscription alias/); + await assert.rejects(f.service.create(doc), /Invalid subscription alias/); + await assert.rejects(f.service.enabled([1]), /Invalid subscription alias/); + await assert.rejects( + f.service.remove([1], { force: true }), + /Invalid subscription alias/, + ); + assert.deepEqual(f.events, []); +}); + +test('valid subscription scheduling still creates quoted commands for both schedule types', async (t) => { + for (const schedule_type of ['crontab', 'interval']) { + const doc = { + ...privateSubscription('owner_repo_feature/foo'), + schedule_type, + interval_schedule: { type: 'hours', value: 1 }, + }; + const f = cancellationFixture(t, [doc]); + await f.service.handleTask(doc, true, true); + const command = formatCommand(doc); + assert.deepEqual( + f.events, + schedule_type === 'crontab' + ? [ + ['cancelCron', 1], + ['createCron', command, true], + ] + : [ + ['cancelInterval', 1], + ['createInterval', command, { hours: 1 }, true], + ], + ); + } +}); diff --git a/test/back/user-security.test.cjs b/test/back/user-security.test.cjs index 963aadcd..3fcdd5a8 100644 --- a/test/back/user-security.test.cjs +++ b/test/back/user-security.test.cjs @@ -48,7 +48,7 @@ function fixture(initial) { }, '../shared/i18n': { t: (x) => x, tf: (x, y) => x.replace('%s', y) }, '../shared/clientIp': { - getClientIp: () => '127.0.0.1', + getClientIp: (request) => request.socket.remoteAddress, normalizeClientIp: (x) => x, }, ip2region: class { @@ -246,3 +246,104 @@ test('default credentials with historical metadata still require initialization' ); assert.equal(f.auth.token, ''); }); + +test('one source cannot lock out another or revive a persisted global lock', async () => { + const f = fixture({ ...initialized(), retries: 40, lastlogon: Date.now() }); + const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } }; + const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } }; + for (let i = 0; i < 3; i++) { + assert.equal( + (await f.user.login({ username: 'owner', password: 'wrong' }, attacker)) + .code, + 400, + ); + } + assert.equal( + ( + await f.user.login( + { username: 'owner', password: 'old-password' }, + attacker, + ) + ).code, + 410, + ); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, owner)) + .code, + 200, + ); + // Another client's successful login does not reset the attacker's own budget. + assert.equal( + (await f.user.login({ username: 'owner', password: 'wrong' }, attacker)) + .code, + 410, + ); + await f.user.resetAuthInfo({ retries: 0 }); + assert.equal( + ( + await f.user.login( + { username: 'owner', password: 'old-password' }, + attacker, + ) + ).code, + 200, + ); +}); + +test('TOTP guesses from another source do not lock the owner out', async () => { + const secret = authenticator.generateSecret(); + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: secret, + }); + const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } }; + const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } }; + await f.user.login({ username: 'owner', password: 'old-password' }, owner); + const good = authenticator.generate(secret); + const bad = good === '000000' ? '111111' : '000000'; + for (let i = 0; i < 3; i++) { + assert.equal( + ( + await f.user.twoFactorLogin( + { username: 'owner', password: 'old-password', code: bad }, + attacker, + ) + ).code, + 430, + ); + } + assert.equal( + ( + await f.user.twoFactorLogin( + { username: 'owner', password: 'old-password', code: bad }, + attacker, + ) + ).code, + 410, + ); + assert.equal( + ( + await f.user.twoFactorLogin( + { username: 'owner', password: 'old-password', code: good }, + owner, + ) + ).code, + 200, + ); +}); + +test('source backoff is capped and expires after inactivity', () => { + const { LoginThrottle } = load(path.resolve('back/shared/loginThrottle.ts')); + const throttle = new LoginThrottle(); + let now = 1; + for (let i = 0; i < 20; i++) { + throttle.fail('attacker', now); + const wait = throttle.retryAfter('attacker', now); + assert.ok(wait <= 300); + now += (wait + 1) * 1000; + } + assert.equal(throttle.retryAfter('owner', now), 0); + assert.equal(throttle.retryAfter('attacker', now + 15 * 60 * 1000), 0); + assert.equal(throttle.fail('attacker', now + 15 * 60 * 1000), 1); +}); diff --git a/test/front/diff-file-routing.test.cjs b/test/front/diff-file-routing.test.cjs new file mode 100644 index 00000000..bbb69ef9 --- /dev/null +++ b/test/front/diff-file-routing.test.cjs @@ -0,0 +1,126 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); + +const source = fs.readFileSync( + path.join(__dirname, '../../src/pages/diff/index.tsx'), + 'utf8', +); +const ast = ts.createSourceFile( + 'diff.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +const callbacks = new Map(); +function visit(node) { + if ( + ts.isVariableDeclaration(node) && + ['getConfig', 'updateConfig', 'getSample'].includes(node.name.getText(ast)) + ) { + callbacks.set(node.name.getText(ast), node.initializer.getText(ast)); + } + ts.forEachChild(node, visit); +} +visit(ast); + +function fixture(current, editorContent) { + const calls = []; + const values = {}; + const context = { + current, + origin: 'sample/notify.js', + currentValue: '', + editorRef: { + current: + editorContent === undefined + ? null + : { + getModel: () => ({ modified: { getValue: () => editorContent } }), + }, + }, + config: { apiPrefix: '/panel/api/' }, + request: { + get: async (url) => { + calls.push({ method: 'GET', url }); + return { code: 200, data: 'file content' }; + }, + post: async (url, body) => { + calls.push({ method: 'POST', url, body }); + return { code: 200 }; + }, + }, + setCurrentValue: (value) => { + values.current = value; + }, + setOriginValue: (value) => { + values.origin = value; + }, + message: { + success: (value) => { + values.message = value; + }, + }, + intl: { get: (value) => value }, + }; + vm.createContext(context); + for (const [name, initializer] of callbacks) { + vm.runInContext( + ts.transpile(`var ${name} = ${initializer}`, { + target: ts.ScriptTarget.ES2020, + }), + context, + ); + } + return { context, calls, values }; +} + +for (const [target, filename] of [ + ['config.sh', null], + ['data/scripts/sendNotify.js', 'sendNotify.js'], + ['data/scripts/notify.py', 'notify.py'], + ['data/scripts/子目录/a b.js', '子目录/a b.js'], +]) { + test(`diff tool reads and saves ${target} through its resource API`, async () => { + const { context, calls, values } = fixture(target, 'updated\r\n'); + context.getConfig(); + context.updateConfig(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + calls[0].url, + filename + ? `/panel/api/scripts/detail?file=${encodeURIComponent(filename)}` + : '/panel/api/configs/detail?path=config.sh', + ); + assert.equal( + calls[1].url, + filename ? '/panel/api/scripts' : '/panel/api/configs/save', + ); + assert.equal( + calls[1].body[filename ? 'filename' : 'name'], + filename || target, + ); + assert.equal(calls[1].body.content, 'updated\n'); + assert.equal(values.current, 'file content'); + assert.equal(values.message, '保存成功'); + }); +} + +test('mobile diff tool can save an empty notification script and load its sample', async () => { + const { context, calls, values } = fixture('data/scripts/sendNotify.js'); + context.updateConfig(); + context.getSample(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls[0].url, '/panel/api/scripts'); + assert.equal(calls[0].body.filename, 'sendNotify.js'); + assert.equal(calls[0].body.content, ''); + assert.equal( + calls[1].url, + '/panel/api/configs/detail?path=sample%2Fnotify.js', + ); + assert.equal(values.origin, 'file content'); +});