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
# =============================================================================
# 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

View File

@@ -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,

View File

@@ -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

View File

@@ -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=<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
Three isolated networks replace the default flat network: