ninja-code-guard / app /github /webhook.py
NinjainPJs's picture
initial - commit
4b445f6
"""
GitHub Webhook Signature Validation
====================================
When GitHub sends a webhook event to our server, it includes a cryptographic
signature in the `X-Hub-Signature-256` header. This signature proves the request
genuinely came from GitHub, not from an attacker.
The signature is computed as: HMAC-SHA256(webhook_secret, request_body)
We recompute the same HMAC on our side and compare. If they match, the request
is authentic. We use `hmac.compare_digest()` for constant-time comparison to
prevent timing attacks — where an attacker measures response time differences
to guess the signature byte by byte.
Reference: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
"""
import hashlib
import hmac
from fastapi import Header, HTTPException, Request
from app.config import settings
async def validate_webhook_signature(
request: Request,
x_hub_signature_256: str = Header(..., alias="X-Hub-Signature-256"),
) -> bytes:
"""
FastAPI dependency that validates the GitHub webhook HMAC-SHA256 signature.
How this works as a FastAPI dependency:
- FastAPI's dependency injection system calls this function before your endpoint runs
- It automatically extracts the X-Hub-Signature-256 header from the request
- If validation fails, it raises HTTPException and the endpoint never executes
- If it passes, it returns the raw request body for further processing
Args:
request: The incoming FastAPI request object (injected automatically)
x_hub_signature_256: The signature header from GitHub (extracted by FastAPI)
Returns:
The raw request body bytes (so the endpoint can parse it as JSON)
Raises:
HTTPException 401: If the signature is missing or invalid
"""
# Read the raw request body — we need the exact bytes GitHub used to compute the HMAC.
# Important: we read raw bytes, NOT parsed JSON, because even a single whitespace
# difference would produce a completely different HMAC hash.
body = await request.body()
# Reject if webhook secret is not configured — empty secret = no security
if not settings.github_webhook_secret:
raise HTTPException(status_code=500, detail="Webhook secret not configured")
if not x_hub_signature_256:
raise HTTPException(status_code=401, detail="Missing webhook signature header")
# GitHub sends the signature as "sha256=<hex_digest>"
# We need to strip the "sha256=" prefix to get just the hex digest
if not x_hub_signature_256.startswith("sha256="):
raise HTTPException(status_code=401, detail="Invalid signature format")
received_signature = x_hub_signature_256[7:] # Strip "sha256=" prefix
# Compute the expected HMAC using our stored webhook secret
# hmac.new() takes: key (bytes), message (bytes), hash algorithm
expected_signature = hmac.new(
key=settings.github_webhook_secret.encode("utf-8"),
msg=body,
digestmod=hashlib.sha256,
).hexdigest()
# Constant-time comparison — this is critical for security.
# A naive `==` comparison short-circuits on the first different byte,
# which leaks timing information. compare_digest() always takes the
# same amount of time regardless of where the mismatch is.
if not hmac.compare_digest(expected_signature, received_signature):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
return body