From d61226496b5745ff7f57ee8138009d4f81826095 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 20 Jun 2026 00:53:11 +0800 Subject: [PATCH] fix(security): harden rate limits, Redis auth, resource caps, and error sanitization - Lower LOGIN_ATTEMPT_LIMIT default from 30 to 10 (brute-force protection) - Lower RATE_LIMIT_PER_MIN default from 1000 to 300 - Add Redis authentication (requirepass) with REDIS_PASSWORD env var - Add Redis maxmemory 512mb cap to prevent unbounded growth - Add mem_limit: 1g to Postgres and Redis containers - Strip internal file paths from all error responses (defense-in-depth) - Add startup warnings for default admin/Postgres/Redis credentials - Update security test expectations for new defaults --- apps/api/src/index.ts | 5 +++-- apps/api/src/lib/env.ts | 4 ++-- docker/docker-compose-gpu.yml | 15 +++++++++++---- docker/docker-compose.yml | 15 +++++++++++---- docker/entrypoint.sh | 13 +++++++++++++ tests/unit/security-auth-hardening.test.ts | 4 ++-- 6 files changed, 42 insertions(+), 14 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5c647777..e779000c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -20,6 +20,7 @@ import { closeWorkers, startWorkers } from "./jobs/worker.js"; import { captureException, initAnalytics, shutdownAnalytics } from "./lib/analytics.js"; import { shouldRunStartupCleanup } from "./lib/cleanup.js"; import { buildCsp } from "./lib/csp.js"; +import { stripInternalPaths } from "./lib/errors.js"; import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js"; import { logger } from "./lib/logger.js"; import { requestDuration } from "./lib/metrics.js"; @@ -229,8 +230,8 @@ app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => request.log.warn({ err: error, url: request.url, method: request.method }, "Request error"); } reply.status(statusCode).send({ - error: statusCode >= 500 ? "Internal server error" : error.message, - ...(statusCode < 500 && { details: error.message }), + error: statusCode >= 500 ? "Internal server error" : stripInternalPaths(error.message), + ...(statusCode < 500 && { details: stripInternalPaths(error.message) }), }); }); diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 1df582e6..1116d29a 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -32,7 +32,7 @@ const envSchema = z MAX_BATCH_SIZE: z.coerce.number().default(0), CONCURRENT_JOBS: z.coerce.number().default(0), MAX_MEGAPIXELS: z.coerce.number().default(0), - RATE_LIMIT_PER_MIN: z.coerce.number().default(1000), + RATE_LIMIT_PER_MIN: z.coerce.number().default(300), DATABASE_URL: z.string().default("postgres://snapotter:snapotter@localhost:5432/snapotter"), SQLITE_MIGRATE_PATH: z.string().default(""), FILES_STORAGE_PATH: z.string().default("./data/files"), @@ -57,7 +57,7 @@ const envSchema = z MAX_VIDEO_BITRATE_KBPS: z.coerce.number().default(0), LIBREOFFICE_TIMEOUT_S: z.coerce.number().default(120), SESSION_DURATION_HOURS: z.coerce.number().default(168), - LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30), + LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(10), TRUST_PROXY: z.string().default("false"), OIDC_ENABLED: z .enum(["true", "false"]) diff --git a/docker/docker-compose-gpu.yml b/docker/docker-compose-gpu.yml index d2d1cd59..369721ee 100644 --- a/docker/docker-compose-gpu.yml +++ b/docker/docker-compose-gpu.yml @@ -33,12 +33,12 @@ services: - MAX_WORKER_THREADS=${MAX_WORKER_THREADS:-0} - PROCESSING_TIMEOUT_S=${PROCESSING_TIMEOUT_S:-0} - MAX_PIPELINE_STEPS=${MAX_PIPELINE_STEPS:-20} - - RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-1000} + - RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-300} - MAX_USERS=${MAX_USERS:-0} - SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168} - TRUST_PROXY=${TRUST_PROXY:-true} - DATABASE_URL=postgres://${POSTGRES_USER:-snapotter}:${POSTGRES_PASSWORD:-snapotter}@postgres:5432/${POSTGRES_DB:-snapotter} - - REDIS_URL=redis://redis:6379 + - REDIS_URL=redis://:${REDIS_PASSWORD:-snapotter}@redis:6379 # 1.x upgrade: uncomment to import the old SQLite database on first boot; # re-comment after the migration succeeds. # - SQLITE_MIGRATE_PATH=/data/snapotter.db @@ -121,6 +121,7 @@ services: volumes: - SnapOtter-pgdata:/var/lib/postgresql/data restart: unless-stopped + mem_limit: 1g healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter}"] interval: 10s @@ -131,12 +132,18 @@ services: redis: image: redis:8-alpine container_name: SnapOtter-redis - command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes"] + command: >- + redis-server + --maxmemory-policy noeviction + --maxmemory 512mb + --appendonly yes + --requirepass ${REDIS_PASSWORD:-snapotter} volumes: - SnapOtter-redisdata:/data restart: unless-stopped + mem_limit: 1g healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-snapotter}", "--no-auth-warning", "ping"] interval: 10s timeout: 5s retries: 12 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 176ee200..0c3e279a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -32,12 +32,12 @@ services: - MAX_WORKER_THREADS=${MAX_WORKER_THREADS:-0} - PROCESSING_TIMEOUT_S=${PROCESSING_TIMEOUT_S:-0} - MAX_PIPELINE_STEPS=${MAX_PIPELINE_STEPS:-20} - - RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-1000} + - RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-300} - MAX_USERS=${MAX_USERS:-0} - SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168} - TRUST_PROXY=${TRUST_PROXY:-true} - DATABASE_URL=postgres://${POSTGRES_USER:-snapotter}:${POSTGRES_PASSWORD:-snapotter}@postgres:5432/${POSTGRES_DB:-snapotter} - - REDIS_URL=redis://redis:6379 + - REDIS_URL=redis://:${REDIS_PASSWORD:-snapotter}@redis:6379 # 1.x upgrade: uncomment to import the old SQLite database on first boot; # re-comment after the migration succeeds. # - SQLITE_MIGRATE_PATH=/data/snapotter.db @@ -113,6 +113,7 @@ services: volumes: - SnapOtter-pgdata:/var/lib/postgresql/data restart: unless-stopped + mem_limit: 1g healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter}"] interval: 10s @@ -123,12 +124,18 @@ services: redis: image: redis:8-alpine container_name: SnapOtter-redis - command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes"] + command: >- + redis-server + --maxmemory-policy noeviction + --maxmemory 512mb + --appendonly yes + --requirepass ${REDIS_PASSWORD:-snapotter} volumes: - SnapOtter-redisdata:/data restart: unless-stopped + mem_limit: 1g healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-snapotter}", "--no-auth-warning", "ping"] interval: 10s timeout: 5s retries: 12 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 2524aca2..08641f8f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -104,6 +104,18 @@ if [ -n "${DATABASE_URL:-}" ]; then echo "Postgres is reachable." fi +print_security_warnings() { + if [ "${DEFAULT_PASSWORD}" = "admin" ]; then + printf ' \033[33mWARNING:%b Default admin password is still "admin". Change it for any non-local deployment.\n' '\033[0m' >&2 + fi + if echo "${DATABASE_URL:-}" | grep -q "snapotter:snapotter@"; then + printf ' \033[33mWARNING:%b Default Postgres credentials in use. Set POSTGRES_PASSWORD for production.\n' '\033[0m' >&2 + fi + if echo "${REDIS_URL:-}" | grep -q ":snapotter@"; then + printf ' \033[33mWARNING:%b Default Redis password in use. Set REDIS_PASSWORD for production.\n' '\033[0m' >&2 + fi +} + print_banner() { RST='\033[0m' printf '\n' @@ -114,6 +126,7 @@ print_banner() { printf ' \033[33m➜%b Login \033[1m%s%b / \033[1m[CHANGE ON FIRST LOGIN]%b\n' "$RST" "${DEFAULT_USERNAME}" "$RST" "$RST" printf ' \033[36m➜%b Docs \033[2mhttps://docs.snapotter.com%b\n' "$RST" "$RST" printf '\n' + print_security_warnings } # Fix ownership of mounted volumes so the non-root snapotter user can write. diff --git a/tests/unit/security-auth-hardening.test.ts b/tests/unit/security-auth-hardening.test.ts index 4d232e62..6dd8ef08 100644 --- a/tests/unit/security-auth-hardening.test.ts +++ b/tests/unit/security-auth-hardening.test.ts @@ -26,9 +26,9 @@ import { // ── Env defaults ───────────────────────────────────────────────────────────── describe("Security: env defaults", () => { - it("LOGIN_ATTEMPT_LIMIT defaults to 30", () => { + it("LOGIN_ATTEMPT_LIMIT defaults to 10", () => { const env = loadEnv(); - expect(env.LOGIN_ATTEMPT_LIMIT).toBe(30); + expect(env.LOGIN_ATTEMPT_LIMIT).toBe(10); }); it("RATE_LIMIT_PER_MIN is parsed correctly (test env overrides to 10000)", () => {