fix(reborn): reject duplicate shared-secret verification headers

SharedSecretHeaderAuth read the header with HeaderMap::get, which
silently takes the FIRST occurrence — duplicate verification headers
became first-wins, a classic proxy-disagreement ambiguity vector (one
hop validates occurrence A while another forwards occurrence B). A
request carrying the header more than once is now rejected as
Malformed outright, even when one copy holds the correct secret; the
single-occurrence path is unchanged (same constant-time comparison).

Red-first regression:
auth_verifier::shared_secret_header_rejects_duplicate_headers_even_with_a_correct_value
(fails on the pre-fix first-wins read for the correct-then-forged
arrangement). Automates manual-QA row qa-telegram:S3. Crate suite
66/66; telegram serve suite unaffected (nothing legitimate sends
duplicates).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
BenKurrek
2026-07-16 23:57:14 -04:00
parent 937ecebda5
commit 04df727ffc

View File

@@ -224,14 +224,20 @@ impl WebhookAuthVerifier for SharedSecretHeaderAuth {
failure: ProtocolAuthFailure::Malformed,
};
}
let Some(received) = headers
.get(self.header_name.as_str())
.and_then(|v| v.to_str().ok())
else {
let mut values = headers.get_all(self.header_name.as_str()).iter();
let Some(received) = values.next().and_then(|v| v.to_str().ok()) else {
return VerificationOutcome::Failed {
failure: ProtocolAuthFailure::Missing,
};
};
// Duplicate occurrences are an ambiguity vector (proxies disagree on
// which one "counts"), so they are malformed outright — never
// first-or-last-wins, even if one copy carries the correct secret.
if values.next().is_some() {
return VerificationOutcome::Failed {
failure: ProtocolAuthFailure::Malformed,
};
}
if !bool::from(received.as_bytes().ct_eq(self.expected_secret.as_bytes())) {
return VerificationOutcome::Failed {
failure: ProtocolAuthFailure::SharedSecretMismatch,
@@ -288,6 +294,42 @@ mod tests {
}
}
/// Duplicate verification headers are an ambiguity vector (different
/// proxies pick different occurrences), so the verifier rejects them
/// outright — no first-or-last-wins, even when one occurrence carries the
/// correct secret. Automates manual-QA row qa-telegram:S3.
#[test]
fn shared_secret_header_rejects_duplicate_headers_even_with_a_correct_value() {
let verifier = SharedSecretHeaderAuth {
header_name: "X-Telegram-Bot-Api-Secret-Token".into(),
expected_secret: "topsecret".into(),
subject: "telegram_install_alpha".into(),
};
for values in [
["topsecret", "forged"],
["forged", "topsecret"],
["topsecret", "topsecret"],
] {
let mut headers = HeaderMap::new();
for value in values {
headers.append(
http::header::HeaderName::from_bytes(b"X-Telegram-Bot-Api-Secret-Token")
.expect("name"),
HeaderValue::from_str(value).expect("value"),
);
}
match verifier.verify(&headers, b"") {
VerificationOutcome::Failed { failure } => {
assert!(
matches!(failure, ProtocolAuthFailure::Malformed),
"duplicates are malformed requests, got {failure:?}"
);
}
other => panic!("expected Failed for duplicates {values:?}, got {other:?}"),
}
}
}
#[test]
fn shared_secret_header_rejects_mismatch() {
let verifier = SharedSecretHeaderAuth {