fix(security): comprehensive security audit and hardening

Auth: login rate limit 30/min (was 500), global rate limit 1000/min (was
unlimited), password/username max lengths on all Zod schemas, session
invalidation on role change, API key legacy scan bounded to 100 keys.

SVG: hardened regex sanitizer with CDATA stripping, XML entity decoding,
set/animate/iframe/embed blocking, comprehensive data: URI blocking,
use element external href blocking. 11 attack payload fixtures added.

SSRF: fixed DNS rebinding TOCTOU by pinning resolved IPs via custom
HTTP/HTTPS agents. Added 6to4 and NAT64 to blocked IPv6 ranges.

Docker: capability dropping (cap_drop ALL + minimal cap_add), resource
limits (4g/8g mem, 512/1024 pids), healthcheck timeout, password
removed from startup banner, default password warning comments.

Network: CSP and HSTS applied in all environments (not just production),
stack traces removed from all error responses, internal paths stripped
from error details, per-route rate limits on uploads (60/min) and URL
fetches (200/hour).

Files: exclusive temp file creation (O_EXCL), disk space circuit
breaker, per-user storage quotas, settings payload 64KB size guard.

Python sidecar: script name allowlist in dispatcher, minimal environment
for subprocess spawns.

Dependencies: fixed 6 production CVEs (drizzle-orm, fastify, fast-uri,
@fastify/static, next, archiver/lodash). Pinned all GitHub Actions to
SHA hashes.

114 security tests added. Full OWASP Top 10 penetration test matrix
verified against production Docker container (30/30 pass after
hardening).
This commit is contained in:
SnapOtter
2026-05-13 21:33:50 +08:00
parent bc0cac42e3
commit 4e64ee2779
58 changed files with 2572 additions and 3677 deletions
+36
View File
@@ -6,6 +6,40 @@ import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PYTHON_DIR = resolve(__dirname, "../python");
/**
* Build a minimal environment for spawned Python processes.
* Only passes through variables needed for venv, CUDA, model cache,
* package resolution, and locale -- avoids leaking secrets or
* application config from the parent process.
*/
function buildMinimalEnv(): Record<string, string> {
const env: Record<string, string> = {
PYTHONUNBUFFERED: "1",
LANG: process.env.LANG || "C.UTF-8",
};
const passthrough = [
"PATH",
"HOME",
"VIRTUAL_ENV",
"PYTHONPATH",
"CUDA_VISIBLE_DEVICES",
"LD_LIBRARY_PATH",
// Application-specific vars the sidecar scripts depend on
"DATA_DIR",
"MODELS_DIR",
"U2NET_HOME",
"PROCESSING_TIMEOUT_S",
"DISPATCHER_MAX_REQUESTS",
"PYTHON_VENV_PATH",
];
for (const key of passthrough) {
if (process.env[key] !== undefined) {
env[key] = process.env[key] as string;
}
}
return env;
}
/** Try venv first, then system python. */
function getPythonPath(): string {
const venvPath = process.env.PYTHON_VENV_PATH || resolve(__dirname, "../../../.venv");
@@ -115,6 +149,7 @@ function startDispatcher(): ChildProcess | null {
try {
const child = spawn(getPythonPath(), [resolve(PYTHON_DIR, "dispatcher.py")], {
stdio: ["pipe", "pipe", "pipe"],
env: buildMinimalEnv(),
});
let stderrBuffer = "";
@@ -394,6 +429,7 @@ function runPythonPerRequest(
const trySpawn = (pythonBin: string, isFallback: boolean) => {
const child = spawn(pythonBin, [scriptPath, ...args], {
stdio: ["ignore", "pipe", "pipe"],
env: buildMinimalEnv(),
});
let stdout = "";