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:
@@ -438,7 +438,11 @@ def tmp_env(monkeypatch):
|
||||
"SECRET_KEY": "test-secret-key-for-cookies",
|
||||
"DATABASE_PATH": str(db_path),
|
||||
"OWNER_GITEA_LOGIN": "ben",
|
||||
"GITEA_WEBHOOK_SECRET": "",
|
||||
# v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory at startup
|
||||
# per the email + webhook hygiene proposal. Tests bind a fake
|
||||
# value so the framework boots; tests that want to exercise
|
||||
# the dev-bypass path monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1`.
|
||||
"GITEA_WEBHOOK_SECRET": "test-webhook-secret-for-signature-verification",
|
||||
"ENABLED_MODELS": "claude",
|
||||
}
|
||||
for k, v in env.items():
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""End-to-end integration tests for the Gitea webhook receiver
|
||||
(v0.18.0 Slice 3 — webhook tightening per the email + webhook
|
||||
hygiene proposal).
|
||||
|
||||
The release changes the receiver from "verifies the signature only
|
||||
when a secret is configured; silently accepts unsigned POSTs
|
||||
otherwise" to "requires the secret unless `RFC_APP_INSECURE_WEBHOOKS=1`
|
||||
is set as an explicit dev-bypass." The startup-time check lives in
|
||||
`config.load_config()`; the request-time check lives in
|
||||
`webhooks.receive`.
|
||||
|
||||
These tests prove:
|
||||
|
||||
* The framework refuses to start when `GITEA_WEBHOOK_SECRET` is
|
||||
empty and the dev-bypass is not set.
|
||||
* The dev-bypass (`RFC_APP_INSECURE_WEBHOOKS=1`) lets the
|
||||
framework boot with an empty secret AND lets webhook POSTs
|
||||
land without signature verification (a loud-warning log line
|
||||
surfaces, but the request is accepted).
|
||||
* Default path (secret bound): a POST with a valid signature
|
||||
lands; a POST with an invalid signature gets 401; a POST with
|
||||
no signature gets 401.
|
||||
* Unknown-repo POSTs surface in the log (the "stale Gitea hook"
|
||||
case the proposal targets).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup-time secret check (config.load_config)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_refuses_to_load_with_empty_secret_and_no_bypass(monkeypatch, tmp_path):
|
||||
"""The framework MUST refuse to start when `GITEA_WEBHOOK_SECRET`
|
||||
is empty unless `RFC_APP_INSECURE_WEBHOOKS=1` is set. This is
|
||||
the v0.18.0 startup-loud-failure shape — silent acceptance was
|
||||
the bug."""
|
||||
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "")
|
||||
monkeypatch.delenv("RFC_APP_INSECURE_WEBHOOKS", raising=False)
|
||||
|
||||
from app.config import load_config
|
||||
with pytest.raises(RuntimeError, match="GITEA_WEBHOOK_SECRET"):
|
||||
load_config()
|
||||
|
||||
|
||||
def test_config_loads_with_empty_secret_when_bypass_is_set(monkeypatch, tmp_path):
|
||||
"""The explicit `RFC_APP_INSECURE_WEBHOOKS=1` opt-in lets the
|
||||
framework boot with an empty webhook secret. This is the
|
||||
local-dev escape hatch."""
|
||||
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "")
|
||||
monkeypatch.setenv("RFC_APP_INSECURE_WEBHOOKS", "1")
|
||||
|
||||
from app.config import load_config
|
||||
cfg = load_config() # MUST NOT raise
|
||||
assert cfg.webhook_secret == ""
|
||||
|
||||
|
||||
def test_config_loads_with_secret_set(monkeypatch, tmp_path):
|
||||
"""Sanity: the happy path (secret bound, bypass not set) loads
|
||||
cleanly."""
|
||||
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "my-real-secret")
|
||||
monkeypatch.delenv("RFC_APP_INSECURE_WEBHOOKS", raising=False)
|
||||
|
||||
from app.config import load_config
|
||||
cfg = load_config()
|
||||
assert cfg.webhook_secret == "my-real-secret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request-time signature verification (webhooks.receive)
|
||||
#
|
||||
# The default `app_with_fake_gitea` fixture binds
|
||||
# `GITEA_WEBHOOK_SECRET=test-webhook-secret-for-signature-verification`,
|
||||
# so these tests exercise the production path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SECRET = "test-webhook-secret-for-signature-verification"
|
||||
|
||||
|
||||
def _sign(body: bytes) -> str:
|
||||
return hmac.new(_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def test_webhook_post_with_valid_signature_accepted(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
|
||||
sig = _sign(body)
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"X-Gitea-Signature": sig,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_webhook_post_with_invalid_signature_refused_401(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"X-Gitea-Signature": "0" * 64, # wrong signature
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_webhook_post_with_missing_signature_refused_401(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unknown-repo logging (the "stale hook on a fork" surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_webhook_unknown_repo_logs_at_info(app_with_fake_gitea, caplog):
|
||||
"""Per the proposal: a hook on a fork or a stale Gitea binding
|
||||
used to silently 200-OK. v0.18.0 surfaces it as an INFO log."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "someone-else/unrelated"}}).encode()
|
||||
sig = _sign(body)
|
||||
with caplog.at_level(logging.INFO, logger="app.webhooks"):
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"X-Gitea-Signature": sig,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 # the handler still 200s; surface is the log line
|
||||
assert any(
|
||||
"unknown repo" in rec.message and "someone-else/unrelated" in rec.message
|
||||
for rec in caplog.records
|
||||
), f"expected unknown-repo log line; got: {[r.message for r in caplog.records]}"
|
||||
Reference in New Issue
Block a user