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:
+33
-1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user