feat(security): merge Phase 4b security hardening

Complete security hardening implementation including HTTPS enforcement,
security headers, rate limiting, and comprehensive security test suite.

Key features:
- HTTPS enforcement with HSTS support
- Security headers (CSP, X-Frame-Options, X-Content-Type-Options)
- Rate limiting for all critical endpoints
- Enhanced email template security
- 87% test coverage with security-specific tests

Architect approval: 9.5/10

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 18:28:50 -07:00
parent 115e733604
commit d3c3e8dc6b
23 changed files with 3762 additions and 7 deletions

View File

@@ -45,6 +45,11 @@ class Config:
TOKEN_CLEANUP_ENABLED: bool
TOKEN_CLEANUP_INTERVAL: int
# Security Configuration (Phase 4b)
HTTPS_REDIRECT: bool
TRUST_PROXY: bool
SECURE_COOKIES: bool
# Logging
LOG_LEVEL: str
DEBUG: bool
@@ -101,6 +106,11 @@ class Config:
cls.TOKEN_CLEANUP_ENABLED = os.getenv("GONDULF_TOKEN_CLEANUP_ENABLED", "false").lower() == "true"
cls.TOKEN_CLEANUP_INTERVAL = int(os.getenv("GONDULF_TOKEN_CLEANUP_INTERVAL", "3600"))
# Security Configuration (Phase 4b)
cls.HTTPS_REDIRECT = os.getenv("GONDULF_HTTPS_REDIRECT", "true").lower() == "true"
cls.TRUST_PROXY = os.getenv("GONDULF_TRUST_PROXY", "false").lower() == "true"
cls.SECURE_COOKIES = os.getenv("GONDULF_SECURE_COOKIES", "true").lower() == "true"
# Logging
cls.DEBUG = os.getenv("GONDULF_DEBUG", "false").lower() == "true"
# If DEBUG is true, default LOG_LEVEL to DEBUG, otherwise INFO
@@ -162,6 +172,10 @@ class Config:
"GONDULF_TOKEN_CLEANUP_INTERVAL must be at least 600 seconds (10 minutes)"
)
# Disable HTTPS redirect in development mode
if cls.DEBUG:
cls.HTTPS_REDIRECT = False
# Configuration is loaded lazily or explicitly by the application
# Tests should call Config.load() explicitly in fixtures

View File

@@ -88,9 +88,9 @@ Gondulf IndieAuth Server
try:
self._send_email(to_email, subject, body)
logger.info(f"Verification code sent to {to_email} for domain={domain}")
logger.info(f"Verification code sent for domain={domain}")
except Exception as e:
logger.error(f"Failed to send verification email to {to_email}: {e}")
logger.error(f"Failed to send verification email for domain={domain}: {e}")
raise EmailError(f"Failed to send verification email: {e}") from e
def _send_email(self, to_email: str, subject: str, body: str) -> None:
@@ -139,7 +139,7 @@ Gondulf IndieAuth Server
server.send_message(msg)
server.quit()
logger.debug(f"Email sent successfully to {to_email}")
logger.debug("Email sent successfully")
except smtplib.SMTPAuthenticationError as e:
raise EmailError(f"SMTP authentication failed: {e}") from e

View File

@@ -14,6 +14,8 @@ from gondulf.database.connection import Database
from gondulf.dns import DNSService
from gondulf.email import EmailService
from gondulf.logging_config import configure_logging
from gondulf.middleware.https_enforcement import HTTPSEnforcementMiddleware
from gondulf.middleware.security_headers import SecurityHeadersMiddleware
from gondulf.routers import authorization, metadata, token, verification
from gondulf.storage import CodeStore
@@ -32,6 +34,17 @@ app = FastAPI(
version="0.1.0-dev",
)
# Add middleware (order matters: HTTPS enforcement first, then security headers)
# HTTPS enforcement middleware
app.add_middleware(
HTTPSEnforcementMiddleware, debug=Config.DEBUG, redirect=Config.HTTPS_REDIRECT
)
logger.info(f"HTTPS enforcement middleware registered (debug={Config.DEBUG})")
# Security headers middleware
app.add_middleware(SecurityHeadersMiddleware, debug=Config.DEBUG)
logger.info(f"Security headers middleware registered (debug={Config.DEBUG})")
# Register routers
app.include_router(authorization.router)
app.include_router(metadata.router)

View File

@@ -0,0 +1 @@
"""Gondulf middleware modules."""

View File

