a330f2b340
_client_ip usava il primo elemento di XFF, impostabile dal client. Ora usa l'elemento a -trusted_proxy_count (nuova impostazione, default 1, env TRUSTED_PROXY_COUNT), quello aggiunto dal proxy fidato più esterno. Con trusted_proxy_count=0 XFF e X-Real-IP sono ignorati. Aggiunta anche web_concurrency (env WEB_CONCURRENCY, default 1): i bucket del rate limit sono per processo, con --workers N il limite configurato viene diviso per N (arrotondato per eccesso, minimo 1) in _effective_limit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
6.6 KiB
Python
185 lines
6.6 KiB
Python
"""Security integration tests for FastAPI server.
|
|
|
|
Covers CORS, rate limiting, security headers, API key auth,
|
|
path traversal protection, filename sanitization, and OPTIONS preflight.
|
|
"""
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from src.backend.tests.conftest import _create_user, auth_headers
|
|
|
|
|
|
class TestCORSHeaders:
|
|
"""CORS header tests."""
|
|
|
|
async def test_cors_headers_present(self, client: AsyncClient):
|
|
"""Response includes CORS headers for allowed origin."""
|
|
resp = await client.get(
|
|
"/api/health",
|
|
headers={"Origin": "http://localhost:5000"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "access-control-allow-origin" in resp.headers
|
|
|
|
|
|
class TestSecurityHeaders:
|
|
"""Security header tests."""
|
|
|
|
async def test_security_headers_present(self, client: AsyncClient):
|
|
"""Response includes all standard security headers."""
|
|
resp = await client.get("/api/health")
|
|
assert resp.status_code == 200
|
|
assert resp.headers.get("x-content-type-options") == "nosniff"
|
|
assert resp.headers.get("x-frame-options") == "DENY"
|
|
assert resp.headers.get("x-xss-protection") == "1; mode=block"
|
|
assert "strict-origin" in resp.headers.get("referrer-policy", "")
|
|
assert "default-src" in resp.headers.get("content-security-policy", "")
|
|
|
|
|
|
class TestAPIKeyAuth:
|
|
"""API key authentication tests."""
|
|
|
|
async def test_protected_endpoint_requires_api_key(self, client: AsyncClient):
|
|
"""Protected endpoint returns 401 without API key."""
|
|
resp = await client.get("/api/users")
|
|
assert resp.status_code == 401
|
|
detail = resp.json().get("detail", "")
|
|
assert "API key" in detail or "Missing" in detail
|
|
|
|
async def test_invalid_api_key_returns_401(self, client: AsyncClient):
|
|
"""Invalid API key returns 401."""
|
|
resp = await client.get(
|
|
"/api/users",
|
|
headers={"X-API-Key": "totally-invalid-key-12345"},
|
|
)
|
|
assert resp.status_code == 401
|
|
detail = resp.json().get("detail", "")
|
|
assert "Invalid" in detail or "inactive" in detail
|
|
|
|
|
|
class TestRateLimiting:
|
|
"""Rate limiting tests."""
|
|
|
|
async def test_login_rate_limit_returns_429(self, client: AsyncClient, db_session):
|
|
"""Exceeding login rate limit returns 429."""
|
|
# Default rate_limit_login = 5 per minute
|
|
# Send 6 requests to exceed the limit
|
|
for i in range(6):
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": f"user{i}", "password": "pass"},
|
|
)
|
|
|
|
# The 6th request (or beyond) should be rate limited
|
|
# Send one more to be sure
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "overflow", "password": "pass"},
|
|
)
|
|
assert resp.status_code == 429
|
|
assert "Too many" in resp.json().get("detail", "")
|
|
|
|
async def test_rate_limit_retry_after_header(self, client: AsyncClient):
|
|
"""429 response includes Retry-After header."""
|
|
# Exhaust login rate limit
|
|
for i in range(7):
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": f"retry{i}", "password": "pass"},
|
|
)
|
|
|
|
# Final request should be 429 with Retry-After
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "retrycheck", "password": "pass"},
|
|
)
|
|
assert resp.status_code == 429
|
|
assert "retry-after" in resp.headers
|
|
retry_val = int(resp.headers["retry-after"])
|
|
assert retry_val >= 1
|
|
|
|
async def test_login_rate_limit_survives_rotated_x_forwarded_for(
|
|
self, client: AsyncClient
|
|
):
|
|
"""A client rotating the spoofable (first) hop of XFF cannot dodge the
|
|
login rate limit: with one trusted proxy (the default), the real
|
|
client IP is the last hop, which stays constant."""
|
|
for i in range(5):
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": f"rotated{i}", "password": "pass"},
|
|
headers={
|
|
"X-Forwarded-For": f"{i}.{i}.{i}.{i}, 198.51.100.42",
|
|
},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "rotated-overflow", "password": "pass"},
|
|
headers={"X-Forwarded-For": "9.9.9.9, 198.51.100.42"},
|
|
)
|
|
assert resp.status_code == 429
|
|
|
|
|
|
class TestPathTraversal:
|
|
"""Path traversal protection in file endpoints."""
|
|
|
|
async def test_path_traversal_blocked(self, client: AsyncClient, db_session):
|
|
"""Path traversal in file endpoint is blocked (403 or 404)."""
|
|
user = await _create_user(
|
|
db_session,
|
|
username="fileuser",
|
|
roles=["Maker", "MeasurementTec"],
|
|
)
|
|
|
|
resp = await client.get(
|
|
"/api/files/../../etc/passwd",
|
|
headers=auth_headers(user),
|
|
)
|
|
# Should be 403 (access denied) or 404 (not found), never 200
|
|
assert resp.status_code in (403, 404)
|
|
|
|
|
|
class TestFilenameSanitization:
|
|
"""Filename sanitization tests."""
|
|
|
|
async def test_dotfile_rejected(self, client: AsyncClient, db_session):
|
|
"""Filenames starting with '.' are rejected."""
|
|
from src.backend.api.routers.files import sanitize_filename
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
sanitize_filename(".htaccess")
|
|
assert exc_info.value.status_code == 400
|
|
assert "." in exc_info.value.detail
|
|
|
|
async def test_empty_filename_rejected(self, client: AsyncClient, db_session):
|
|
"""Empty filenames are rejected."""
|
|
from src.backend.api.routers.files import sanitize_filename
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
sanitize_filename("///")
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
class TestOptionsPreflight:
|
|
"""OPTIONS preflight request tests."""
|
|
|
|
async def test_options_preflight(self, client: AsyncClient):
|
|
"""OPTIONS request returns CORS preflight headers."""
|
|
resp = await client.options(
|
|
"/api/health",
|
|
headers={
|
|
"Origin": "http://localhost:5000",
|
|
"Access-Control-Request-Method": "GET",
|
|
"Access-Control-Request-Headers": "X-API-Key",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "access-control-allow-methods" in resp.headers
|