From: Giorgio Ravera Date: Thu, 6 Aug 2026 11:53:02 +0000 (+0200) Subject: Improved security X-Git-Url: http://git.giorgioravera.it/?a=commitdiff_plain;h=37207e131f53200838b44961071307aa333ee5a5;p=network-manager.git Improved security --- diff --git a/README.md b/README.md index 8f60f36..3addc9a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,17 @@ This project is currently under development. For upcoming tasks and planned impr - 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 @@ -69,7 +80,9 @@ project/ # --- 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 @@ -83,7 +96,8 @@ LOG_TO_FILE=false # --- 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 @@ -108,8 +122,10 @@ services: # 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 @@ -143,8 +159,10 @@ secrets: | `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 | @@ -191,7 +209,8 @@ Docker compose will mount it in: ## 🔑 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 @@ -236,10 +255,11 @@ docker compose up --build -d --force-recreate --- ## 🔒 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 diff --git a/TODO.md b/TODO.md index db15e49..bfec49c 100644 --- a/TODO.md +++ b/TODO.md @@ -52,6 +52,22 @@ --- +## 🔧 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 @@ -114,38 +130,46 @@ # 🔐 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 --- @@ -161,9 +185,9 @@ - [ ] 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 diff --git a/backend/app.py b/backend/app.py index 889607f..2492c6b 100644 --- a/backend/app.py +++ b/backend/app.py @@ -6,6 +6,7 @@ from fastapi import FastAPI, Request, status 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 @@ -59,19 +60,19 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): 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 @@ -244,12 +245,18 @@ def create_app() -> FastAPI: 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"]) diff --git a/backend/bootstrap.py b/backend/bootstrap.py index f869fb1..d120333 100644 --- a/backend/bootstrap.py +++ b/backend/bootstrap.py @@ -29,8 +29,12 @@ def print_welcome(logger): 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", diff --git a/backend/routes/login.py b/backend/routes/login.py index b57cfe9..f1fef94 100644 --- a/backend/routes/login.py +++ b/backend/routes/login.py @@ -2,7 +2,7 @@ # 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 @@ -74,6 +74,17 @@ def api_login(request: Request, data: dict, response: Response): 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) diff --git a/backend/security.py b/backend/security.py index b364f26..bcaa056 100644 --- a/backend/security.py +++ b/backend/security.py @@ -4,7 +4,7 @@ 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 @@ -62,10 +62,10 @@ def apply_session(response, username: str | None = None, token: str | None = Non "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, ) # ----------------------------- @@ -79,7 +79,7 @@ def is_logged_in(request: Request) -> bool: try: signer.unsign(token, max_age=86400) return True - except: + except (BadSignature, SignatureExpired): return False # ----------------------------- @@ -89,7 +89,9 @@ def close_session(response): response.delete_cookie( key="session", - path="/" + path="/", + samesite="Strict", + secure=settings.HTTPS_ENABLED, ) logger.debug("Session closed") diff --git a/backend/settings/default.py b/backend/settings/default.py index d1da838..5473b32 100644 --- a/backend/settings/default.py +++ b/backend/settings/default.py @@ -34,12 +34,14 @@ LOG_ACCESS_FILE = "access.log" # --------------------------------------------------------- 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 diff --git a/backend/settings/settings.py b/backend/settings/settings.py index 54e63e5..4d5c518 100644 --- a/backend/settings/settings.py +++ b/backend/settings/settings.py @@ -91,10 +91,18 @@ class Settings(BaseModel): # 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))