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
+50
View File
@@ -9,7 +9,17 @@ Request format: {"id": "uuid", "script": "remove_bg", "args": [...]}
Response format: {"id": "uuid", "stdout": "...", "exitCode": 0}
Pre-imports heavy libraries at startup to eliminate cold-start latency.
Security boundary
-----------------
Scripts run in the dispatcher process space via dynamic module loading (exec()).
There is NO process-level isolation between scripts. The security boundary is the
ALLOWED_SCRIPTS allowlist below -- only filenames present in that set can be
loaded and executed. The allowlist is validated against a strict regex that
forbids path separators, dots (except the .py suffix added internally), and
non-alphanumeric characters other than underscores.
"""
import re
import sys
import json
import gc
@@ -18,6 +28,31 @@ import os
import traceback
# ── Script allowlist ───────────────────────────────────────────────────
# Only these script names (without .py) may be dispatched. This is the
# primary security gate -- no path traversal, no arbitrary file execution.
ALLOWED_SCRIPTS = {
"colorize",
"detect_faces",
"enhance_faces",
"face_landmarks",
"inpaint",
"install_feature",
"noise_removal",
"ocr",
"ocr_preprocess",
"outpaint",
"red_eye_removal",
"remove_bg",
"restore",
"upscale",
}
# Strict pattern: lowercase alphanumeric and underscores only.
# No path separators, no dots, no spaces, no special characters.
_SCRIPT_NAME_RE = re.compile(r"^[a-z0-9_]+$")
INSTALLED_PATH = os.path.join(os.environ.get("DATA_DIR", "/data"), "ai", "installed.json")
MODELS_DIR = os.path.join(os.environ.get("DATA_DIR", "/data"), "ai", "models")
@@ -124,6 +159,21 @@ def _run_script_main(script_name, args):
"""
import threading
# ── Security gate: validate script name against allowlist ──
if not _SCRIPT_NAME_RE.match(script_name):
return (json.dumps({
"success": False,
"error": "invalid_script_name",
"message": f"Script name contains invalid characters: {script_name!r}"
}), 1)
if script_name not in ALLOWED_SCRIPTS:
return (json.dumps({
"success": False,
"error": "script_not_allowed",
"message": f"Script '{script_name}' is not in the allowed scripts list"
}), 1)
script_dir = os.path.dirname(os.path.abspath(__file__))
# ── Feature gate: reject scripts whose bundle is not installed ──
+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 = "";