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:
+75
-20
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user