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,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"}

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

View File

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

View File

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