Security Audit Report
Date: 2026-04-29
Scope: Authentication, Rate Limiting, Input Validation
Status: Pre-v1.0 Release Audit
Executive Summary
This audit examines the security posture of the Hermes API with focus on three critical areas:
- Authentication — API key-based access control
- Rate Limiting — Request throttling per API key
- Input Validation — Protection against injection and malformed data
Overall Assessment: 🟢 SECURE with minor recommendations for improvement.
The codebase demonstrates strong security fundamentals with proper use of:
- Constant-time comparison for API keys
- Parameterized SQL queries (no SQL injection risk)
- Pydantic validation for all user inputs
- CORS restrictions and security headers
- Secure defaults (auth disabled for local dev)
1. Authentication
Implementation Overview
Location: src/api/auth.py
Authentication is optional and controlled by the API_KEY environment variable:
- If set: All write endpoints require
X-Api-Keyheader matching the configured key - If unset: Authentication is disabled (zero-config local development)
Protected endpoints:
POST /api/trigger— Manual speed test triggerPUT /api/config— Runtime configuration updatesPUT /api/alerts— Alert configuration updatesPOST /api/alerts/test— Test alert notification
Public endpoints (no auth required):
GET /api/health— Health checkGET /api/config— Read configurationGET /api/alerts— Read alert configurationGET /api/results— Query resultsGET /api/results/latest— Latest resultGET /api/trigger/status— Test running status
Security Strengths ✅
-
Constant-time comparison prevents timing attacks:
if not secrets.compare_digest(x_api_key, config.API_KEY): raise HTTPException(status_code=403, detail="Invalid API key.") -
Secure defaults — auth disabled for local dev avoids hardcoded keys
-
Clear separation —
Depends(require_api_key)makes protection explicit - Proper status codes:
401when key missing403when key invalid429when rate limited
- Logging — failed auth attempts are logged at WARNING level with redacted key prefix
Issues & Recommendations
🟡 MEDIUM: No API Key Complexity Requirements
Issue:
The system accepts any string as an API key with no minimum length, entropy, or complexity requirements.
Risk:
Users may set weak keys like "password" or "123", making brute-force attacks feasible.
Recommendation:
Add validation at startup to enforce minimum key length:
# In src/config.py
API_KEY: str | None = os.getenv("API_KEY") or None
if API_KEY is not None and len(API_KEY) < 32:
logging.error(
"API_KEY must be at least 32 characters. "
"Generate a secure key: python -c 'import secrets; print(secrets.token_urlsafe(32))'"
)
raise SystemExit(1)
Status: 🟡 Recommended for v1.0
🟢 LOW: No API Key Rotation Mechanism
Issue:
Keys cannot be rotated without restarting the application.
Risk:
If a key is compromised, an attacker retains access until restart.
Recommendation:
For v1.1+: Consider supporting multiple valid keys or a key rotation endpoint. Not critical for v1.0 single-user deployments.
Status: 🟢 Defer to post-v1.0
🟢 LOW: No Multi-User Support
Issue:
Single API key means no per-user access control or audit trails.
Risk:
Cannot distinguish between legitimate users or revoke access selectively.
Recommendation:
For v1.1+: Consider user database with hashed keys if multi-user access
is required. Not needed for current use case (single-user self-hosted).
Status: 🟢 Defer to post-v1.0
2. Rate Limiting
2.1 Implementation Overview
Location: src/api/auth.py:37-50
Algorithm: Sliding window (60-second) per API key
Default Limit: 60 requests/minute (configurable via
RATE_LIMIT_PER_MINUTE)
Storage: In-process dictionary (_request_timestamps)
When rate limit is exceeded:
- Returns
429 Too Many Requests - Logs warning with redacted key prefix
- Request is rejected (no retry-after header)
2.2 Security Strengths ✅
-
Per-key tracking prevents one user exhausting quota for others (if multiple keys were supported)
-
Sliding window is more accurate than fixed-window (prevents burst at window boundaries)
-
Configurable — can be disabled (
RATE_LIMIT_PER_MINUTE=0) or adjusted per deployment -
Thread-safe — uses
threading.Lock()for concurrent request handling -
Automatic cleanup — old timestamps are pruned on each request (no memory leak)
2.3 Issues & Recommendations
🟡 MEDIUM: In-Process State Not Suitable for Multi-Instance Deployment
Issue:
Rate limit state is stored in memory and not shared between processes or instances.
Risk:
- Multi-instance deployments behind a load balancer bypass rate limiting
- Each instance tracks limits independently (effective limit × instance count)
- Restart resets all counters
Recommendation:
For v1.1+: If horizontal scaling is needed, migrate to Redis or distributed rate limiter.
Not critical for v1.0 (single-instance Docker deployment).
Status: 🟢 Acceptable for v1.0 (single-instance model)
🟢 LOW: No Retry-After Header
Issue:
429 responses do not include a Retry-After header indicating when the client can retry.
Risk:
Clients may retry immediately, wasting resources.
Recommendation:
Add Retry-After header to 429 responses:
if not _check_rate_limit(x_api_key):
logger.warning("auth: rate limit exceeded for key prefix=%.4s", x_api_key)
raise HTTPException(
status_code=429,
detail="Rate limit exceeded.",
headers={"Retry-After": "60"}
)
Status: 🟢 Nice-to-have for v1.0
🟢 LOW: Rate Limiting Only Applies When Auth Enabled
Issue:
If API_KEY is unset, rate limiting is bypassed entirely (even for unauthenticated endpoints).
Risk:
Unauthenticated deployments (local dev) are vulnerable to abuse (e.g., flooding /api/trigger).
Recommendation:
Consider IP-based rate limiting for unauthenticated mode. However, local dev use case makes this low priority.
Status: 🟢 Acceptable (by design for dev/trusted networks)
🔵 INFO: No Distributed Denial-of-Service (DDoS) Protection
Issue:
Application-layer rate limiting cannot prevent network-layer DDoS attacks.
Risk:
Malicious actors could overwhelm the server before requests reach FastAPI.
Recommendation:
Deploy behind reverse proxy (nginx/Caddy/Traefik) with connection
limits, or use cloud provider DDoS protection. This is infrastructure-level,
not application-level.
Status: 🔵 Out of scope (deployment concern)
3. Input Validation
3.1 Implementation Overview
Validation Strategy:
- Pydantic models for JSON request bodies (automatic type/constraint validation)
- FastAPI
Queryannotations for query parameters (type/range validation) - Parameterized SQL queries for database operations (SQL injection prevention)
3.2 Security Strengths ✅
3.2.1 Request Body Validation (Pydantic)
All PUT/POST endpoints use typed Pydantic models:
Example: src/api/routes/config.py:16-21
class RuntimeConfigSchema(BaseModel):
interval_minutes: int = Field(ge=5, le=1440) # 5 min to 24 hours
enabled_exporters: list[str]
scanning_enabled: bool
Protection:
- ✅ Type coercion (string
"30"→ int30) - ✅ Range constraints (
ge=5, le=1440) - ✅ Required field enforcement
- ✅ Rejects unknown fields by default
- ✅ Returns
422 Unprocessable Entitywith detailed error messages
Additional validation: Unknown exporter names are rejected with explicit check:
unknown = [e for e in body.enabled_exporters if e not in VALID_EXPORTERS]
if unknown:
raise HTTPException(status_code=422, detail=f"Unknown exporters: {unknown}")
3.2 Query Parameter Validation
Example: src/api/routes/results.py:59-61
def get_results(
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=500)] = 50,
) -> ResultsPage:
Protection:
- ✅ Type validation (
?page=abc→422error) - ✅ Range constraints (
page_size ≤ 500prevents excessive memory usage) - ✅ Minimum values (
page ≥ 1prevents negative indexing)
3.3 SQL Injection Prevention
All database queries use parameterized statements:
Example: src/exporters/sqlite_exporter.py:36-40
_INSERT = """
INSERT INTO results
(timestamp, download_mbps, upload_mbps, ping_ms, jitter_ms, isp_name,
server_name, server_location, server_id)
VALUES
(:timestamp, :download_mbps, :upload_mbps, :ping_ms, :jitter_ms, :isp_name,
:server_name, :server_location, :server_id)"""
# Usage:
conn.execute(_INSERT, row) # Parameters passed separately
Protection:
- ✅ No string interpolation
(
f"SELECT * FROM results WHERE id={user_input}"❌) - ✅ No manual escaping (SQLite driver handles it)
- ✅ Named parameters (
:timestamp) prevent positional injection -
✅ All user-controlled data in
results.pyuses?placeholders:conn.execute( "SELECT * FROM results ORDER BY timestamp DESC LIMIT ? OFFSET ?", (page_size, offset) )
No SQL injection risk found in any database operation.
3.4 Path Traversal Prevention
File system operations:
- Database path (
SQLITE_DB_PATH) — from environment, not user input - CSV log path (
CSV_LOG_PATH) — from environment, not user input - Runtime config path (
data/runtime_config.json) — hardcoded
User-controlled paths: None. The API does not accept file paths from users.
Protection:
- ✅ No endpoints accept file paths as input
- ✅ File operations use
Pathobjects with validation - ✅ SPA static file serving uses
StaticFiles(FastAPI built-in, secure)
3.5 JSON Deserialization
Mechanism: FastAPI + Pydantic handle all JSON parsing.
Protection:
- ✅ No custom deserialization (avoids
eval(),pickle, etc.) - ✅ Recursive depth limited by Pydantic (prevents stack exhaustion)
- ✅ Malformed JSON rejected before reaching application code
3.6 Alert Provider URL Validation
Issue Investigated: Users provide webhook URLs, Gotify URLs, ntfy URLs via /api/alerts.
Risk Assessment:
- URLs are stored and invoked by the backend (not client-side)
- Could be used for Server-Side Request Forgery (SSRF) if not validated
Current Implementation:
src/services/alert_providers.py
# WebhookProvider
response = requests.post(self.url, json=payload, timeout=10)
# GotifyProvider
endpoint = f"{self.url}/message"
response = requests.post(endpoint, json=payload, headers=headers, timeout=10)
# NtfyProvider
endpoint = f"{self.url}/{self.topic}"
response = requests.post(endpoint, headers=headers, data=message, timeout=10)
Protection:
- ✅ Timeouts prevent indefinite hangs (10 seconds)
- ⚠️ No URL scheme validation (could POST to
file://,ftp://, etc.) - ⚠️ No private IP range filtering (could target
internal services like
http://localhost:6379)
SSRF Risk: 🟡 MEDIUM
3.2 Validation Issues & Recommendations
🟡 MEDIUM: Server-Side Request Forgery (SSRF) via Alert URLs
Issue:
Alert provider URLs are not validated. Malicious users could configure alerts to target:
- Internal services (
http://localhost:6379— Redis) - Private networks (
http://192.168.1.1/admin) - Cloud metadata endpoints (
http://169.254.169.254/latest/meta-data/) - File system (
file:///etc/passwd)
Risk:
Attacker with API access can probe internal network
or exfiltrate data via alert payloads.
Recommendation:
Add URL validation in
src/api/routes/alerts.py:
from urllib.parse import urlparse
import ipaddress
def validate_alert_url(url: str, field_name: str) -> None:
"""Validate alert provider URL to prevent SSRF."""
if not url:
return # Empty URL is valid (provider disabled)
try:
parsed = urlparse(url)
# Only allow http/https schemes
if parsed.scheme not in ("http", "https"):
raise HTTPException(
status_code=422,
detail=f"{field_name}: Only http:// and https:// URLs are allowed."
)
# Block localhost and private IP ranges
if parsed.hostname:
# Check for localhost
if parsed.hostname.lower() in ("localhost", "127.0.0.1", "::1"):
raise HTTPException(
status_code=422,
detail=f"{field_name}: Localhost URLs are not allowed."
)
# Check for private IP ranges
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise HTTPException(
status_code=422,
detail=f"{field_name}: Private IP addresses are not allowed."
)
except ValueError:
# Not an IP address, likely a hostname (allow it)
pass
except ValueError:
raise HTTPException(
status_code=422,
detail=f"{field_name}: Invalid URL format."
)
@router.put("/alerts", dependencies=[Depends(require_api_key)])
def update_alerts(body: AlertConfigSchema) -> AlertConfigSchema:
# Validate all provider URLs
validate_alert_url(body.providers.webhook.url, "Webhook URL")
validate_alert_url(body.providers.gotify.url, "Gotify URL")
validate_alert_url(body.providers.ntfy.url, "ntfy URL")
validate_alert_url(body.providers.apprise.url, "Apprise URL")
# ... rest of implementation
Alternative: Use Pydantic’s HttpUrl type with custom validator:
from pydantic import HttpUrl, field_validator
class WebhookProviderConfig(BaseModel):
enabled: bool = False
url: HttpUrl = "" # Validates scheme automatically
@field_validator('url')
def block_private_ips(cls, v):
if v:
parsed = urlparse(str(v))
# ... validation logic
return v
Status: 🟡 Critical for v1.0 if untrusted users have API access
Mitigation: If deployment is single-user self-hosted (user targets their own services), SSRF risk is mitigated. However, validation should still be added as defense-in-depth.
🟢 LOW: No Content-Length Limit on Request Bodies
Issue:
FastAPI does not enforce a maximum request body size by default.
Risk:
Attacker could send extremely large payloads (multi-GB JSON) causing memory exhaustion.
Recommendation:
Add body size limit in main.py:
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
content_length = request.headers.get("content-length")
if content_length and int(content_length) > 1_048_576: # 1 MB
return JSONResponse(
status_code=413,
content={"detail": "Request body too large (max 1 MB)"}
)
return await call_next(request)
app.add_middleware(RequestSizeLimitMiddleware)
Status: 🟢 Nice-to-have for v1.0 (Pydantic models are small, unlikely to be exploited)
🟢 LOW: Alert Test Endpoint Lacks Throttling
Issue:
POST /api/alerts/test sends real HTTP requests to
external services. No rate limiting beyond global
API limit.
Risk:
Authenticated user could abuse endpoint to flood
external services (e.g., 60 requests/min to
victim’s webhook).
Recommendation:
Add separate rate limit for test endpoint:
from functools import wraps
import time
_test_alert_last_call = 0
_TEST_ALERT_COOLDOWN = 10 # seconds
def rate_limit_test_alerts():
global _test_alert_last_call
now = time.time()
if now - _test_alert_last_call < _TEST_ALERT_COOLDOWN:
raise HTTPException(
status_code=429,
detail=f"Test alerts can only be sent every {_TEST_ALERT_COOLDOWN} seconds."
)
_test_alert_last_call = now
@router.post("/alerts/test", dependencies=[Depends(require_api_key)])
def test_alerts() -> TestAlertResponse:
rate_limit_test_alerts()
# ... rest of implementation
Status: 🟢 Nice-to-have for v1.0
🟢 LOW: No Input Sanitization for Logging
Issue:
User-controlled strings (API keys, error messages) are logged without sanitization.
Risk:
Log injection (newline characters in input could corrupt log files or inject fake entries).
Example:
logger.warning("auth: rate limit exceeded for key prefix=%.4s", x_api_key)
If x_api_key contains \n, could split log entry.
Recommendation:
Python’s logging module already handles newlines safely (escapes
them). No action needed unless custom log parsing is added.
Status: 🟢 No issue (Python logging handles this)
4. Additional Security Observations
4.1 Security Headers ✅
Location: src/api/main.py:171-176
class _SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
return response
Headers Present:
- ✅
X-Content-Type-Options: nosniff— prevents MIME sniffing - ✅
Cross-Origin-Resource-Policy: same-origin— restricts resource embedding
Missing Headers (consider adding):
X-Frame-Options: DENY— prevents clickjackingContent-Security-Policy— prevents XSS (if serving HTML)Strict-Transport-Security— forces HTTPS (if deployed with TLS)
Recommendation:
Add additional headers for defense-in-depth:
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Only if deployed with HTTPS:
# response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
Status: 🟢 Nice-to-have for v1.0
4.2 CORS Configuration ✅
Location: src/api/main.py:181-186
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:4173"],
allow_methods=["*"],
allow_headers=["*"],
)
Analysis:
- ✅ Restricts origins to Vite dev server (not wide open)
- ⚠️
allow_methods=["*"]andallow_headers=["*"]are permissive but safe given origin restrictions
Recommendation:
For production, ensure CORS origins match actual deployment domain:
# In production environment
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173,http://localhost:4173").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_methods=["GET", "POST", "PUT"],
allow_headers=["Content-Type", "X-Api-Key"],
)
Status: 🟢 Good for dev; document production requirements
4.3 Dependency Security ✅
Supply Chain Scans:
- ✅ CI pipeline runs
safety,pip-audit,npm audit,trivy - ✅ Renovate auto-updates dependencies weekly
- ✅ Semgrep + Bandit scan for code-level issues
No action needed — already best-in-class.
4.4 Secret Management
Current Approach:
- Environment variables (
API_KEY,ALERT_GOTIFY_TOKEN, etc.) - Stored in
.envfile (excluded from git via.gitignore)
Recommendations:
- ✅ Document in README: “Never commit
.envto version control” - ✅ Add
.env.examplewith placeholders (already exists) - 🟢 For production: Consider Docker secrets or vault integration (post-v1.0)
Status: 🟢 Acceptable for v1.0
5. Test Coverage Analysis
Authentication Tests ✅
File: tests/test_api_auth.py
- ✅ Rate limit under limit (allows requests)
- ✅ Rate limit at limit (blocks requests)
- ✅ Rate limit disabled (
RATE_LIMIT_PER_MINUTE=0) - ✅ Rate limit not applied when auth disabled
- ✅ Missing key logged
- ✅ Wrong key logged
- ✅ Rate limit breach logged
Coverage: 100% of auth.py
Input Validation Tests ✅
File: tests/test_api_boundaries.py
- ✅ Protected endpoints return 401 when key missing
- ✅ Protected endpoints return 403 when key wrong
- ✅ Public endpoints accessible without auth
- ✅ Invalid interval range returns 422
- ✅ Invalid page/page_size returns 422
- ✅ Missing required fields return 422
- ✅ Unknown exporter names rejected
Coverage: Comprehensive boundary testing
Gaps
- ⚠️ No SSRF tests for alert provider URLs
- ⚠️ No test for oversized request bodies
- ⚠️ No test for rapid test alert spam
Recommendation: Add tests after implementing SSRF mitigations.
6. Threat Model Summary
| Threat | Current Mitigation | Risk Level | Action |
|---|---|---|---|
| Brute-force API key | Constant-time comparison | 🟡 Medium | Add min key length |
| Timing attacks | secrets.compare_digest() |
🟢 Low | ✅ Mitigated |
| SQL injection | Parameterized queries | 🟢 Low | ✅ Mitigated |
| XSS | FastAPI auto-escapes JSON | 🟢 Low | ✅ Mitigated |
| SSRF via alert URLs | None | 🟡 Medium | Add URL validation |
| Path traversal | No user-controlled paths | 🟢 Low | ✅ Mitigated |
| Rate limit bypass (multi-instance) | In-process state | 🟢 Low* | *Single-instance OK |
| DDoS (network layer) | None (application level) | 🔵 Info | Deploy behind proxy |
| Log injection | Python logging escapes | 🟢 Low | ✅ Mitigated |
| Dependency vulnerabilities | CI scans + Renovate | 🟢 Low | ✅ Mitigated |
7. Recommendations Summary
🔴 Critical (Block v1.0)
None — No blocking issues found.
🟡 High Priority (Strongly Recommended for v1.0)
- Add API key length validation (5 minutes)
- Enforce minimum 32 characters at startup
- Prevents weak keys
- Add SSRF protection for alert URLs (30 minutes)
- Validate URL schemes (http/https only)
- Block localhost and private IP ranges
- Prevents internal network probing
🟢 Low Priority (Nice-to-Have for v1.0)
- Add
Retry-Afterheader to 429 responses (5 minutes) - Add request body size limit (10 minutes)
- Rate limit test alert endpoint (10 minutes)
- Add missing security headers (5 minutes)
- Make CORS origins configurable (5 minutes)
🔵 Deferred (Post-v1.0)
- API key rotation mechanism
- Multi-user support with per-user keys
- Distributed rate limiting (Redis)
- Secrets vault integration
8. Conclusion
Overall Security Posture: 🟢 STRONG
The Hermes API demonstrates solid security fundamentals:
- ✅ No SQL injection vulnerabilities
- ✅ Proper authentication with constant-time comparison
- ✅ Comprehensive input validation via Pydantic
- ✅ Rate limiting with sliding window algorithm
- ✅ Secure defaults for development
- ✅ Excellent test coverage
Two medium-priority issues identified:
- SSRF risk in alert provider URLs — easily fixed with URL validation
- Weak API key risk — mitigated by enforcing minimum length
Recommendation: Implement the two high-priority fixes (~35 minutes total) before v1.0 release. All other issues are low-risk and can be deferred.
Audit Conclusion: ✅ APPROVED FOR v1.0 RELEASE after implementing SSRF protection and API key length validation.
Appendix A: Secure API Key Generation
Document for users:
# Generate a secure 32-character API key (recommended)
python -c 'import secrets; print(secrets.token_urlsafe(32))'
# Output example:
# xK7J9mN4vQ8pL2wR6tY3hF5sD1gA0cE9zB4xM7nV8qP2
# Set in .env file:
# API_KEY=xK7J9mN4vQ8pL2wR6tY3hF5sD1gA0cE9zB4xM7nV8qP2
Appendix B: Audited Files
src/api/auth.py— Authentication & rate limitingsrc/api/main.py— FastAPI app, middleware, CORSsrc/api/routes/config.py— Configuration endpointssrc/api/routes/alerts.py— Alert endpointssrc/api/routes/results.py— Results endpointssrc/api/routes/trigger.py— Manual trigger endpointsrc/config.py— Environment variable loadingsrc/exporters/sqlite_exporter.py— Database operationssrc/services/alert_providers.py— Alert HTTP requeststests/test_api_auth.py— Authentication teststests/test_api_boundaries.py— Input validation tests
Review Date: April 29, 2026
Next Review: Before v2.0 release or 6 months