From b68d54225884ad76cf57a4c6fcbbfb7d22f39317 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 4 Mar 2026 23:15:15 +0100 Subject: [PATCH] fix(security): harden Redis auth, restrict /metrics, document Gitea port fix - Add Redis password via REDIS_PASSWORD env var (--requirepass flag) - Update all REDIS_URL and REDIS_ADDR references to include password - Restrict /metrics endpoint to localhost and Docker internal networks (403 for external requests) - Document Gitea port 3000 localhost binding fix (must be applied manually on server) - Add REDIS_PASSWORD to .env.example Co-Authored-By: Claude Opus 4.6 --- .env.example | 4 +++ app/core/observability.py | 13 +++++++-- docker-compose.yml | 12 ++++---- docs/deployment/hetzner-server-setup.md | 39 +++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 4ec8b68f..fecc58f8 100644 --- a/.env.example +++ b/.env.example @@ -149,6 +149,10 @@ SEED_ORDERS_PER_STORE=10 # ============================================================================= # CELERY / REDIS TASK QUEUE # ============================================================================= +# Redis password (must match docker-compose.yml --requirepass flag) +# ⚠️ CHANGE THIS IN PRODUCTION! Generate with: openssl rand -hex 16 +REDIS_PASSWORD=changeme + # Redis connection URL (used for Celery broker and backend) # Default works with: docker-compose up -d redis REDIS_URL=redis://localhost:6379/0 diff --git a/app/core/observability.py b/app/core/observability.py index 08077cf8..19f70338 100644 --- a/app/core/observability.py +++ b/app/core/observability.py @@ -34,7 +34,8 @@ from datetime import UTC, datetime from enum import Enum from typing import Any -from fastapi import APIRouter, Response +from fastapi import APIRouter, Request, Response +from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) @@ -538,12 +539,20 @@ async def readiness_check() -> dict[str, Any]: @health_router.get("/metrics") -async def metrics_endpoint() -> Response: +async def metrics_endpoint(request: Request) -> Response: """ Prometheus metrics endpoint. Returns metrics in Prometheus text format for scraping. + Restricted to localhost and Docker internal networks only. """ + client_ip = request.client.host if request.client else None + allowed_prefixes = ("127.", "10.", "172.16.", "172.17.", "172.18.", "172.19.", + "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", + "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", + "172.30.", "172.31.", "192.168.", "::1") + if not client_ip or not client_ip.startswith(allowed_prefixes): + return JSONResponse(status_code=403, content={"detail": "Forbidden"}) content = metrics_registry.generate_latest() return Response( content=content, diff --git a/docker-compose.yml b/docker-compose.yml index e64a3d0f..1ed5a2ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: redis: image: redis:7-alpine restart: always - command: redis-server --maxmemory 100mb --maxmemory-policy allkeys-lru + command: redis-server --maxmemory 100mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD:-changeme} mem_limit: 128m healthcheck: test: ["CMD", "redis-cli", "ping"] @@ -42,7 +42,7 @@ services: environment: DATABASE_URL: postgresql://orion_user:secure_password@db:5432/orion_db JWT_SECRET_KEY: ${JWT_SECRET_KEY:-your-super-secret-key} - REDIS_URL: redis://redis:6379/0 + REDIS_URL: redis://:${REDIS_PASSWORD:-changeme}@redis:6379/0 USE_CELERY: "true" depends_on: db: @@ -73,7 +73,7 @@ services: env_file: .env environment: DATABASE_URL: postgresql://orion_user:secure_password@db:5432/orion_db - REDIS_URL: redis://redis:6379/0 + REDIS_URL: redis://:${REDIS_PASSWORD:-changeme}@redis:6379/0 depends_on: db: condition: service_healthy @@ -101,7 +101,7 @@ services: env_file: .env environment: DATABASE_URL: postgresql://orion_user:secure_password@db:5432/orion_db - REDIS_URL: redis://redis:6379/0 + REDIS_URL: redis://:${REDIS_PASSWORD:-changeme}@redis:6379/0 depends_on: db: condition: service_healthy @@ -123,7 +123,7 @@ services: ports: - "127.0.0.1:5555:5555" environment: - REDIS_URL: redis://redis:6379/0 + REDIS_URL: redis://:${REDIS_PASSWORD:-changeme}@redis:6379/0 FLOWER_BASIC_AUTH: ${FLOWER_USER:-admin}:${FLOWER_PASSWORD:-changeme} depends_on: redis: @@ -238,7 +238,7 @@ services: ports: - "127.0.0.1:9121:9121" environment: - REDIS_ADDR: redis://redis:6379 + REDIS_ADDR: redis://:${REDIS_PASSWORD:-changeme}@redis:6379 depends_on: redis: condition: service_healthy diff --git a/docs/deployment/hetzner-server-setup.md b/docs/deployment/hetzner-server-setup.md index 4d04d022..a90f0d9e 100644 --- a/docs/deployment/hetzner-server-setup.md +++ b/docs/deployment/hetzner-server-setup.md @@ -1724,6 +1724,22 @@ Docker network segmentation, fail2ban configuration, and automatic security upda | API | `"127.0.0.1:8001:8000"` | `"8001:8000"` | | Flower | `"127.0.0.1:5555:5555"` | `"5555:5555"` | +**Gitea stack** (`~/gitea/docker-compose.yml`) also needs this fix: + +```yaml +# BEFORE (vulnerable): +ports: + - "3000:3000" + - "2222:22" + +# AFTER (secure): +ports: + - "127.0.0.1:3000:3000" # Caddy proxies git.wizard.lu + - "2222:22" # SSH must stay public (Caddy can't proxy SSH) +``` + +Port 2222 stays public because Caddy cannot proxy SSH — this is acceptable since SSH is designed for internet exposure. Port 3000 (Gitea web UI) must be localhost-only since Caddy reverse proxies `git.wizard.lu` to it. + **After deploying, verify no services are exposed:** ```bash @@ -1732,8 +1748,31 @@ sudo ss -tlnp | grep -E '0.0.0.0:(5432|6379|6380)' # Should show 127.0.0.1 only for app services sudo ss -tlnp | grep -E '(8001|5555|9090|3001)' + +# Gitea web UI should be localhost only, SSH stays public +sudo ss -tlnp | grep 3000 # should show 127.0.0.1 +sudo ss -tlnp | grep 2222 # will show 0.0.0.0 (expected for SSH) ``` +### 20.0b Redis Authentication (Defense-in-Depth) + +Redis is isolated on Docker's internal network with no exposed ports, but as defense-in-depth a password is configured via the `REDIS_PASSWORD` environment variable. + +Add to `~/apps/orion/.env`: + +```bash +# Generate a strong password +openssl rand -hex 16 +# Add to .env +REDIS_PASSWORD= +``` + +The `docker-compose.yml` passes this to `redis-server --requirepass` and includes it in all `REDIS_URL` connection strings automatically. + +### 20.0c Prometheus /metrics Endpoint Restriction + +The `/metrics` endpoint is restricted to localhost and Docker internal networks at the application level. External requests to `https://api.wizard.lu/metrics` receive a `403 Forbidden` response. Prometheus scrapes from the Docker monitoring network (172.x.x.x) and is unaffected. + ### 20.1 Docker Network Segmentation Three isolated networks replace the default flat network: