protection de l'application contre les attaques numériques

This commit is contained in:
innotex
2026-01-16 20:10:17 +01:00
parent 520166a1e9
commit de157e9d0e
11 changed files with 1350 additions and 44 deletions

View File

@@ -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"

View File

@@ -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)

View 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