From dfec7976c4346311c48351bb996e4875dd0d32af Mon Sep 17 00:00:00 2001 From: nessi Date: Sun, 21 Jun 2026 09:44:47 +0200 Subject: [PATCH] Add security hardening and PostgreSQL 18 upgrade support Add ALLOWED_HOSTS configuration to restrict trusted hosts in TrustedHostMiddleware. Enhance SSRF protection to block all private, loopback, link-local, multicast, reserved, and unspecified IP addresses using ipaddress module and DNS resolution checks. Add encrypt_value/decrypt_value aliases for encryption functions. Upgrade PostgreSQL from 16 to 18.4 in Docker Compose with updated data directory path (/var/lib/postgresql). Add security_opt no --- .env.example | 1 + README.md | 15 +++++++++++ apps/api/src/config.py | 1 + apps/api/src/main.py | 6 ++++- apps/api/src/security/encryption.py | 4 +++ apps/api/src/security/ssrf.py | 39 +++++++++++++++++++++-------- apps/api/tests/test_security.py | 4 +++ compose.yaml | 28 +++++++++++++++------ 8 files changed, 78 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 999b90c..20b6a06 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,7 @@ MAX_LOGIN_ATTEMPTS=5 LOGIN_LOCKOUT_MINUTES=15 RATE_LIMIT_DEFAULT=100 RATE_LIMIT_LOGIN=10 +ALLOWED_HOSTS=localhost,127.0.0.1,testserver,nexadash-api # ----------------------------------------------------------- # Mail (SMTP) diff --git a/README.md b/README.md index 62c6178..26b35cc 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,21 @@ docker compose up -d docker compose exec api alembic upgrade head ``` +### PostgreSQL 18 Upgrade Note + +The bundled Docker Compose stack uses PostgreSQL 18.4. PostgreSQL 18 changed the +official Docker image data directory, so existing PostgreSQL 16/17 deployments +should be backed up before upgrading and restored into the new container: + +```bash +docker compose exec postgres pg_dump -U nexadash nexadash > nexadash-backup.sql +docker compose down +docker volume rm nexadash_postgres_data +docker compose up -d postgres +docker compose exec -T postgres psql -U nexadash nexadash < nexadash-backup.sql +docker compose up -d +``` + ## Developer Setup ### Backend diff --git a/apps/api/src/config.py b/apps/api/src/config.py index b306c2a..6213bcd 100644 --- a/apps/api/src/config.py +++ b/apps/api/src/config.py @@ -30,6 +30,7 @@ class Settings(BaseSettings): LOGIN_LOCKOUT_MINUTES: int = 15 RATE_LIMIT_DEFAULT: int = 100 RATE_LIMIT_LOGIN: int = 10 + ALLOWED_HOSTS: str = "localhost,127.0.0.1,testserver,nexadash-api" # Mail SMTP_HOST: str = "" diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 7294b22..348727f 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -14,6 +14,10 @@ from apps.api.src.models.base import Base from apps.api.src.security.rate_limit import limiter +def _parse_csv_setting(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + class SecurityHeadersMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): response = await call_next(request) @@ -60,7 +64,7 @@ def create_app() -> FastAPI: allow_headers=["*"], ) app.add_middleware(SecurityHeadersMiddleware) - app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"]) + app.add_middleware(TrustedHostMiddleware, allowed_hosts=_parse_csv_setting(settings.ALLOWED_HOSTS)) app.include_router(api_router) diff --git a/apps/api/src/security/encryption.py b/apps/api/src/security/encryption.py index a80b4a7..3dad3ea 100644 --- a/apps/api/src/security/encryption.py +++ b/apps/api/src/security/encryption.py @@ -23,6 +23,10 @@ def decrypt_secret(ciphertext: str) -> str: return _get_fernet().decrypt(ciphertext.encode()).decode() +encrypt_value = encrypt_secret +decrypt_value = decrypt_secret + + def hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() diff --git a/apps/api/src/security/ssrf.py b/apps/api/src/security/ssrf.py index 5feb717..a69def0 100644 --- a/apps/api/src/security/ssrf.py +++ b/apps/api/src/security/ssrf.py @@ -1,16 +1,28 @@ +import ipaddress +import socket from urllib.parse import urlparse -BLOCKED_SCHEMES = {"file", "gopher", "ftp", "dict", "ldap", "tftp"} BLOCKED_HOSTS = { "localhost", - "127.0.0.1", - "0.0.0.0", - "::1", - "169.254.169.254", # AWS metadata } +def _is_blocked_ip(value: str) -> bool: + try: + ip = ipaddress.ip_address(value) + except ValueError: + return False + return ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ) + + def is_safe_url(url: str) -> bool: try: parsed = urlparse(url) @@ -21,12 +33,17 @@ def is_safe_url(url: str) -> bool: hostname = parsed.hostname if not hostname: return False - if hostname.lower() in BLOCKED_HOSTS: + hostname = hostname.strip().lower().rstrip(".") + if hostname in BLOCKED_HOSTS: return False - # Block internal IP ranges - parts = hostname.split(".") - if len(parts) == 4 and all(p.isdigit() for p in parts): - first = int(parts[0]) - if first in {10, 127} or (first == 192 and parts[1] == "168"): + if _is_blocked_ip(hostname): + return False + try: + resolved = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except socket.gaierror: + return False + for result in resolved: + address = result[4][0] + if _is_blocked_ip(address): return False return True diff --git a/apps/api/tests/test_security.py b/apps/api/tests/test_security.py index 4024b62..48c41e5 100644 --- a/apps/api/tests/test_security.py +++ b/apps/api/tests/test_security.py @@ -24,6 +24,10 @@ def test_mask_secret(): def test_ssrf_protection(): assert not is_safe_url("http://localhost:8000") assert not is_safe_url("http://127.0.0.1/admin") + assert not is_safe_url("http://10.0.0.1/admin") + assert not is_safe_url("http://172.16.0.1/admin") + assert not is_safe_url("http://192.168.1.1/admin") + assert not is_safe_url("http://[::1]/admin") assert not is_safe_url("http://169.254.169.254") assert not is_safe_url("ftp://example.com") assert is_safe_url("https://example.com/api") diff --git a/compose.yaml b/compose.yaml index 3df4832..c71c2cd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,20 +2,22 @@ name: nexadash services: postgres: - image: postgres:16-alpine + image: postgres:18.4-alpine container_name: nexadash-postgres restart: unless-stopped environment: - POSTGRES_USER: ${POSTGRES_USER:-nexadash} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-this-strong-password} - POSTGRES_DB: ${POSTGRES_DB:-nexadash} + POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env} volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nexadash} -d ${POSTGRES_DB:-nexadash}"] + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] interval: 10s timeout: 5s retries: 5 + security_opt: + - no-new-privileges:true networks: - nexadash @@ -23,14 +25,18 @@ services: image: redis:7-alpine container_name: nexadash-redis restart: unless-stopped - command: redis-server --requirepass ${REDIS_PASSWORD:-change-this-redis-password} + command: ["sh", "-c", "redis-server --requirepass \"$${REDIS_PASSWORD}\""] + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env} volumes: - redis_data:/data healthcheck: - test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-change-this-redis-password}", "ping"] + test: ["CMD-SHELL", "REDISCLI_AUTH=\"$${REDIS_PASSWORD}\" redis-cli ping"] interval: 10s timeout: 5s retries: 5 + security_opt: + - no-new-privileges:true networks: - nexadash @@ -61,6 +67,8 @@ services: interval: 30s timeout: 10s retries: 3 + security_opt: + - no-new-privileges:true networks: - nexadash @@ -84,6 +92,8 @@ services: CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND} volumes: - plugin_data:/app/plugins + security_opt: + - no-new-privileges:true networks: - nexadash @@ -107,6 +117,8 @@ services: interval: 30s timeout: 10s retries: 3 + security_opt: + - no-new-privileges:true networks: - nexadash