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:
parent
79a29873e3
commit
7b1b1d9db8
5 changed files with 84 additions and 12 deletions
|
|
@ -58,7 +58,9 @@ struct AppState {
|
||||||
coord: Arc<Coordinator>,
|
coord: Arc<Coordinator>,
|
||||||
/// HMAC-SHA256 secret shared with Forgejo webhook registrations.
|
/// HMAC-SHA256 secret shared with Forgejo webhook registrations.
|
||||||
/// Verified on every incoming `/webhook/*` POST.
|
/// 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(
|
#[allow(
|
||||||
|
|
@ -68,7 +70,11 @@ struct AppState {
|
||||||
handler; splitting that exhaustive list across helpers would \
|
handler; splitting that exhaustive list across helpers would \
|
||||||
obscure the route map for no readability gain"
|
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
|
// API-only: the gateway static-serves the dashboard dist and proxies
|
||||||
// non-static requests here (see hive-gateway.nix). Unmatched paths 404.
|
// non-static requests here (see hive-gateway.nix). Unmatched paths 404.
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,13 @@ use super::AppState;
|
||||||
// ── HMAC helper ───────────────────────────────────────────────────────────────
|
// ── HMAC helper ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Verify the `X-Hub-Signature-256` header on an incoming Forgejo webhook.
|
/// 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> {
|
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
|
let sig = headers
|
||||||
.get("x-hub-signature-256")
|
.get("x-hub-signature-256")
|
||||||
.and_then(|v| v.to_str().ok())
|
.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() {
|
if sig.is_empty() {
|
||||||
return Err("missing X-Hub-Signature-256 header".to_owned());
|
return Err("missing X-Hub-Signature-256 header".to_owned());
|
||||||
}
|
}
|
||||||
crate::webhook_secret::verify_signature(&state.webhook_secret, body, sig)
|
crate::webhook_secret::verify_signature(secret, body, sig).map_err(|e| e.to_string())
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── knowledge webhook ──────────────────────────────────────────────────────────
|
// ── knowledge webhook ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -72,7 +76,12 @@ pub(super) async fn post_webhook_knowledge(
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if let Err(e) = verify_hmac(&state, &headers, &body) {
|
if let Err(e) = verify_hmac(&state, &headers, &body) {
|
||||||
tracing::warn!("webhook/knowledge: HMAC verification failed: {e}");
|
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) {
|
let payload = match serde_json::from_slice::<PushWebhookPayload>(&body) {
|
||||||
|
|
@ -174,7 +183,12 @@ pub(super) async fn post_webhook_config_pr(
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if let Err(e) = verify_hmac(&state, &headers, &body) {
|
if let Err(e) = verify_hmac(&state, &headers, &body) {
|
||||||
tracing::warn!("webhook/config-pr: HMAC verification failed: {e}");
|
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) {
|
let payload = match serde_json::from_slice::<PrWebhookPayload>(&body) {
|
||||||
|
|
|
||||||
|
|
@ -358,6 +358,30 @@ pub async fn ensure_config_pr_webhook(
|
||||||
tracing::debug!(%target_url, "forge: config-pr webhook already configured");
|
tracing::debug!(%target_url, "forge: config-pr webhook already configured");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
// Delete stale hooks that point at our path but a different base
|
||||||
|
// (e.g. old loopback hooks from before the SSRF-bypass migration).
|
||||||
|
for h in &hooks {
|
||||||
|
let hook_url = h
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.get("url"))
|
||||||
|
.map_or("", String::as_str);
|
||||||
|
if hook_url.ends_with("/webhook/config-pr")
|
||||||
|
&& hook_url != target_url
|
||||||
|
&& let Some(id) = h.id
|
||||||
|
{
|
||||||
|
tracing::info!(
|
||||||
|
hook_url,
|
||||||
|
org = CONFIG_ORG,
|
||||||
|
"forge: deleting stale config-pr webhook (wrong base)"
|
||||||
|
);
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
HTTP_TIMEOUT,
|
||||||
|
client.org_delete_hook(CONFIG_ORG, id).send(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!(error = %e, "forge: listing config-pr hooks failed; attempting create");
|
tracing::debug!(error = %e, "forge: listing config-pr hooks failed; attempting create");
|
||||||
|
|
|
||||||
|
|
@ -306,11 +306,14 @@ async fn cmd_serve(
|
||||||
// Webhook HMAC secret: load from state dir or generate on first run.
|
// Webhook HMAC secret: load from state dir or generate on first run.
|
||||||
// Used by both the webhook handlers (verification) and the Forgejo
|
// Used by both the webhook handlers (verification) and the Forgejo
|
||||||
// hook registrations (so Forgejo signs deliveries with the same key).
|
// hook registrations (so Forgejo signs deliveries with the same key).
|
||||||
let webhook_secret = match hive_c0re::webhook_secret::load_or_generate() {
|
let webhook_secret: Option<String> = match hive_c0re::webhook_secret::load_or_generate() {
|
||||||
Ok(s) => s,
|
Ok(s) => Some(s),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = ?e, "webhook secret load/generate failed; webhooks will not verify HMAC");
|
tracing::error!(
|
||||||
String::new()
|
error = ?e,
|
||||||
|
"webhook secret load/generate failed; /webhook/* endpoints disabled and hooks not registered"
|
||||||
|
);
|
||||||
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Webhook setup: ensure Forgejo webhooks are registered for both
|
// Webhook setup: ensure Forgejo webhooks are registered for both
|
||||||
|
|
@ -319,9 +322,14 @@ async fn cmd_serve(
|
||||||
// forge::ensure_all so the core token + repos + org are present.
|
// forge::ensure_all so the core token + repos + org are present.
|
||||||
// URLs use the public hive domain (HYPERHIVE_HIVE_DOMAIN) so Forgejo
|
// URLs use the public hive domain (HYPERHIVE_HIVE_DOMAIN) so Forgejo
|
||||||
// delivers through the gateway, bypassing the SSRF loopback guard.
|
// delivers through the gateway, bypassing the SSRF loopback guard.
|
||||||
// No-op when the core token or domain are absent.
|
// No-op when the core token or domain are absent, or when the HMAC
|
||||||
|
// secret is unavailable (load failure).
|
||||||
let webhook_secret_reg = webhook_secret.clone();
|
let webhook_secret_reg = webhook_secret.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let Some(webhook_secret_reg) = webhook_secret_reg else {
|
||||||
|
tracing::debug!("webhook secret unavailable; skipping hook registration");
|
||||||
|
return;
|
||||||
|
};
|
||||||
let Some(token) = forge::core_token() else {
|
let Some(token) = forge::core_token() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,26 @@ pub async fn ensure_webhook(
|
||||||
tracing::debug!(%target_url, "knowledge: push webhook already configured");
|
tracing::debug!(%target_url, "knowledge: push webhook already configured");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
// Delete stale hooks that point at our path but a different base
|
||||||
|
// (e.g. old loopback hooks from before the SSRF-bypass migration).
|
||||||
|
for h in &hooks {
|
||||||
|
let hook_url = h
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.get("url"))
|
||||||
|
.map_or("", String::as_str);
|
||||||
|
if hook_url.ends_with("/webhook/knowledge")
|
||||||
|
&& hook_url != target_url
|
||||||
|
&& let Some(id) = h.id
|
||||||
|
{
|
||||||
|
tracing::info!(hook_url, "knowledge: deleting stale webhook (wrong base)");
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
HTTP_TIMEOUT,
|
||||||
|
client.repo_delete_hook(ORG, REPO, id).send(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!(error = %e, "knowledge: listing hooks failed; attempting create");
|
tracing::debug!(error = %e, "knowledge: listing hooks failed; attempting create");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue