P0 security batch: admin-only user mgmt, unified role model, login rate limiting, webhook secret, security headers, pagination caps

- Remove POST /api/auth/register (404); no sign-up UI; users are admin-managed
- Add admin-only POST/PATCH/DELETE /api/auth/users (forced canonical roles,
  self-lockout + reference guards)
- Unify role model in app/core/roles.py; reject unknown roles at creation and
  at login/JWT validation; startup normalizes unambiguous legacy aliases
- Login rate limiting ~5 fails/15 min per IP+email -> 429 (in-process, tunable)
- WhatsApp webhook requires X-Webhook-Secret; fail-closed when env unset;
  GET handshake uses constant-time verify token (403 on mismatch)
- GET /api/whatsapp/mock-log now requires auth
- Security headers middleware: X-Frame-Options DENY, nosniff, CSP on HTML,
  HSTS behind TLS
- Pagination: limit alias for page_size, hard cap enforced, both -> 422
This commit is contained in:
root
2026-09-08 10:36:16 +00:00
parent 901f95e0f6
commit 3ae1062d65
17 changed files with 1305 additions and 145 deletions
+75 -20
View File
@@ -1,18 +1,31 @@
"""WhatsApp webhook handler — Meta Graph API integration."""
"""WhatsApp webhook handler — Meta Graph API integration.
P0 hardening:
* ``POST /api/whatsapp/webhook`` requires the ``X-Webhook-Secret`` header to
match ``WHATSAPP_WEBHOOK_SECRET``. Fail-closed: when the env var is unset
every message is rejected (same posture as the SECRET_KEY guard).
* ``GET /api/whatsapp/webhook`` (Meta handshake) validates ``hub.verify_token``
with a constant-time compare and returns 403 on mismatch.
* ``GET /api/whatsapp/mock-log`` now requires authentication (was a public
debug endpoint that could 500 and leak stack traces without auth).
"""
from __future__ import annotations
import logging
import secrets
from datetime import datetime, timezone
from typing import Annotated
import httpx
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.whatsapp_log import WhatsAppLog
from app.schemas.whatsapp import (
MetaWebhookRequest,
@@ -62,24 +75,55 @@ async def send_whatsapp_reply(
return WhatsAppReplyResponse(success=False, message=str(exc))
# ── Webhook auth (fail-closed) ──────────────────────────────────────
async def require_webhook_secret(
x_webhook_secret: Annotated[str | None, Header(alias="X-Webhook-Secret")] = None,
) -> None:
"""Reject webhook messages unless X-Webhook-Secret matches the env secret.
Reads ``settings.WHATSAPP_WEBHOOK_SECRET`` at request time so the value
can be injected per-deployment. Empty/unset env ⇒ reject everything.
"""
expected = settings.WHATSAPP_WEBHOOK_SECRET
if not expected:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Webhook disabled: WHATSAPP_WEBHOOK_SECRET is not configured",
)
if not x_webhook_secret or not secrets.compare_digest(x_webhook_secret, expected):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid webhook secret",
)
# ── Webhook endpoint ────────────────────────────────────────────────
@router.post("/webhook")
async def whatsapp_webhook(
@router.get("/webhook")
async def whatsapp_webhook_verify(
mode: str | None = Query(None, alias="hub.mode"),
verify_token: str | None = Query(None, alias="hub.verify_token"),
challenge: str | None = Query(None, alias="hub.challenge"),
) -> WebhookVerificationResponse | dict:
"""Meta webhook handshake (GET): echo the challenge when the token matches."""
if mode != "subscribe" or not challenge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing hub.mode / hub.challenge",
)
expected = settings.WHATSAPP_VERIFY_TOKEN
if not expected or not secrets.compare_digest(verify_token or "", expected):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Verify token mismatch",
)
return WebhookVerificationResponse(challenge=challenge)
async def _process_entries(
body: MetaWebhookRequest,
db: Annotated[AsyncSession, Depends(get_db)],
hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
mode: str | None = Query(None),
hub_challenge: str | None = Query(None),
db: AsyncSession,
) -> dict:
"""Handle incoming WhatsApp webhook from Meta."""
# ── Verification GET request (Meta sends this on webhook setup) ──
if mode and hub_challenge:
if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
return {"error": "Verify token mismatch"}
return WebhookVerificationResponse(challenge=hub_challenge).model_dump()
# ── Process inbound messages ────────────────────────────────────
"""Create tickets + logs for inbound messages. Shared by the POST handler."""
if not body.entry:
return {"status": "no entry"}
@@ -153,13 +197,24 @@ async def whatsapp_webhook(
return {"status": "no messages"}
# ── Legacy debug endpoint ──────────────────────────────────────────
@router.post("/webhook")
async def whatsapp_webhook(
body: MetaWebhookRequest,
db: Annotated[AsyncSession, Depends(get_db)],
_auth: None = Depends(require_webhook_secret),
) -> dict:
"""Process an inbound WhatsApp message (Meta POST). Requires webhook secret."""
return await _process_entries(body, db)
# ── Debug endpoint (auth required) ──────────────────────────────────
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
async def mock_whatsapp_log(
db: Annotated[AsyncSession, Depends(get_db)],
limit: int = 50,
current_user: Annotated[User, Depends(get_current_user)],
limit: int = Query(50, ge=1, le=200),
) -> list[MockWhatsAppLogEntry]:
"""Return recent WhatsApp webhook submissions for debugging."""
"""Return recent WhatsApp webhook submissions (authenticated only)."""
result = await db.execute(
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
)