fix(security): harden Redis auth, restrict /metrics, document Gitea port fix
Some checks failed
CI / ruff (push) Successful in 10s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 23:15:15 +01:00
parent a7392de9f6
commit b68d542258
4 changed files with 60 additions and 8 deletions

View File

@@ -149,6 +149,10 @@ SEED_ORDERS_PER_STORE=10
# ============================================================================= # =============================================================================
# CELERY / REDIS TASK QUEUE # 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) # Redis connection URL (used for Celery broker and backend)
# Default works with: docker-compose up -d redis # Default works with: docker-compose up -d redis
REDIS_URL=redis://localhost:6379/0 REDIS_URL=redis://localhost:6379/0

View File

@@ -34,7 +34,8 @@ from datetime import UTC, datetime
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from fastapi import APIRouter, Response from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -538,12 +539,20 @@ async def readiness_check() -> dict[str, Any]:
@health_router.get("/metrics") @health_router.get("/metrics")
async def metrics_endpoint() -> Response: async def metrics_endpoint(request: Request) -> Response:
""" """
Prometheus metrics endpoint. Prometheus metrics endpoint.
Returns metrics in Prometheus text format for scraping. 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() content = metrics_registry.generate_latest()
return Response( return Response(
content=content, content=content,

View File

@@ -21,7 +21,7 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
restart: always 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 mem_limit: 128m
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
@@ -42,7 +42,7 @@ services:
environment: environment:
DATABASE_URL: postgresql://orion_user:secure_password@db:5432/orion_db DATABASE_URL: postgresql://orion_user:secure_password@db:5432/orion_db
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-your-super-secret-key} 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" USE_CELERY: "true"
depends_on: depends_on:
db: db:
@@ -73,7 +73,7 @@ services:
env_file: .env env_file: .env
environment: environment:
DATABASE_URL: postgresql://orion_user:secure_password@db:5432/orion_db 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -101,7 +101,7 @@ services:
env_file: .env env_file: .env
environment: environment:
DATABASE_URL: postgresql://orion_user:secure_password@db:5432/orion_db 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -123,7 +123,7 @@ services:
ports: ports:
- "127.0.0.1:5555:5555" - "127.0.0.1:5555:5555"
environment: 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} FLOWER_BASIC_AUTH: ${FLOWER_USER:-admin}:${FLOWER_PASSWORD:-changeme}
depends_on: depends_on:
redis: redis:
@@ -238,7 +238,7 @@ services:
ports: ports:
- "127.0.0.1:9121:9121" - "127.0.0.1:9121:9121"
environment: environment:
REDIS_ADDR: redis://redis:6379 REDIS_ADDR: redis://:${REDIS_PASSWORD:-changeme}@redis:6379
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy

View File

@@ -1724,6 +1724,22 @@ Docker network segmentation, fail2ban configuration, and automatic security upda
| API | `"127.0.0.1:8001:8000"` | `"8001:8000"` | | API | `"127.0.0.1:8001:8000"` | `"8001:8000"` |
| Flower | `"127.0.0.1:5555:5555"` | `"5555:5555"` | | 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:** **After deploying, verify no services are exposed:**
```bash ```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 # Should show 127.0.0.1 only for app services
sudo ss -tlnp | grep -E '(8001|5555|9090|3001)' 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=<generated-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 ### 20.1 Docker Network Segmentation
Three isolated networks replace the default flat network: Three isolated networks replace the default flat network: