v0.18.0 Slice 3: webhook tightening — mandatory secret + dev-bypass

`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.
This commit is contained in:
Ben Stull
2026-05-28 07:27:23 -07:00
parent e9fdc478f6
commit d3daa97264
4 changed files with 258 additions and 3 deletions
+33 -1
View File
@@ -12,6 +12,7 @@ import hashlib
import hmac
import json
import logging
import os
from fastapi import APIRouter, Header, HTTPException, Request
@@ -40,7 +41,27 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
x_gitea_signature: str = Header(default=""),
):
body = await request.body()
if config.webhook_secret:
# 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")
@@ -68,6 +89,17 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
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")