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
This commit is contained in:
2026-06-21 09:44:47 +02:00
parent d694c8b8e3
commit dfec7976c4
8 changed files with 78 additions and 20 deletions
+1
View File
@@ -43,6 +43,7 @@ MAX_LOGIN_ATTEMPTS=5
LOGIN_LOCKOUT_MINUTES=15 LOGIN_LOCKOUT_MINUTES=15
RATE_LIMIT_DEFAULT=100 RATE_LIMIT_DEFAULT=100
RATE_LIMIT_LOGIN=10 RATE_LIMIT_LOGIN=10
ALLOWED_HOSTS=localhost,127.0.0.1,testserver,nexadash-api
# ----------------------------------------------------------- # -----------------------------------------------------------
# Mail (SMTP) # Mail (SMTP)
+15
View File
@@ -112,6 +112,21 @@ docker compose up -d
docker compose exec api alembic upgrade head 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 ## Developer Setup
### Backend ### Backend
+1
View File
@@ -30,6 +30,7 @@ class Settings(BaseSettings):
LOGIN_LOCKOUT_MINUTES: int = 15 LOGIN_LOCKOUT_MINUTES: int = 15
RATE_LIMIT_DEFAULT: int = 100 RATE_LIMIT_DEFAULT: int = 100
RATE_LIMIT_LOGIN: int = 10 RATE_LIMIT_LOGIN: int = 10
ALLOWED_HOSTS: str = "localhost,127.0.0.1,testserver,nexadash-api"
# Mail # Mail
SMTP_HOST: str = "" SMTP_HOST: str = ""
+5 -1
View File
@@ -14,6 +14,10 @@ from apps.api.src.models.base import Base
from apps.api.src.security.rate_limit import limiter 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): class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
response = await call_next(request) response = await call_next(request)
@@ -60,7 +64,7 @@ def create_app() -> FastAPI:
allow_headers=["*"], allow_headers=["*"],
) )
app.add_middleware(SecurityHeadersMiddleware) 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) app.include_router(api_router)
+4
View File
@@ -23,6 +23,10 @@ def decrypt_secret(ciphertext: str) -> str:
return _get_fernet().decrypt(ciphertext.encode()).decode() return _get_fernet().decrypt(ciphertext.encode()).decode()
encrypt_value = encrypt_secret
decrypt_value = decrypt_secret
def hash_token(token: str) -> str: def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest() return hashlib.sha256(token.encode()).hexdigest()
+28 -11
View File
@@ -1,16 +1,28 @@
import ipaddress
import socket
from urllib.parse import urlparse from urllib.parse import urlparse
BLOCKED_SCHEMES = {"file", "gopher", "ftp", "dict", "ldap", "tftp"}
BLOCKED_HOSTS = { BLOCKED_HOSTS = {
"localhost", "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: def is_safe_url(url: str) -> bool:
try: try:
parsed = urlparse(url) parsed = urlparse(url)
@@ -21,12 +33,17 @@ def is_safe_url(url: str) -> bool:
hostname = parsed.hostname hostname = parsed.hostname
if not hostname: if not hostname:
return False return False
if hostname.lower() in BLOCKED_HOSTS: hostname = hostname.strip().lower().rstrip(".")
if hostname in BLOCKED_HOSTS:
return False return False
# Block internal IP ranges if _is_blocked_ip(hostname):
parts = hostname.split(".") return False
if len(parts) == 4 and all(p.isdigit() for p in parts): try:
first = int(parts[0]) resolved = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
if first in {10, 127} or (first == 192 and parts[1] == "168"): except socket.gaierror:
return False
for result in resolved:
address = result[4][0]
if _is_blocked_ip(address):
return False return False
return True return True
+4
View File
@@ -24,6 +24,10 @@ def test_mask_secret():
def test_ssrf_protection(): def test_ssrf_protection():
assert not is_safe_url("http://localhost:8000") 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://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("http://169.254.169.254")
assert not is_safe_url("ftp://example.com") assert not is_safe_url("ftp://example.com")
assert is_safe_url("https://example.com/api") assert is_safe_url("https://example.com/api")
+20 -8
View File
@@ -2,20 +2,22 @@ name: nexadash
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:18.4-alpine
container_name: nexadash-postgres container_name: nexadash-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-nexadash} POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-this-strong-password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-nexadash} POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
security_opt:
- no-new-privileges:true
networks: networks:
- nexadash - nexadash
@@ -23,14 +25,18 @@ services:
image: redis:7-alpine image: redis:7-alpine
container_name: nexadash-redis container_name: nexadash-redis
restart: unless-stopped 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: volumes:
- redis_data:/data - redis_data:/data
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
security_opt:
- no-new-privileges:true
networks: networks:
- nexadash - nexadash
@@ -61,6 +67,8 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
security_opt:
- no-new-privileges:true
networks: networks:
- nexadash - nexadash
@@ -84,6 +92,8 @@ services:
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND} CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND}
volumes: volumes:
- plugin_data:/app/plugins - plugin_data:/app/plugins
security_opt:
- no-new-privileges:true
networks: networks:
- nexadash - nexadash
@@ -107,6 +117,8 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
security_opt:
- no-new-privileges:true
networks: networks:
- nexadash - nexadash