v1.7.12: security hardening — CORS fix, security headers, fail-closed rate limiter, OpenAPI docs disabled by default, config auth privacy, webhook validation
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user