diff --git a/.env.example b/.env.example index 6a44801..ea7653a 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ NEO4J_DATABASE=neo4j GITHUB_APP_ID=your_app_id_here GITHUB_PRIVATE_KEY_PATH=/path/to/your-app-private-key.pem GITHUB_WEBHOOK_SECRET=your_webhook_secret_here +GITHUB_MARKETPLACE_WEBHOOK_SECRET=your_marketplace_webhook_secret_here # ============================================================================ # Firebase (service account — JSON string or file path) diff --git a/src/api/middleware/webhook_auth.py b/src/api/middleware/webhook_auth.py new file mode 100644 index 0000000..387db5a --- /dev/null +++ b/src/api/middleware/webhook_auth.py @@ -0,0 +1,49 @@ +import hashlib +import hmac +import logging +import os + +from fastapi import HTTPException, Request + +logger = logging.getLogger(__name__) + + +def _verify_signature(raw_body: bytes, secret: str, signature_header: str) -> None: + if not signature_header or not signature_header.startswith("sha256="): + raise HTTPException(status_code=403, detail="Missing or malformed X-Hub-Signature-256 header") + local_hash = hmac.new( + key=secret.encode("utf-8"), + msg=raw_body, + digestmod=hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(local_hash, signature_header.removeprefix("sha256=")): + raise HTTPException(status_code=403, detail="Invalid webhook signature") + + +async def verify_github_webhook_signature(request: Request) -> None: + secret = os.environ.get("GITHUB_WEBHOOK_SECRET") + if not secret: + logger.error("GITHUB_WEBHOOK_SECRET is not set") + raise HTTPException(status_code=500, detail="Webhook secret not configured") + _verify_signature( + await request.body(), + secret, + request.headers.get("X-Hub-Signature-256", ""), + ) + + +async def verify_marketplace_webhook_signature(request: Request) -> None: + secret = os.environ.get("GITHUB_MARKETPLACE_WEBHOOK_SECRET") + if not secret: + logger.warning( + "GITHUB_MARKETPLACE_WEBHOOK_SECRET is not set — falling back to GITHUB_WEBHOOK_SECRET" + ) + secret = os.environ.get("GITHUB_WEBHOOK_SECRET") + if not secret: + logger.error("Neither GITHUB_MARKETPLACE_WEBHOOK_SECRET nor GITHUB_WEBHOOK_SECRET is set") + raise HTTPException(status_code=500, detail="Webhook secret not configured") + _verify_signature( + await request.body(), + secret, + request.headers.get("X-Hub-Signature-256", ""), + ) \ No newline at end of file diff --git a/src/api/routers/webhook.py b/src/api/routers/webhook.py index 5265bc8..97c641f 100644 --- a/src/api/routers/webhook.py +++ b/src/api/routers/webhook.py @@ -2,8 +2,12 @@ import logging import uuid -from fastapi import APIRouter, BackgroundTasks, Request +from fastapi import APIRouter, BackgroundTasks, Depends, Request +from api.middleware.webhook_auth import ( + verify_github_webhook_signature, + verify_marketplace_webhook_signature, +) from api.services.cloud_tasks_service import CloudTasksService from api.services.code_review_commands import extract_review_command, is_bot_mentioned from common.firebase_service import firebase_service @@ -22,7 +26,7 @@ cloud_tasks = CloudTasksService() -@router.post("/onComment") +@router.post("/onComment", dependencies=[Depends(verify_github_webhook_signature)]) async def on_comment(request: Request, background_tasks: BackgroundTasks): """ Single GitHub webhook endpoint. Routes all events by X-GitHub-Event header: @@ -289,7 +293,7 @@ async def _handle_comment_review(payload: dict, background_tasks: BackgroundTask } -@router.post("/marketplace") +@router.post("/marketplace", dependencies=[Depends(verify_marketplace_webhook_signature)]) async def on_marketplace(request: Request): """ GitHub Marketplace webhook.