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.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(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_origins=_allowed_origins,
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
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
# ---------------------------------------------------------------------------

View File

@@ -386,9 +386,9 @@ async def azure_callback(
except HTTPException:
raise
except Exception as exc:
except Exception:
logger.exception("Azure AD authentication error")
raise HTTPException(
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),
):
"""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()
if existing:
raise HTTPException(