mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
fix: 加固安全输入边界并修复订阅兼容性 (#3084)
* 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
This commit is contained in:
+6
-7
@@ -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('文件无法访问') });
|
||||
|
||||
+17
-14
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+31
-4
@@ -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) {
|
||||
|
||||
+14
-9
@@ -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();
|
||||
}
|
||||
|
||||
+93
-45
@@ -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, unknown>): 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<string> {
|
||||
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<GrpcTlsConfig> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-26
@@ -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(' ')}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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('文件无法访问') });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
"\\'",
|
||||
|
||||
+30
-22
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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',
|
||||
|
||||
@@ -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<Subscription> {
|
||||
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<Subscription> {
|
||||
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<Subscription> {
|
||||
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);
|
||||
|
||||
+20
-10
@@ -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}`);
|
||||
|
||||
+15
-12
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, "'\\''") + "'";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+12
-16
@@ -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}"
|
||||
|
||||
+24
-17
@@ -25,13 +25,16 @@ const Diff = () => {
|
||||
const [language, setLanguage] = useState<string>('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 = () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user