]> git.giorgioravera.it Git - network-manager.git/commitdiff
Improved security
authorGiorgio Ravera <giorgio.ravera@gmail.com>
Thu, 6 Aug 2026 11:53:02 +0000 (13:53 +0200)
committerGiorgio Ravera <giorgio.ravera@gmail.com>
Thu, 6 Aug 2026 11:53:02 +0000 (13:53 +0200)
README.md
TODO.md
backend/app.py
backend/bootstrap.py
backend/routes/login.py
backend/security.py
backend/settings/default.py
backend/settings/settings.py

index 8f60f366fb46da23196640cca56095fe5d2baa2e..3addc9a137eaea63a20ef44f02cf28dd09a5a187 100644 (file)
--- 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 db15e49a99de3902a3dbf6ea3f20642af6229054..bfec49c93fa5ef9bf349f45fcb9479cbc5805153 100644 (file)
--- a/TODO.md
+++ b/TODO.md
 
 ---
 
+## 🔧 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
index 889607f21c541579eb51b0d7095960bddbcad975..2492c6b62a12fc86242f7457d32ec4c0a153524e 100644 (file)
@@ -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"])
index f869fb1416c36850af576a10278ff5b1073c0a73..d1203338d76bb9158e3ef4c157b50f5c62b4b413 100644 (file)
@@ -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",
index b57cfe95691e27b33d8031ca7f54d76f5d54e127..f1fef941412cbdb2e3ec46a618e3ecd479381452 100644 (file)
@@ -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)
 
index b364f26434ea32f92fbe327be67ff5849cfd2d0f..bcaa056c0b93990af924c80afc2b2f57d9353b46 100644 (file)
@@ -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")
index d1da8388bc56fc16625e389588902827699ebe34..5473b32a7d61c33e538af4aaf561214377ec9dac 100644 (file)
@@ -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
 
index 54e63e5b2dc70c70806fd85c5313b137140f6f84..4d5c518d2dfbc4f71f9bbc2e5890dc66621a99dd 100644 (file)
@@ -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))