Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: test verify_hmac guards and webhook status mapping

`verify_hmac` is the only authentication gate on the public
`/webhook/config-pr` endpoint, and neither of its guard clauses nor the
handler's "unavailable" → 503 / otherwise → 401 mapping had a test.

The tests build a real `AppState` over the tempdir `Coordinator` that
the socket_server schedule tests already use. That helper is widened
from `pub(in crate::socket_server)` to `pub(crate)` and re-exported from
`socket_server` under `#[cfg(test)]`, so non-test code is unchanged.

Removing the missing-secret guard (replacing it with
`unwrap_or_default()`) fails
verify_hmac_rejects_every_delivery_when_no_secret_is_loaded and
config_pr_answers_503_when_no_secret_is_loaded. Deleting the
missing-header guard fails
verify_hmac_rejects_a_delivery_with_no_usable_signature_header. Breaking
the "unavailable" match fails the 503 test.

Without the missing-header guard, an unsigned delivery is still refused
by `verify_signature` (no `sha256=` prefix). The test therefore pins the
guard's own message rather than the bare rejection.

Closes #4654
This commit is contained in:
atlas 2026-10-02 17:01:21 +02:00 • committed by mara
commit a40c0026cf
3 changed files with 141 additions and 1 deletions

View file

@ -222,3 +222,141 @@ pub(super) async fn post_webhook_config_pr(
(StatusCode::OK, "ok").into_response()
}
#[cfg(test)]
mod tests {
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, HeaderValue, StatusCode},
};
use super::{AppState, post_webhook_config_pr, verify_hmac};
const SECRET: &str = "s3cr3t";
/// The `TempDir` holds the coordinator's sqlite files; keep it alive as
/// long as the state.
fn state(webhook_secret: Option<&str>) -> (tempfile::TempDir, AppState) {
let (dir, coord) = crate::socket_server::coordinator();
let state = AppState {
coord,
webhook_secret: webhook_secret.map(str::to_owned),
};
(dir, state)
}
/// The `X-Hub-Signature-256` header Forgejo would send for `secret` + `body`.
fn signed(secret: &str, body: &[u8]) -> HeaderMap {
use std::fmt::Write as _;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let mut hex = String::new();
for b in mac.finalize().into_bytes() {
write!(hex, "{b:02x}").unwrap();
}
let mut headers = HeaderMap::new();
headers.insert(
"x-hub-signature-256",
HeaderValue::from_str(&format!("sha256={hex}")).unwrap(),
);
headers
}
/// With no secret loaded, nothing verifies — including a delivery signed
/// with the empty key. The message must say "unavailable": the handler
/// maps on that word to 503.
#[test]
fn verify_hmac_rejects_every_delivery_when_no_secret_is_loaded() {
let (_dir, state) = state(None);
let body = Bytes::from_static(b"{}");
for (label, headers) in [
("signed with the empty key", signed("", &body)),
("signed with some key", signed(SECRET, &body)),
("unsigned", HeaderMap::new()),
] {
let err = verify_hmac(&state, &headers, &body).expect_err(label);
assert!(err.contains("unavailable"), "{label}: {err}");
}
}
/// An absent header, an empty one and one that is not valid UTF-8 all
/// read as "no signature", and are refused before any HMAC is computed.
#[test]
fn verify_hmac_rejects_a_delivery_with_no_usable_signature_header() {
let (_dir, state) = state(Some(SECRET));
let body = Bytes::from_static(b"{}");
let mut empty = HeaderMap::new();
empty.insert("x-hub-signature-256", HeaderValue::from_static(""));
let mut not_utf8 = HeaderMap::new();
not_utf8.insert(
"x-hub-signature-256",
HeaderValue::from_bytes(b"sha256=\xff").unwrap(),
);
for (label, headers) in [
("absent", HeaderMap::new()),
("empty", empty),
("not UTF-8", not_utf8),
] {
assert_eq!(
verify_hmac(&state, &headers, &body),
Err("missing X-Hub-Signature-256 header".to_owned()),
"{label}"
);
}
}
/// The other side of both guards: a loaded secret and a matching
/// signature pass.
#[test]
fn verify_hmac_accepts_a_correctly_signed_delivery() {
let (_dir, state) = state(Some(SECRET));
let body = Bytes::from_static(b"{}");
assert_eq!(verify_hmac(&state, &signed(SECRET, &body), &body), Ok(()));
}
/// 503 means "this hive cannot verify any delivery", 401 means "this
/// delivery is not authentic".
#[tokio::test]
async fn config_pr_answers_503_when_no_secret_is_loaded() {
let (_dir, state) = state(None);
let body = Bytes::from_static(b"{}");
let headers = signed(SECRET, &body);
let resp = post_webhook_config_pr(State(state), headers, body).await;
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn config_pr_answers_401_for_a_missing_or_wrong_signature() {
let body = Bytes::from_static(b"{}");
for (label, headers) in [
("missing", HeaderMap::new()),
("wrong secret", signed("different-secret", &body)),
("other body", signed(SECRET, b"tampered")),
] {
let (_dir, state) = state(Some(SECRET));
let resp = post_webhook_config_pr(State(state), headers, body.clone()).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{label}");
}
}
/// A verified delivery reaches payload handling: an action the handler
/// ignores comes back 200, and an unparseable body 400, neither of which
/// is an auth status.
#[tokio::test]
async fn config_pr_lets_a_correctly_signed_delivery_through() {
for (body, expected) in [
(&br#"{"action":"closed"}"#[..], StatusCode::OK),
(&b"not json"[..], StatusCode::BAD_REQUEST),
] {
let (_dir, state) = state(Some(SECRET));
let body = Bytes::from_static(body);
let headers = signed(SECRET, &body);
let resp = post_webhook_config_pr(State(state), headers, body).await;
assert_eq!(resp.status(), expected);
}
}
}

View file

@ -27,6 +27,8 @@ mod schedules;
pub(crate) use config_approvals::submit_merge_config_pr;
pub(crate) use schedules::filter_ghost_schedule_targets;
pub use schedules::schedule_to_wire_public;
#[cfg(test)]
pub(crate) use schedules::tests::coordinator;
use schedules::{
EditSchedulePatch, handle_cancel_schedule, handle_edit_schedule, handle_fire_schedule_now,

View file

@ -380,7 +380,7 @@ pub(super) mod tests {
/// A real `Coordinator` over a throwaway sqlite dir. The socket-server
/// handlers take `&Arc<Coordinator>`, so there is no lighter way in;
/// `open` touches nothing outside the db path it is handed.
pub(in crate::socket_server) fn coordinator() -> (tempfile::TempDir, Arc<Coordinator>) {
pub(crate) fn coordinator() -> (tempfile::TempDir, Arc<Coordinator>) {
let dir = tempfile::tempdir().expect("tempdir");
let coord = Coordinator::open(
&dir.path().join("broker.sqlite"),