e91ef739af
Il secchio "login" del rate limit valeva solo per /api/auth/login. Un
capoturno può ora essere attaccato a forza bruta anche via
/api/measurements/{id}/authorise e /api/production-runs/{id}/pause|
resume|close, senza mai toccare il bucket più permissivo "general".
_is_login_bucket_request confronta il path con un'espressione regolare,
non con ==, perché l'id in mezzo varia per richiesta.
Include anche la parte di #9 che riguarda questo stesso file: i path
sotto /api/setup/ entrano nello stesso secchio, indipendentemente dal
metodo (GET /api/setup/status accetta ora una password opzionale).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
211 lines
7.6 KiB
Python
211 lines
7.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
|
|
|
|
async def test_production_run_pause_shares_login_bucket(self, client: AsyncClient):
|
|
"""Repeated wrong-password POSTs to a supervisor-gated action are rate
|
|
limited under the login bucket, not the (much higher) general one."""
|
|
for i in range(5):
|
|
resp = await client.post(
|
|
f"/api/production-runs/{i + 1}/pause",
|
|
json={
|
|
"supervisor_username": "capoturno",
|
|
"supervisor_password": "wrong",
|
|
"note": None,
|
|
},
|
|
)
|
|
# Unauthenticated (no X-API-Key) or run not found either way -
|
|
# what matters here is that it is not yet rate limited.
|
|
assert resp.status_code != 429
|
|
|
|
resp = await client.post(
|
|
"/api/production-runs/999/pause",
|
|
json={
|
|
"supervisor_username": "capoturno",
|
|
"supervisor_password": "wrong",
|
|
"note": None,
|
|
},
|
|
)
|
|
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
|