mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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 ──
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user