- Admin credentials configurable via env or Docker secrets
- Support for `SESSION_SECRET`: custom key for cookie signing (required in production; auto-generated in development if not provided)
+## 🔐 Security Features
+
+- HttpOnly session cookies
+- Secure cookies when HTTPS is enabled
+- SameSite=Strict cookies
+- CSP protection
+- TrustedHostMiddleware
+- Login rate limiting
+- HSTS support
+- Security headers
+
---
## 📦 Requirements
# --- Host & Web ---
DOMAIN=example.com
EXTERNAL_NAME=dyndns.example.com
+TRUSTED_HOSTS=127.0.0.1,localhost,networkmanager.example.com
HTTP_PORT=8000
+HTTPS_ENABLED=1
# --- Admin ---
ADMIN_USER=admin
ADMIN_PASSWORD=admin
# --- Session secret (optional but recommended in production) ---
# SESSION_SECRET=****ReplaceWithYourSecret*****
```
-If SESSION_SECRET is not set, the application generates a new random key at each restart, which invalidates all existing sessions.
+If SESSION_SECRET is not set, the application generates a new random key at each startup, invalidating all existing sessions.
+For production deployments, configure a persistent SESSION_SECRET.
### 3) 🐳 Example `docker-compose.yml`
```yaml
# Host
DOMAIN: "${DOMAIN:-example.com}"
EXTERNAL_NAME: "${EXTERNAL_NAME:-dyndns.example.com}"
+ TRUSTED_HOSTS: "${TRUSTED_HOSTS:-127.0.0.1,localhost,networkmanager.example.com}"
# Web
HTTP_PORT: "${HTTP_PORT:-8000}"
+ HTTPS_ENABLED: "${HTTPS_ENABLED:-0}"
LOGIN_MAX_ATTEMPTS: "${LOGIN_MAX_ATTEMPTS:-5}"
LOGIN_WINDOW_SECONDS: "${LOGIN_WINDOW_SECONDS:-600}"
# Admin
| `LOG_ACCESS_FILE` | access.log | HTTP access log |
| `DOMAIN` | example.com | Public domain |
| `EXTERNAL_NAME` | dyndns.example.com | External Name |
+| `TRUSTED_HOSTS` | 127.0.0.1,localhost,networkmanager.example.com | Comma-separated list of allowed HTTP Host headers. |
| `HTTP_HOST` | 0.0.0.0 | IP address the server binds to |
| `HTTP_PORT` | 8000 | Internal HTTP port |
+| `HTTPS_ENABLED` | false | HTTPS enabled |
| `LOGIN_MAX_ATTEMPTS` | 5 | Login attempts |
| `LOGIN_WINDOW_SECONDS` | 600 | Attempt window |
| `ADMIN_USER` | admin | Admin username |
## 🔑 SESSION_SECRET
Used to sign cookies.
-If set, the app generates a new key each time and all sessions expire on each restart.
+If SESSION_SECRET is not set, the application generates a new random key at startup, which invalidates all existing sessions after every restart.
+For production deployments, configure a persistent SESSION_SECRET value.
Generate a strong secret:
```bash
openssl rand -base64 64
---
## 🔒 Security Checklist
- Use `ADMIN_PASSWORD_HASH_FILE` in production
-- Disable `SESSION_SECRET` for automatic generation
-- Set `secure=True` on cookies if you use HTTPS
-- Use a reverse proxy with TLS
-- Do not put passwords in the repository
+- Configure `SESSION_SECRET` in production
+- Configure `TRUSTED_HOSTS`
+- Enable HTTPS through a reverse proxy
+- Set `HTTPS_ENABLED=1` when running behind HTTPS
+- Do not store credentials in the repository
---
## 📄 License
---
+## 🔧 Backup & Recovery
+- [X] Backup generation
+- [X] Backup restore
+- [X] Backup/Restore from web
+- [ ] Show Backup information (metadata, integrity, statistics) in Backup Management
+- [ ] Periodic backup of SQLite DB
+- [ ] Remote Git repository backup
+- [ ] Backup of generated configurations
+
+---
+
+## 🌍 Language
+- [X] Localization
+
+---
+
## 📝 Logs
- [x] Log Generation
# 🔐 Web Security Hardening
### 🔒 Sessions & Cookies
-- [ ] Set `secure=True` when using HTTPS
-- [ ] Set `httponly=True` to prevent access via JS
-- [ ] Set `samesite=Strict` or `Lax` depending on use
-- [ ] Controlled rotation of `SESSION_SECRET` (manual or scheduled)
+- [X] Set secure=True when using HTTPS
+- [X] Set httponly=True to prevent access via JS
+- [X] Set samesite=Strict
+- [X] Session expiration enforced server-side (TimestampSigner max_age)
+- [X] Session renewal (sliding expiration)
+- [ ] Controlled rotation of SESSION_SECRET (manual or scheduled)
+- [ ] Global session invalidation
+- [ ] Session timeout configurable from UI
### 🛡 HTTP Protection
-- [ ] Security headers:
- - [ ] `Content-Security-Policy`
- - [ ] `Strict-Transport-Security`
- - [ ] `X-Frame-Options`
- - [ ] `X-Content-Type-Options`
- - [ ] `Referrer-Policy`
+- [X] Content-Security-Policy
+- [X] Strict-Transport-Security (implemented, da applicare solo con HTTPS)
+- [X] X-Frame-Options
+- [X] X-Content-Type-Options
+- [X] Referrer-Policy
+- [X] Permissions-Policy
+- [X] Cross-Origin-Opener-Policy
+- [X] Cross-Origin-Resource-Policy
+
+### 🛡 Access Protection
+- [X] CORS allowlist
+- [X] TrustedHostMiddleware
- [ ] Enable TLS via reverse proxy
-- [ ] Additional rate limiting on sensitive IPs and endpoints
+- [ ] Restrict forwarded_allow_ips
+- [ ] Disable /docs and /openapi.json in production
+
+### 🛡 Rate Limiting
+- [X] Login rate limiting
+- [ ] Additional rate limiting on sensitive endpoints
+ - /api/backup
+ - /api/settings
+ - /api/certificates
### 🔥 Application protection
- [ ] DNS/DHCP input validation (hostname, IP, subnet)
- [ ] Input sanitization against YAML/XML/JSON injection
- [ ] Audit log of critical changes
-- [ ] Brute force protection
-
-### 🔧 Backup & Recovery
-- [X] Backup generation
-- [X] Backup restore
-- [X] Backup/Restore from web
-- [ ] Show Backup information (metadata, integrity, statistics) in Backup Management
-- [ ] Periodic backup of SQLite DB
-- [ ] Remote Git repository backup
-- [ ] Backup of generated configurations
-
-### 🌍 Language
-- [X] Localization
+- [X] Brute force protection (IP-based)
+- [ ] Account lockout after N failures
+- [ ] Security event logging
---
- [ ] Password reset
- [ ] User disabling
- [ ] Admin password change
-- [ ] Hash-based authentication (bcrypt or argon2)
+- [X] Hash-based authentication (bcrypt or argon2)
- [ ] Audit log (who did what, when)
-- [ ] Session timeout
+- [X] Session timeout
- [ ] Protection against session hijacking
- [ ] Global logout / session invalidation
- [ ] Themes & Dark mode
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse, JSONResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.middleware.trustedhost import TrustedHostMiddleware
from typing import Callable
# Import Routers
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
- # CSP rigida per produzione
+ # CSP for production
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"base-uri 'self'; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"img-src 'self' data:; "
- "font-src 'self' data:; "
- "style-src 'self'; "
- "script-src 'self'; "
- "connect-src 'self'; "
+ "font-src 'self' data: https://cdn.jsdelivr.net; "
+ "style-src 'self' https://cdn.jsdelivr.net; "
+ "script-src 'self' https://cdn.jsdelivr.net; "
+ "connect-src 'self' https://cdn.jsdelivr.net; "
"manifest-src 'self'; "
- "worker-src 'self'"
+ "worker-src 'self'; "
)
return response
allow_credentials=True,
)
- # Security headers (GRGR -> to be enabled in production)
- # app.add_middleware(SecurityHeadersMiddleware)
+ # Security headers
+ app.add_middleware(SecurityHeadersMiddleware)
# Session/Auth middleware (funzionale)
app.middleware("http")(session_middleware)
+ # Trusted Host Middleware
+ app.add_middleware(
+ TrustedHostMiddleware,
+ allowed_hosts=settings.TRUSTED_HOSTS,
+ )
+
# Route per file del frontend
app.add_api_route("/", home_page, methods=["GET"])
app.add_api_route("/home", home_page, methods=["GET"])
settings.APP_NAME, settings.APP_VERSION
)
logger.info(
- "App settings: frontend=%s | host=%s | port=%d | secret=%s",
- str(settings.FRONTEND_PATH), settings.HTTP_HOST, settings.HTTP_PORT, masked_secret
+ "App settings: frontend=%s | host=%s | port=%d | https_enabled=%s | secret=%s",
+ str(settings.FRONTEND_PATH), settings.HTTP_HOST, settings.HTTP_PORT, settings.HTTPS_ENABLED, masked_secret
+ )
+ logger.info(
+ " trusted_hosts=%s",
+ settings.TRUSTED_HOSTS
)
logger.info(
"Database: file=%s | reset=%s",
# import standard modules
from fastapi import APIRouter, Request, Response, HTTPException, status
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, JSONResponse
import time
# Import local modules
pwd = data.get("password")
if verify_login(user, pwd):
+ # check if HTTPS is required and the request is not secure
+ if settings.HTTPS_ENABLED and request.url.scheme != "https":
+ return JSONResponse(
+ status_code=403,
+ content={
+ "code": "HTTPS_REQUIRED",
+ "status": "failure",
+ "message": "HTTPS is required for authentication"
+ }
+ )
+
# reset tentativi su IP
login_attempts.pop(ip, None)
import bcrypt
import os
from fastapi import Request, HTTPException
-from itsdangerous import TimestampSigner
+from itsdangerous import TimestampSigner, BadSignature, SignatureExpired
# Import local modules
from backend.db.users import get_user_by_username
"session",
token,
httponly=True,
- max_age=86400,
+ secure=settings.HTTPS_ENABLED,
+ samesite="Strict",
path="/",
- #secure=True, # GRGR solo via HTTPS
- samesite="Strict"
+ max_age=86400,
)
# -----------------------------
try:
signer.unsign(token, max_age=86400)
return True
- except:
+ except (BadSignature, SignatureExpired):
return False
# -----------------------------
response.delete_cookie(
key="session",
- path="/"
+ path="/",
+ samesite="Strict",
+ secure=settings.HTTPS_ENABLED,
)
logger.debug("Session closed")
# ---------------------------------------------------------
DOMAIN = "example.com"
EXTERNAL_NAME = "dyndns.example.com"
+TRUSTED_HOSTS="127.0.0.1,localhost,*"
# ---------------------------------------------------------
# Web
# ---------------------------------------------------------
HTTP_HOST = "0.0.0.0"
HTTP_PORT = 8000
+HTTPS_ENABLED = False
LOGIN_MAX_ATTEMPTS = 5
LOGIN_WINDOW_SECONDS = 600
# Hosts
DOMAIN: str = Field(default_factory=lambda: os.getenv("DOMAIN", default.DOMAIN))
EXTERNAL_NAME: str = Field(default_factory=lambda: os.getenv("EXTERNAL_NAME", default.EXTERNAL_NAME))
+ TRUSTED_HOSTS: list[str] = Field(
+ default_factory=lambda: [
+ h.strip()
+ for h in os.getenv("TRUSTED_HOSTS", default.TRUSTED_HOSTS).split(",")
+ if h.strip()
+ ]
+ )
# Web
HTTP_HOST: str = Field(default_factory=lambda: os.getenv("HTTP_HOST", default.HTTP_HOST))
HTTP_PORT: int = Field(default_factory=lambda: to_int(os.getenv("HTTP_PORT"), default.HTTP_PORT))
+ HTTPS_ENABLED: bool = Field(default_factory=lambda: to_bool(os.getenv("HTTPS_ENABLED"), default.HTTPS_ENABLED))
SECRET_KEY: str = Field(default_factory=_load_secret_key)
LOGIN_MAX_ATTEMPTS: int = Field(default_factory=lambda: to_int(os.getenv("LOGIN_MAX_ATTEMPTS"), default.LOGIN_MAX_ATTEMPTS))
LOGIN_WINDOW_SECONDS: int = Field(default_factory=lambda: to_int(os.getenv("LOGIN_WINDOW_SECONDS"), default.LOGIN_WINDOW_SECONDS))