d3daa97264
`GITEA_WEBHOOK_SECRET` is now mandatory at startup. The framework
refuses to load_config() when the env var is empty unless the
operator opts into the dev-bypass with `RFC_APP_INSECURE_WEBHOOKS=1`.
This is the v0.18.0 startup-loud-failure shape — the pre-v0.18.0
"silently accept unsigned POSTs when the secret is empty" path is
the bug the proposal targets.
`webhooks.receive`:
* Defense in depth: refuses 500 if the secret is empty at request
time and the dev-bypass is not set (catches the case where
something mutates env after startup).
* Logs a loud warning every time a webhook lands under the
bypass — so a misconfigured production deployment shows up in
the logs even if the operator missed the warning at boot.
* Adds an INFO log at the unknown-repo branch (previously
silently 200-OK'd a hook on a fork or a stale Gitea binding).
`tmp_env` fixture binds a fake secret so the existing 277 tests
boot cleanly; the new test_webhooks_vertical.py exercises both
the production-secret path (valid signature, invalid signature,
missing signature) and the dev-bypass path (config loads with
empty secret when bypass set, refuses without it).
7 new tests; full suite: 284 passed.
124 lines
4.8 KiB
Python
124 lines
4.8 KiB
Python
"""Gitea webhook receiver per §4.1.
|
|
|
|
Both the webhook receiver and the reconciler are §4.1 cache writers.
|
|
On a meaningful event — meta-repo push or PR change — we re-read just
|
|
what changed from Gitea and update the cache. The signature is verified
|
|
against the configured shared secret so spurious POSTs cannot poison
|
|
the cache.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
|
|
from fastapi import APIRouter, Header, HTTPException, Request
|
|
|
|
from . import cache, db
|
|
from .config import Config
|
|
from .gitea import Gitea
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
EVENTS_OF_INTEREST = {
|
|
"push", # meta-repo or RFC-repo commits
|
|
"pull_request", # opened / closed / merged
|
|
"create", # branch or repo created
|
|
"delete", # branch deleted
|
|
"repository", # repo created or deleted
|
|
}
|
|
|
|
|
|
def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
|
router = APIRouter()
|
|
|
|
@router.post("/api/webhooks/gitea")
|
|
async def receive(
|
|
request: Request,
|
|
x_gitea_event: str = Header(default=""),
|
|
x_gitea_signature: str = Header(default=""),
|
|
):
|
|
body = await request.body()
|
|
# v0.18.0: defense in depth. config.py refuses to start
|
|
# when the secret is empty unless `RFC_APP_INSECURE_WEBHOOKS=1`
|
|
# is set; this branch catches the dev-bypass case (the only
|
|
# path where `config.webhook_secret` can be empty) and surfaces
|
|
# it loudly to the client. A POST that lands here with an
|
|
# empty secret on a production deployment indicates a
|
|
# mis-configuration (somebody flipped the bypass in prod),
|
|
# and the loud 500 is the proposal's whole point.
|
|
insecure = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
|
|
if not config.webhook_secret:
|
|
if not insecure:
|
|
log.error(
|
|
"webhook receiver misconfigured: GITEA_WEBHOOK_SECRET is empty "
|
|
"and RFC_APP_INSECURE_WEBHOOKS=1 is not set"
|
|
)
|
|
raise HTTPException(status_code=500, detail="Webhook receiver misconfigured")
|
|
log.warning(
|
|
"webhook receiver running with RFC_APP_INSECURE_WEBHOOKS=1 — "
|
|
"signature verification is DISABLED. Production deployments MUST NOT set this."
|
|
)
|
|
else:
|
|
if not _verify_signature(body, x_gitea_signature, config.webhook_secret):
|
|
raise HTTPException(status_code=401, detail="Invalid signature")
|
|
|
|
event = x_gitea_event.lower()
|
|
if event not in EVENTS_OF_INTEREST:
|
|
return {"ok": True, "ignored": event}
|
|
|
|
# Identify the originating repo. For the meta repo we refresh
|
|
# the entry cache + meta-PR cache; for a per-RFC repo we refresh
|
|
# just that repo's branches/PRs/main body. The handler stays
|
|
# generous in what it accepts — refreshes are idempotent and
|
|
# small enough that overlapping events do not pile up.
|
|
try:
|
|
payload = json.loads(body) if body else {}
|
|
except Exception:
|
|
payload = {}
|
|
repo_full = (payload.get("repository") or {}).get("full_name") or ""
|
|
meta_full = f"{config.gitea_org}/{config.meta_repo}"
|
|
try:
|
|
if repo_full == meta_full or not repo_full:
|
|
await cache.refresh_meta_repo(config, gitea)
|
|
await cache.refresh_meta_branches(config, gitea)
|
|
await cache.refresh_meta_pulls(config, gitea)
|
|
else:
|
|
slug = _slug_for_repo(repo_full)
|
|
if slug:
|
|
await cache.refresh_rfc_repo(config, gitea, slug)
|
|
else:
|
|
# v0.18.0: the proposal's "unknown-repo logging"
|
|
# gesture — a hook on a fork or a stale repo binding
|
|
# used to silently 200-OK here, hiding the
|
|
# misconfiguration. Now the operator sees it in
|
|
# the log.
|
|
log.info(
|
|
"webhook received for unknown repo: repo_full=%s event=%s "
|
|
"(no cached_rfcs row matched; hook may be on a fork or stale)",
|
|
repo_full, event,
|
|
)
|
|
except Exception:
|
|
log.exception("webhook refresh failed")
|
|
raise HTTPException(status_code=500, detail="Refresh failed")
|
|
|
|
return {"ok": True}
|
|
|
|
return router
|
|
|
|
|
|
def _verify_signature(body: bytes, header: str, secret: str) -> bool:
|
|
if not header:
|
|
return False
|
|
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
return hmac.compare_digest(expected, header)
|
|
|
|
|
|
def _slug_for_repo(repo_full: str) -> str | None:
|
|
row = db.conn().execute(
|
|
"SELECT slug FROM cached_rfcs WHERE repo = ?", (repo_full,)
|
|
).fetchone()
|
|
return row["slug"] if row else None
|