security: fix CORS wildcard, add security headers, enforce role check, sanitize errors

- CORS: remove allow_origins=["*"]; restrict to ALLOWED_ORIGINS env var
  (comma-separated list); default is no cross-origin access. Removed
  allow_credentials=True and method/header wildcards.
- Security headers middleware: add X-Content-Type-Options, X-Frame-Options,
  X-XSS-Protection, Referrer-Policy, Strict-Transport-Security to all
  responses.
- users.py: guard POST /api/users so only users with role="admin" can
  create new accounts (prevents privilege escalation by non-admin roles).
- auth.py: remove raw exception detail from Azure AD 500 response to
  avoid leaking internal error messages / stack traces to clients.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 00:39:43 +01:00
parent 1bbe4904a7
commit bc9aa6624f
3 changed files with 34 additions and 7 deletions

View File

@@ -43,15 +43,36 @@ app = FastAPI(
app.state.limiter = limiter app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS — allow same-origin; adjust if needed # CORS — restrict to explicitly configured origins only.
# Set ALLOWED_ORIGINS in .env as a comma-separated list of allowed origins,
# e.g. ALLOWED_ORIGINS=https://myapp.example.com
# If unset, no cross-origin requests are allowed (same-origin only).
_raw_origins = os.environ.get("ALLOWED_ORIGINS", "")
_allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=_allowed_origins,
allow_credentials=True, allow_credentials=False,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Authorization", "Content-Type"],
) )
# ---------------------------------------------------------------------------
# Security headers middleware
# ---------------------------------------------------------------------------
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
"""Attach standard security headers to every response."""
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routers # Routers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -386,9 +386,9 @@ async def azure_callback(
except HTTPException: except HTTPException:
raise raise
except Exception as exc: except Exception:
logger.exception("Azure AD authentication error") logger.exception("Azure AD authentication error")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Azure AD authentication error: {exc}", detail="Azure AD authentication failed. Please try again or contact support.",
) )

View File

@@ -33,6 +33,12 @@ async def create_user(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Create a new local user.""" """Create a new local user."""
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can create new users.",
)
existing = db.query(User).filter(User.username == payload.username).first() existing = db.query(User).filter(User.username == payload.username).first()
if existing: if existing:
raise HTTPException( raise HTTPException(