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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user