fix(#2164): address argus review — Option<String> secret + stale hook cleanup

Two issues flagged by argus in PR #2388 review:

1. Empty-key fallback: when load_or_generate() failed, webhook_secret was
   String::new(). An attacker knowing this could forge deliveries with a
   valid HMAC of the empty key. Fix: change to Option<String>; on None,
   skip hook registration entirely and return 503 from /webhook/* handlers
   (rather than 401 with a misleadingly-verifiable empty-key HMAC).

2. Stale hook cleanup: on upgrade from old code, old loopback hooks
   (http://127.0.0.1:.../webhook/knowledge, .../webhook/config-pr) were
   left alongside the new domain-URL hook. Fix: during ensure_webhook /
   ensure_config_pr_webhook, after listing hooks, delete any that end with
   our path suffix but point at a different base URL.

clippy + nix fmt clean.
This commit is contained in:
atlas 2026-07-12 02:21:33 +02:00
commit 7b1b1d9db8
5 changed files with 84 additions and 12 deletions

View file

@ -58,7 +58,9 @@ struct AppState {
coord: Arc<Coordinator>,
/// HMAC-SHA256 secret shared with Forgejo webhook registrations.
/// Verified on every incoming `/webhook/*` POST.
webhook_secret: String,
/// `None` when the secret could not be loaded at startup — all
/// `/webhook/*` requests are rejected with 503 in that case.
webhook_secret: Option<String>,
}
#[allow(
@ -68,7 +70,11 @@ struct AppState {
handler; splitting that exhaustive list across helpers would \
obscure the route map for no readability gain"
)]
pub async fn serve(port: u16, coord: Arc<Coordinator>, webhook_secret: String) -> Result<()> {
pub async fn serve(
port: u16,
coord: Arc<Coordinator>,
webhook_secret: Option<String>,
) -> Result<()> {
// API-only: the gateway static-serves the dashboard dist and proxies
// non-static requests here (see hive-gateway.nix). Unmatched paths 404.
let app = Router::new()

View file

@ -25,8 +25,13 @@ use super::AppState;
// ── HMAC helper ───────────────────────────────────────────────────────────────
/// Verify the `X-Hub-Signature-256` header on an incoming Forgejo webhook.
/// Returns `Err` (with a safe-to-log message) on mismatch or missing header.
/// Returns `Err` (with a safe-to-log message) on mismatch, missing header,
/// or when the HMAC secret is unavailable (load failure at startup).
fn verify_hmac(state: &AppState, headers: &HeaderMap, body: &Bytes) -> Result<(), String> {
let secret = state
.webhook_secret
.as_deref()
.ok_or_else(|| "webhook HMAC secret unavailable; endpoint disabled".to_owned())?;
let sig = headers
.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok())
@ -34,8 +39,7 @@ fn verify_hmac(state: &AppState, headers: &HeaderMap, body: &Bytes) -> Result<()
if sig.is_empty() {
return Err("missing X-Hub-Signature-256 header".to_owned());
}
crate::webhook_secret::verify_signature(&state.webhook_secret, body, sig)
.map_err(|e| e.to_string())
crate::webhook_secret::verify_signature(secret, body, sig).map_err(|e| e.to_string())
}
// ── knowledge webhook ──────────────────────────────────────────────────────────
@ -72,7 +76,12 @@ pub(super) async fn post_webhook_knowledge(
) -> Response {
if let Err(e) = verify_hmac(&state, &headers, &body) {
tracing::warn!("webhook/knowledge: HMAC verification failed: {e}");
return (StatusCode::UNAUTHORIZED, "signature mismatch").into_response();
let status = if e.contains("unavailable") {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::UNAUTHORIZED
};
return (status, e).into_response();
}
let payload = match serde_json::from_slice::<PushWebhookPayload>(&body) {
@ -174,7 +183,12 @@ pub(super) async fn post_webhook_config_pr(
) -> Response {
if let Err(e) = verify_hmac(&state, &headers, &body) {
tracing::warn!("webhook/config-pr: HMAC verification failed: {e}");
return (StatusCode::UNAUTHORIZED, "signature mismatch").into_response();
let status = if e.contains("unavailable") {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::UNAUTHORIZED
};
return (status, e).into_response();
}
let payload = match serde_json::from_slice::<PrWebhookPayload>(&body) {