v1.7.12: security hardening — CORS fix, security headers, fail-closed rate limiter, OpenAPI docs disabled by default, config auth privacy, webhook validation
All checks were successful
Release / build-and-push (push) Successful in 44s
CI / lint-and-test (push) Successful in 22s

This commit is contained in:
2026-04-27 13:59:05 +02:00
parent c086fa4260
commit 07a841615b
11 changed files with 349 additions and 15 deletions

View File

@@ -1,4 +1,5 @@
import asyncio
import ipaddress
import logging
import os
import time
@@ -7,7 +8,15 @@ from pathlib import Path
import structlog
from audit_trail import log_action
from config import AI_FEATURES_ENABLED, AUTH_ENABLED, CORS_ORIGINS, ENABLE_PERIODIC_FETCH, FETCH_INTERVAL_MINUTES
from config import (
AI_FEATURES_ENABLED,
AUTH_ENABLED,
CORS_ORIGINS,
DOCS_ENABLED,
ENABLE_PERIODIC_FETCH,
FETCH_INTERVAL_MINUTES,
METRICS_ALLOWED_IPS,
)
from database import setup_indexes
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
@@ -51,20 +60,28 @@ def configure_logging():
configure_logging()
logger = structlog.get_logger("aoc.fetcher")
app = FastAPI()
# Disable OpenAPI docs in production by default
app = FastAPI(
docs_url="/docs" if DOCS_ENABLED else None,
redoc_url="/redoc" if DOCS_ENABLED else None,
openapi_url="/openapi.json" if DOCS_ENABLED else None,
)
# CORS: warn if wildcard is used with auth enabled, but do not break deployments
# CORS: when auth is enabled, never allow credentials with wildcard origins
_effective_cors = CORS_ORIGINS
_cors_credentials = True
if AUTH_ENABLED and "*" in _effective_cors:
logger.warning(
"CORS wildcard (*) is insecure when AUTH_ENABLED=true. Set CORS_ORIGINS to your actual origin(s) in production."
"CORS wildcard (*) is insecure with AUTH_ENABLED=true and allow_credentials. "
"Disabling credentials. Set CORS_ORIGINS to your actual origin(s)."
)
_cors_credentials = False
app.add_middleware(CorrelationIdMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=_effective_cors,
allow_credentials=True,
allow_credentials=_cors_credentials,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -81,7 +98,7 @@ async def prometheus_middleware(request: Request, call_next):
@app.middleware("http")
async def cache_control_middleware(request: Request, call_next):
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
# Prevent caching of HTML and API responses by default
if request.url.path.startswith("/api/") or request.url.path in ("/", "/index.html"):
@@ -100,6 +117,13 @@ async def cache_control_middleware(request: Request, call_next):
"img-src 'self' data:; "
"font-src 'self' data:;"
)
# Additional security headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = (
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
)
return response
@@ -165,8 +189,39 @@ async def health_check():
raise HTTPException(status_code=503, detail="Database unavailable") from exc
def _client_ip(request: Request) -> str:
"""Best-effort client IP: X-Forwarded-For first hop, or direct client host."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else ""
def _is_metrics_allowed(ip: str) -> bool:
"""Check if IP is in the configured metrics allowlist."""
if not METRICS_ALLOWED_IPS:
return True
try:
client_addr = ipaddress.ip_address(ip)
except ValueError:
return False
for network in METRICS_ALLOWED_IPS.split(","):
network = network.strip()
if not network:
continue
try:
if client_addr in ipaddress.ip_network(network, strict=False):
return True
except ValueError:
continue
return False
@app.get("/metrics")
async def metrics():
async def metrics(request: Request):
client_ip = _client_ip(request)
if not _is_metrics_allowed(client_ip):
raise HTTPException(status_code=403, detail="Forbidden")
return Response(content=prometheus_metrics(), media_type="text/plain")