Skip to main content

max / makenotwork

mt: verify thread_stats HMAC via the InternalAuth extractor thread_stats alone hand-rolled verify_hmac_headers while its three sibling internal endpoints used the InternalAuth extractor: one API, two signing paths to keep in sync. Route it through the same extractor (binds the concrete request path + empty GET body) and delete verify_hmac_headers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 14:46 UTC
Commit: 5ebe97ebf3fabfb7d69d5924577f53065eb3ea1c
Parent: cb4d22f
2 files changed, +4 insertions, -54 deletions
@@ -229,53 +229,6 @@ pub(crate) fn verify_signed_request(
229 229 Ok(())
230 230 }
231 231
232 - /// Verify HMAC headers on an internal request that has no body extractor (GET
233 - /// endpoints). `method` and `path` bind the v2 signature; pass the concrete
234 - /// request path (e.g. via `OriginalUri`), never a route template.
235 - pub fn verify_hmac_headers(
236 - state: &AppState,
237 - headers: &axum::http::HeaderMap,
238 - method: &str,
239 - path: &str,
240 - body: &[u8],
241 - ) -> Result<(), (StatusCode, &'static str)> {
242 - let secret = state
243 - .config
244 - .internal_shared_secret
245 - .as_deref()
246 - .ok_or_else(|| {
247 - tracing::warn!("internal API called but INTERNAL_SHARED_SECRET not configured");
248 - (StatusCode::SERVICE_UNAVAILABLE, "Service unavailable")
249 - })?;
250 -
251 - let timestamp_header = headers
252 - .get("X-Internal-Timestamp")
253 - .and_then(|v| v.to_str().ok());
254 - let signature_header = headers
255 - .get("X-Internal-Signature")
256 - .and_then(|v| v.to_str().ok());
257 - let nonce_header = headers.get("X-Internal-Nonce").and_then(|v| v.to_str().ok());
258 -
259 - let now = chrono::Utc::now().timestamp();
260 - verify_signed_request(
261 - secret,
262 - timestamp_header,
263 - signature_header,
264 - method,
265 - path,
266 - nonce_header,
267 - body,
268 - now,
269 - )?;
270 -
271 - if let Some(nonce) = nonce_header
272 - && !record_nonce(nonce, now)
273 - {
274 - return Err((StatusCode::UNAUTHORIZED, "Replayed nonce"));
275 - }
276 - Ok(())
277 - }
278 -
279 232 /// Constant-time byte comparison to prevent timing attacks.
280 233 fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
281 234 if a.len() != b.len() {
@@ -273,15 +273,12 @@ async fn create_thread(
273 273 #[tracing::instrument(skip_all, name = "internal::thread_stats")]
274 274 async fn thread_stats(
275 275 State(state): State<AppState>,
276 - headers: axum::http::HeaderMap,
277 - axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
278 276 Path(id): Path<String>,
277 + // Single HMAC path: the same `InternalAuth` extractor the POST siblings use.
278 + // It binds the concrete request path (from `req.uri().path()`) and an empty
279 + // body for this GET — no hand-rolled second verification path to keep in sync.
280 + InternalAuth(_body): InternalAuth,
279 281 ) -> Result<Json<ThreadStatsResponse>, Response> {
280 - // Sign over the concrete request path (OriginalUri), matching what the
281 - // server signer used — never the matched route template.
282 - crate::internal_auth::verify_hmac_headers(&state, &headers, "GET", uri.path(), b"")
283 - .map_err(|e| e.into_response())?;
284 -
285 282 let thread_id = Uuid::parse_str(&id).map_err(|_| {
286 283 StatusCode::NOT_FOUND.into_response()
287 284 })?;