@@ -0,0 +1,119 @@
"""HTTPS enforcement middleware for Gondulf IndieAuth server."""
import logging
from typing import Callable
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import RedirectResponse
from gondulf.config import Config
logger = logging.getLogger("gondulf.middleware.https_enforcement")
def is_https_request(request: Request) -> bool:
"""
Check if request is HTTPS, considering reverse proxy headers.
Args:
request: Incoming HTTP request
Returns:
True if HTTPS, False otherwise
"""
# Direct HTTPS
if request.url.scheme == "https":
return True
# Behind proxy - check forwarded header
# Only trust this header in production with TRUST_PROXY=true
if Config.TRUST_PROXY:
forwarded_proto = request.headers.get("X-Forwarded-Proto", "").lower()
return forwarded_proto == "https"
return False
class HTTPSEnforcementMiddleware(BaseHTTPMiddleware):
"""
Enforce HTTPS in production mode.
In production (DEBUG=False), reject or redirect HTTP requests to HTTPS.
In development (DEBUG=True), allow HTTP for localhost only.
Supports reverse proxy deployments via X-Forwarded-Proto header when
Config.TRUST_PROXY is enabled.
References:
- OAuth 2.0 Security Best Practices: HTTPS required
- W3C IndieAuth: TLS required for production
- Clarifications: See /docs/designs/phase-4b-clarifications.md section 2
"""
def __init__(self, app, debug: bool = False, redirect: bool = True):
"""
Initialize HTTPS enforcement middleware.
Args:
app: FastAPI application
debug: If True, allow HTTP for localhost (development mode)
redirect: If True, redirect HTTP to HTTPS. If False, return 400.
"""
super().__init__(app)
self.debug = debug
self.redirect = redirect
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""
Process request and enforce HTTPS if in production mode.
Args:
request: Incoming HTTP request
call_next: Next middleware/handler in chain
Returns:
Response (redirect to HTTPS, error, or normal response)
"""
hostname = request.url.hostname or ""
# Debug mode: Allow HTTP for localhost only
if self.debug:
if not is_https_request(request) and hostname not in [
"localhost",
"127.0.0.1",
"::1",
]:
logger.warning(
f"HTTP request to non-localhost in debug mode: {hostname}"
)
# Allow but log warning (for development on local networks)
# Continue processing
return await call_next(request)
# Production mode: Enforce HTTPS
if not is_https_request(request):
logger.warning(
f"HTTP request blocked in production mode: "
f"{request.method} {request.url}"
)
if self.redirect:
# Redirect HTTP → HTTPS
https_url = request.url.replace(scheme="https")
logger.info(f"Redirecting to HTTPS: {https_url}")
return RedirectResponse(url=str(https_url), status_code=301)
else:
# Return 400 Bad Request (strict mode)
return JSONResponse(
status_code=400,
content={
"error": "invalid_request",
"error_description": "HTTPS is required",
},
)
# HTTPS or allowed HTTP: Continue processing
return await call_next(request)

View File

@@ -0,0 +1,75 @@
"""Security headers middleware for Gondulf IndieAuth server."""
import logging
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger("gondulf.middleware.security_headers")
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""
Add security-related HTTP headers to all responses.
Headers protect against clickjacking, XSS, MIME sniffing, and other
client-side attacks. HSTS is only added in production mode (non-DEBUG).
References:
- OWASP Secure Headers Project
- Mozilla Web Security Guidelines
"""
def __init__(self, app, debug: bool = False):
"""
Initialize security headers middleware.
Args:
app: FastAPI application
debug: If True, skip HSTS header (development mode)
"""
super().__init__(app)
self.debug = debug
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""
Process request and add security headers to response.
Args:
request: Incoming HTTP request
call_next: Next middleware/handler in chain
Returns:
Response with security headers added
"""
# Process request
response = await call_next(request)
# Add security headers
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# CSP: Allow self, inline styles (for templates), and HTTPS images (for h-app logos)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' https:; "
"frame-ancestors 'none'"
)
# Permissions Policy: Disable unnecessary browser features
response.headers["Permissions-Policy"] = (
"geolocation=(), microphone=(), camera=()"
)
# HSTS: Only in production (not development)
if not self.debug:
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
logger.debug("Added HSTS header (production mode)")
return response

View File

@@ -90,7 +90,7 @@ class DomainVerificationService:
# Validate email format
if not validate_email(email):
logger.warning(f"Invalid email format discovered: {email}")
logger.warning(f"Invalid email format discovered for domain={domain}")
return {"success": False, "error": "invalid_email_format"}
# Step 3: Generate and send verification code