protection de l'application contre les attaques numériques
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from fastapi import APIRouter, HTTPException, status, Form
|
||||
from fastapi import APIRouter, HTTPException, status, Form, Request, Depends
|
||||
from datetime import timedelta
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from app.core.security import (
|
||||
authenticate_user,
|
||||
create_access_token,
|
||||
@@ -8,29 +10,49 @@ from app.core.security import (
|
||||
get_current_user,
|
||||
)
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("innotexboard.security")
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(username: str = Form(...), password: str = Form(...)):
|
||||
@limiter.limit("5/minute") # Max 5 tentatives par minute par IP
|
||||
async def login(
|
||||
request: Request,
|
||||
username: str = Form(..., min_length=2, max_length=32),
|
||||
password: str = Form(..., min_length=1)
|
||||
):
|
||||
"""
|
||||
Endpoint d'authentification PAM
|
||||
Endpoint d'authentification PAM avec protection brute force
|
||||
Authentifie l'utilisateur contre le système Debian via PAM
|
||||
"""
|
||||
user = authenticate_user(username, password)
|
||||
client_ip = get_remote_address(request)
|
||||
|
||||
# Log de la tentative de connexion
|
||||
logger.info(f"Login attempt for user '{username}' from {client_ip}")
|
||||
|
||||
# Validation et authentification
|
||||
user = authenticate_user(username, password, client_ip)
|
||||
|
||||
if not user:
|
||||
logger.warning(f"Failed login for '{username}' from {client_ip}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Identifiants incorrects",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Génération du token
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
username=user.username, expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
logger.info(f"Successful login for '{username}' from {client_ip}")
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
@@ -39,13 +61,17 @@ async def login(username: str = Form(...), password: str = Form(...)):
|
||||
|
||||
|
||||
@router.get("/me", response_model=User)
|
||||
async def read_users_me(current_user: User = None):
|
||||
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||
"""Retourne les informations de l'utilisateur actuellement authentifié"""
|
||||
# Le user est validé par le dépendance get_current_user si nécessaire
|
||||
return {"username": "guest", "is_authenticated": True}
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(current_user: User = None):
|
||||
async def logout(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Endpoint de déconnexion (le token devient simplement invalide côté client)"""
|
||||
client_ip = get_remote_address(request)
|
||||
logger.info(f"User '{current_user.username}' logged out from {client_ip}")
|
||||
return {"message": "Déconnecté avec succès"}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
from pydantic import field_validator
|
||||
from typing import Optional, List, Union
|
||||
import os
|
||||
import secrets
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration de l'application"""
|
||||
@@ -10,20 +12,44 @@ class Settings(BaseSettings):
|
||||
API_VERSION: str = "0.1.0"
|
||||
API_DESCRIPTION: str = "Interface d'administration légère pour Debian"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-super-secret-key-change-in-production")
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8 heures
|
||||
# Sécurité
|
||||
DEBUG: bool = False
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: list = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3010",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3010",
|
||||
"http://127.0.0.1:5173",
|
||||
]
|
||||
# JWT - ATTENTION: Changer SECRET_KEY en production !
|
||||
SECRET_KEY: str = secrets.token_urlsafe(64)
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
|
||||
# Limite de tentatives de connexion
|
||||
MAX_LOGIN_ATTEMPTS: int = 5
|
||||
LOGIN_ATTEMPT_WINDOW: int = 900 # 15 minutes en secondes
|
||||
|
||||
# CORS - Liste blanche stricte (chaîne qui sera parsée)
|
||||
ALLOWED_ORIGINS: Union[str, List[str]] = "http://localhost:3000,http://localhost:5173"
|
||||
|
||||
@field_validator('ALLOWED_ORIGINS', mode='before')
|
||||
@classmethod
|
||||
def parse_origins(cls, v):
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(',')]
|
||||
return v
|
||||
|
||||
# Hôtes de confiance
|
||||
ALLOWED_HOSTS: Union[str, List[str]] = "localhost,127.0.0.1"
|
||||
|
||||
@field_validator('ALLOWED_HOSTS', mode='before')
|
||||
@classmethod
|
||||
def parse_hosts(cls, v):
|
||||
if isinstance(v, str):
|
||||
return [host.strip() for host in v.split(',')]
|
||||
return v
|
||||
|
||||
@field_validator('DEBUG', mode='before')
|
||||
@classmethod
|
||||
def parse_debug(cls, v):
|
||||
if isinstance(v, str):
|
||||
return v.lower() == 'true'
|
||||
return v
|
||||
|
||||
# Docker
|
||||
DOCKER_SOCKET: str = "/var/run/docker.sock"
|
||||
@@ -31,6 +57,9 @@ class Settings(BaseSettings):
|
||||
# Frontend
|
||||
FRONTEND_URL: str = os.getenv("FRONTEND_URL", "http://localhost:3000")
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PER_MINUTE: int = int(os.getenv("RATE_LIMIT_PER_MINUTE", "200"))
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, Dict
|
||||
from jose import JWTError, jwt
|
||||
from pydantic import BaseModel
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from passlib.context import CryptContext
|
||||
import pam
|
||||
import logging
|
||||
import re
|
||||
from app.core.config import settings
|
||||
|
||||
# Configuration du logging de sécurité
|
||||
logger = logging.getLogger("innotexboard.security")
|
||||
|
||||
# Contexte de hashage avec bcrypt (plus sécurisé que MD5/SHA)
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# Stockage temporaire des tentatives de connexion (en production, utiliser Redis)
|
||||
login_attempts: Dict[str, list] = {}
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: str
|
||||
@@ -24,23 +36,95 @@ class User(BaseModel):
|
||||
is_authenticated: bool = True
|
||||
|
||||
|
||||
def authenticate_user(username: str, password: str) -> Optional[User]:
|
||||
def validate_username(username: str) -> bool:
|
||||
"""
|
||||
Valide le format du nom d'utilisateur pour prévenir les injections
|
||||
Accepte uniquement: lettres, chiffres, tirets, underscores
|
||||
"""
|
||||
if not username or len(username) < 2 or len(username) > 32:
|
||||
return False
|
||||
pattern = re.compile(r'^[a-zA-Z0-9_-]+$')
|
||||
return bool(pattern.match(username))
|
||||
|
||||
|
||||
def check_rate_limit(username: str, ip_address: str) -> bool:
|
||||
"""
|
||||
Vérifie si l'utilisateur/IP a dépassé le nombre de tentatives autorisées
|
||||
Protection contre les attaques brute force
|
||||
"""
|
||||
key = f"{username}:{ip_address}"
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
# Nettoyer les anciennes tentatives
|
||||
if key in login_attempts:
|
||||
login_attempts[key] = [
|
||||
attempt_time for attempt_time in login_attempts[key]
|
||||
if (current_time - attempt_time).seconds < settings.LOGIN_ATTEMPT_WINDOW
|
||||
]
|
||||
|
||||
# Vérifier le nombre de tentatives
|
||||
if key in login_attempts and len(login_attempts[key]) >= settings.MAX_LOGIN_ATTEMPTS:
|
||||
logger.warning(
|
||||
f"Rate limit exceeded for {username} from {ip_address}. "
|
||||
f"{len(login_attempts[key])} attempts in last {settings.LOGIN_ATTEMPT_WINDOW}s"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def record_failed_attempt(username: str, ip_address: str):
|
||||
"""Enregistre une tentative de connexion échouée"""
|
||||
key = f"{username}:{ip_address}"
|
||||
if key not in login_attempts:
|
||||
login_attempts[key] = []
|
||||
login_attempts[key].append(datetime.utcnow())
|
||||
|
||||
logger.warning(f"Failed login attempt for {username} from {ip_address}")
|
||||
|
||||
|
||||
def clear_login_attempts(username: str, ip_address: str):
|
||||
"""Efface les tentatives après une connexion réussie"""
|
||||
key = f"{username}:{ip_address}"
|
||||
if key in login_attempts:
|
||||
del login_attempts[key]
|
||||
|
||||
|
||||
def authenticate_user(username: str, password: str, ip_address: str) -> Optional[User]:
|
||||
"""
|
||||
Authentifie un utilisateur via PAM (Pluggable Authentication Module)
|
||||
Validé contre le système Debian/Linux
|
||||
Inclut protection brute force
|
||||
"""
|
||||
# Validation du format du username (protection injection)
|
||||
if not validate_username(username):
|
||||
logger.warning(f"Invalid username format attempted: {username} from {ip_address}")
|
||||
return None
|
||||
|
||||
# Vérification du rate limiting
|
||||
if not check_rate_limit(username, ip_address):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Trop de tentatives de connexion. Réessayez dans {settings.LOGIN_ATTEMPT_WINDOW // 60} minutes."
|
||||
)
|
||||
|
||||
try:
|
||||
pam_auth = pam.pam()
|
||||
if pam_auth.authenticate(username, password):
|
||||
clear_login_attempts(username, ip_address)
|
||||
logger.info(f"Successful authentication for {username} from {ip_address}")
|
||||
return User(username=username)
|
||||
return None
|
||||
else:
|
||||
record_failed_attempt(username, ip_address)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Erreur PAM: {e}")
|
||||
logger.error(f"PAM authentication error for {username} from {ip_address}: {e}")
|
||||
record_failed_attempt(username, ip_address)
|
||||
return None
|
||||
|
||||
|
||||
def create_access_token(username: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Crée un token JWT"""
|
||||
"""Crée un token JWT sécurisé"""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
@@ -48,7 +132,13 @@ def create_access_token(username: str, expires_delta: Optional[timedelta] = None
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
to_encode = {"sub": username, "exp": expire}
|
||||
to_encode = {
|
||||
"sub": username,
|
||||
"exp": expire,
|
||||
"iat": datetime.utcnow(), # Issued at
|
||||
"type": "access"
|
||||
}
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
@@ -58,9 +148,12 @@ def create_access_token(username: str, expires_delta: Optional[timedelta] = None
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> User:
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
) -> User:
|
||||
"""
|
||||
Valide le token JWT et retourne l'utilisateur actuel
|
||||
Inclut validation supplémentaire et logging
|
||||
"""
|
||||
credential_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -70,14 +163,48 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
|
||||
|
||||
try:
|
||||
token = credentials.credentials
|
||||
|
||||
# Décodage et validation du token
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
exp: int = payload.get("exp")
|
||||
token_type: str = payload.get("type")
|
||||
|
||||
# Validations supplémentaires
|
||||
if username is None or token_type != "access":
|
||||
logger.warning(f"Invalid token structure")
|
||||
raise credential_exception
|
||||
token_data = TokenData(username=username, exp=payload.get("exp"))
|
||||
except JWTError:
|
||||
|
||||
# Vérification de l'expiration
|
||||
if exp and datetime.fromtimestamp(exp) < datetime.utcnow():
|
||||
logger.warning(f"Expired token for {username}")
|
||||
raise credential_exception
|
||||
|
||||
# Validation du format username
|
||||
if not validate_username(username):
|
||||
logger.warning(f"Invalid username in token: {username}")
|
||||
raise credential_exception
|
||||
|
||||
token_data = TokenData(username=username, exp=exp)
|
||||
|
||||
except JWTError as e:
|
||||
logger.warning(f"JWT decode error: {e}")
|
||||
raise credential_exception
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in token validation: {e}")
|
||||
raise credential_exception
|
||||
|
||||
return User(username=token_data.username)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash un mot de passe avec bcrypt"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Vérifie un mot de passe contre son hash"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
200
backend/app/core/validators.py
Normal file
200
backend/app/core/validators.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Validateurs de sécurité pour prévenir les injections et attaques
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import HTTPException, status
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("innotexboard.security")
|
||||
|
||||
|
||||
def validate_container_name(name: str) -> str:
|
||||
"""
|
||||
Valide un nom de conteneur Docker
|
||||
Protection contre injection de commandes
|
||||
"""
|
||||
if not name or len(name) > 255:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nom de conteneur invalide"
|
||||
)
|
||||
|
||||
# Docker accepte: lettres, chiffres, tirets, underscores, points
|
||||
pattern = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]*$')
|
||||
if not pattern.match(name):
|
||||
logger.warning(f"Invalid container name attempted: {name}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nom de conteneur contient des caractères invalides"
|
||||
)
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def validate_image_name(image: str) -> str:
|
||||
"""
|
||||
Valide un nom d'image Docker
|
||||
Format: [registry/]repository[:tag]
|
||||
"""
|
||||
if not image or len(image) > 512:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nom d'image invalide"
|
||||
)
|
||||
|
||||
# Pattern pour image Docker valide
|
||||
pattern = re.compile(
|
||||
r'^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*/)*'
|
||||
r'[a-z0-9]+(?:[._-][a-z0-9]+)*'
|
||||
r'(?::[a-zA-Z0-9][a-zA-Z0-9._-]*)?$'
|
||||
)
|
||||
|
||||
if not pattern.match(image):
|
||||
logger.warning(f"Invalid image name attempted: {image}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nom d'image contient des caractères invalides"
|
||||
)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def validate_file_path(file_path: str, allowed_base: str = "/home/innotex/Docker") -> Path:
|
||||
"""
|
||||
Valide un chemin de fichier et prévient le path traversal
|
||||
Vérifie que le chemin reste dans le répertoire autorisé
|
||||
"""
|
||||
try:
|
||||
# Convertir en Path absolu et résoudre les liens symboliques
|
||||
target_path = Path(file_path).resolve()
|
||||
base_path = Path(allowed_base).resolve()
|
||||
|
||||
# Vérifier que le chemin est dans le répertoire autorisé
|
||||
if not str(target_path).startswith(str(base_path)):
|
||||
logger.warning(f"Path traversal attempt detected: {file_path}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Accès au chemin non autorisé"
|
||||
)
|
||||
|
||||
return target_path
|
||||
|
||||
except (ValueError, RuntimeError) as e:
|
||||
logger.warning(f"Invalid path attempted: {file_path} - {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Chemin de fichier invalide"
|
||||
)
|
||||
|
||||
|
||||
def validate_compose_name(name: str) -> str:
|
||||
"""
|
||||
Valide un nom de fichier docker-compose
|
||||
Accepte uniquement les noms de fichiers sûrs
|
||||
"""
|
||||
if not name or len(name) > 100:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nom de compose invalide"
|
||||
)
|
||||
|
||||
# Accepter uniquement alphanumériques, tirets, underscores
|
||||
pattern = re.compile(r'^[a-zA-Z0-9_-]+$')
|
||||
if not pattern.match(name):
|
||||
logger.warning(f"Invalid compose name attempted: {name}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nom de compose contient des caractères invalides"
|
||||
)
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def validate_package_name(package: str) -> str:
|
||||
"""
|
||||
Valide un nom de package Debian/APT
|
||||
Protection contre injection de commandes
|
||||
"""
|
||||
if not package or len(package) > 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nom de package invalide"
|
||||
)
|
||||
|
||||
# Pattern pour nom de package Debian valide
|
||||
pattern = re.compile(r'^[a-z0-9][a-z0-9+.-]*$')
|
||||
if not pattern.match(package):
|
||||
logger.warning(f"Invalid package name attempted: {package}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nom de package contient des caractères invalides"
|
||||
)
|
||||
|
||||
return package
|
||||
|
||||
|
||||
def sanitize_command_output(output: str, max_length: int = 10000) -> str:
|
||||
"""
|
||||
Nettoie la sortie de commande pour éviter l'injection de contenu malveillant
|
||||
Limite la taille pour prévenir DoS
|
||||
"""
|
||||
if not output:
|
||||
return ""
|
||||
|
||||
# Limiter la taille
|
||||
if len(output) > max_length:
|
||||
output = output[:max_length] + "\n[...truncated...]"
|
||||
|
||||
# Retirer les caractères de contrôle dangereux (sauf newline et tab)
|
||||
sanitized = re.sub(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]', '', output)
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def validate_port(port: int) -> int:
|
||||
"""
|
||||
Valide un numéro de port
|
||||
"""
|
||||
if not isinstance(port, int) or port < 1 or port > 65535:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Port invalide: {port}. Doit être entre 1 et 65535"
|
||||
)
|
||||
|
||||
# Ports privilégiés (< 1024) nécessitent root
|
||||
if port < 1024:
|
||||
logger.warning(f"Privileged port requested: {port}")
|
||||
|
||||
return port
|
||||
|
||||
|
||||
def validate_environment_variable(key: str, value: str) -> tuple[str, str]:
|
||||
"""
|
||||
Valide une variable d'environnement
|
||||
Protection contre injection
|
||||
"""
|
||||
# Clé: lettres, chiffres, underscores uniquement
|
||||
key_pattern = re.compile(r'^[A-Z_][A-Z0-9_]*$')
|
||||
if not key_pattern.match(key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Nom de variable d'environnement invalide: {key}"
|
||||
)
|
||||
|
||||
# Valeur: pas de null bytes
|
||||
if '\x00' in value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Valeur de variable d'environnement contient des caractères invalides"
|
||||
)
|
||||
|
||||
# Limiter la taille
|
||||
if len(value) > 10000:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Valeur de variable d'environnement trop longue"
|
||||
)
|
||||
|
||||
return key, value
|
||||
127
backend/main.py
127
backend/main.py
@@ -3,49 +3,158 @@ InnotexBoard - Interface d'administration Debian
|
||||
Backend FastAPI
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
import uvicorn
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from app.core.config import settings
|
||||
from app.api.routes import api_router
|
||||
from app.api.websocket import router as ws_router
|
||||
|
||||
# Configuration du répertoire de logs
|
||||
log_dir = Path(os.getenv('LOG_DIR', '/var/log/innotexboard'))
|
||||
if not log_dir.exists():
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
except PermissionError:
|
||||
# Fallback sur un répertoire local si pas de permissions
|
||||
log_dir = Path('./logs')
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log_file = log_dir / 'security.log'
|
||||
|
||||
# Configuration du logging de sécurité
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(str(log_file)),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("innotexboard.security")
|
||||
|
||||
# Réduire le bruit des logs watchfiles en développement
|
||||
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
|
||||
logging.getLogger("watchfiles").setLevel(logging.WARNING)
|
||||
|
||||
# Rate limiting pour prévenir les attaques brute force et DDoS
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=["200/minute"])
|
||||
|
||||
# Initialiser l'application FastAPI
|
||||
app = FastAPI(
|
||||
title=settings.API_TITLE,
|
||||
description=settings.API_DESCRIPTION,
|
||||
version=settings.API_VERSION,
|
||||
docs_url="/docs" if settings.DEBUG else None, # Désactiver docs en production
|
||||
redoc_url="/redoc" if settings.DEBUG else None,
|
||||
openapi_url="/openapi.json" if settings.DEBUG else None,
|
||||
)
|
||||
|
||||
# Middleware de sécurité CORS
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Middleware de sécurité CORS - Configuration stricte
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.ALLOWED_ORIGINS,
|
||||
allow_origins=settings.ALLOWED_ORIGINS, # Liste blanche uniquement
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"], # Méthodes explicites
|
||||
allow_headers=["Authorization", "Content-Type", "X-Requested-With"],
|
||||
expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"],
|
||||
max_age=3600, # Cache preflight 1h
|
||||
)
|
||||
|
||||
# Middleware pour les hôtes de confiance
|
||||
app.add_middleware(
|
||||
TrustedHostMiddleware,
|
||||
allowed_hosts=["localhost", "127.0.0.1"],
|
||||
allowed_hosts=settings.ALLOWED_HOSTS,
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
"""Ajoute les headers de sécurité HTTP à toutes les réponses"""
|
||||
response = await call_next(request)
|
||||
|
||||
# Protection contre le clickjacking
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
|
||||
# Force HTTPS et HSTS (HTTP Strict Transport Security)
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
|
||||
# Prévention du MIME-sniffing
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
# Protection XSS intégrée au navigateur
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
|
||||
# Content Security Policy - Politique stricte
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: https:; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self';"
|
||||
)
|
||||
|
||||
# Contrôle des permissions
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"geolocation=(), microphone=(), camera=(), payment=()"
|
||||
)
|
||||
|
||||
# Désactiver la divulgation de la version du serveur
|
||||
response.headers["Server"] = "InnotexBoard"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_security_events(request: Request, call_next):
|
||||
"""Log les événements de sécurité suspects"""
|
||||
client_ip = get_remote_address(request)
|
||||
|
||||
# Logger les tentatives d'accès aux endpoints sensibles
|
||||
if "/api/v1/auth" in request.url.path:
|
||||
logger.info(f"Auth attempt from {client_ip} to {request.url.path}")
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Logger les erreurs d'authentification
|
||||
if response.status_code == 401:
|
||||
logger.warning(f"Failed authentication from {client_ip} to {request.url.path}")
|
||||
|
||||
# Logger les tentatives d'accès non autorisé
|
||||
if response.status_code == 403:
|
||||
logger.warning(f"Forbidden access attempt from {client_ip} to {request.url.path}")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# Inclure les routes API
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
app.include_router(ws_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
@limiter.limit("10/minute")
|
||||
async def root(request: Request):
|
||||
"""Endpoint racine"""
|
||||
return {
|
||||
"message": "Bienvenue sur InnotexBoard",
|
||||
"version": settings.API_VERSION,
|
||||
"docs": "/docs",
|
||||
"openapi": "/openapi.json"
|
||||
"docs": "/docs" if settings.DEBUG else "Documentation disabled in production",
|
||||
"openapi": "/openapi.json" if settings.DEBUG else "OpenAPI disabled in production"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ python-pam>=2.0.2
|
||||
psutil>=5.9.0
|
||||
docker>=7.0.0
|
||||
PyJWT>=2.8.0
|
||||
passlib>=1.7.4
|
||||
passlib[bcrypt]>=1.7.4
|
||||
cryptography>=40.0.0
|
||||
python-dotenv>=1.0.0
|
||||
slowapi>=0.1.9
|
||||
bcrypt>=4.0.0
|
||||
email-validator>=2.0.0
|
||||
|
||||
Reference in New Issue
Block a user