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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user