qinglong/back/shared/security.ts
Flody.lee 59a357f76f fix(security): harden command injection, path traversal, auth surfaces
Audit of the backend attack surface and fixes for the web-reachable
CRITICAL/HIGH issues. Adds back/shared/security.ts with centralized
hardening helpers (shellEscape, assertSafeDependenceName,
SUBSCRIPTION_PATTERNS, safeCompare, isSafeSshConfigValue).

- Subscription fields (url/branch/whitelist/blacklist/extensions/proxy)
  are now shell-escaped before reaching spawn() and validated with strict
  Joi patterns at the API, closing OS command injection and the
  downstream shell eval/git-arg-injection paths.
- Dependency names are validated before interpolation into
  pnpm/pip/apk/apt commands (incl. the embedded Python source).
- SSH config generation rejects newline/metachar injection in host/proxy
  (prevents injected ProxyCommand execution).
- ConfigService.getFile resolves the real path before containment check,
  fixing data/scripts/../db traversal that leaked the SQLite DB.
- /configs/save containment check fixed (sibling-dir write bypass).
- Script/env uploads use path.basename, preventing arbitrary file write
  (crontab.list/env.sh overwrite -> RCE) via multer originalname.
- JWT secret is generated and persisted per-install instead of the public
  default 'whyour-secret'; production refuses to boot without one.
- Token comparison is now constant-time (safeCompare).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:22:15 +08:00

73 lines
3.0 KiB
TypeScript

/**
* Centralized input-hardening helpers used to neutralize command injection,
* argument injection and config-file injection across the backend.
*
* The task runner legitimately executes user scripts, but data fields such as
* git URLs, branches, dependency names and proxies are NOT meant to be code.
* These helpers keep such values from breaking out of the shell commands /
* config files they are interpolated into.
*/
import crypto from 'crypto';
/**
* POSIX single-quote escaping. Wraps a value so it is passed to the shell as a
* single literal argument, neutralizing $(), backticks, ;, |, &, redirects,
* whitespace and newlines.
*/
export function shellEscape(value: unknown): string {
const str = value === undefined || value === null ? '' : String(value);
return `'${str.replace(/'/g, `'\\''`)}'`;
}
/** Characters that enable shell command injection / argument chaining. */
const SHELL_METACHAR = /[;&|`$<>(){}\[\]'"\\!\n\r\t ]/;
/**
* Validate a package/dependence name before it is interpolated into an
* install/uninstall/version command (pnpm/pip/apk/apt). Throws on anything that
* could break out of the command or inject an extra argument.
*/
export function assertSafeDependenceName(name: string): string {
const n = String(name ?? '').trim();
if (!n || n.length > 214 || SHELL_METACHAR.test(n) || n.startsWith('-')) {
throw new Error('Invalid dependence name');
}
return n;
}
/**
* Joi-compatible patterns for subscription fields that flow into shell commands
* and ssh config files. They block shell metacharacters / newlines while still
* permitting legitimate values.
*/
export const SUBSCRIPTION_PATTERNS = {
// git/http(s)/ssh url: no whitespace or shell metacharacters, must not start with '-'
url: /^(?!-)[^\s;&|`$<>(){}'"\\]+$/,
// git ref: word chars, dots, slashes, dashes
branch: /^[\w.\/-]*$/,
// space/comma separated bare file extensions
extensions: /^[A-Za-z0-9 ,]*$/,
// host:port (consumed by the nc ProxyCommand) or empty
proxy: /^([\w.\-]+:\d+)?$/,
// regex filters: forbid line breaks and command-substitution chars, keep regex metachars
filter: /^[^\r\n`$\\]*$/,
};
/**
* Constant-time string comparison for tokens / secrets / passwords. Both inputs
* are hashed to a fixed length first so timingSafeEqual never throws on length
* mismatch and the comparison does not leak length via timing.
*/
export function safeCompare(a: string | undefined | null, b: string | undefined | null): boolean {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const ha = crypto.createHash('sha256').update(a).digest();
const hb = crypto.createHash('sha256').update(b).digest();
return crypto.timingSafeEqual(ha, hb) && a.length === b.length;
}
/** Reject values that are dangerous when written verbatim into an ssh config file. */
export function isSafeSshConfigValue(value: string | undefined | null): boolean {
if (value === undefined || value === null) return true;
return !/[\r\n;&|`$<>()'"\\]/.test(String(value));
}