Skip to main content

max / makenotwork

Add HMAC auth to internal thread stats endpoint Extract verify_hmac_headers for GET endpoints without body extractor. Sign GET requests in MNW mt_client. Update test harness to sign GETs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 20:06 UTC
Commit: 7842fb33af8180d2a53f4d70b4d7ea09b5cc9524
Parent: 141b0f6
4 files changed, +64 insertions, -3 deletions
@@ -87,6 +87,53 @@ impl FromRequest<AppState> for InternalAuth {
87 87 }
88 88 }
89 89
90 + /// Verify HMAC-SHA256 headers on an internal request (for GET endpoints without a body extractor).
91 + pub fn verify_hmac_headers(
92 + state: &AppState,
93 + headers: &axum::http::HeaderMap,
94 + body: &[u8],
95 + ) -> Result<(), (StatusCode, &'static str)> {
96 + let secret = state
97 + .config
98 + .internal_shared_secret
99 + .as_deref()
100 + .ok_or_else(|| {
101 + tracing::warn!("internal API called but INTERNAL_SHARED_SECRET not configured");
102 + (StatusCode::SERVICE_UNAVAILABLE, "Service unavailable")
103 + })?;
104 +
105 + let timestamp_str = headers
106 + .get("X-Internal-Timestamp")
107 + .and_then(|v| v.to_str().ok())
108 + .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?;
109 +
110 + let signature = headers
111 + .get("X-Internal-Signature")
112 + .and_then(|v| v.to_str().ok())
113 + .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?;
114 +
115 + let timestamp: i64 = timestamp_str
116 + .parse()
117 + .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?;
118 +
119 + let now = chrono::Utc::now().timestamp();
120 + if (now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
121 + return Err((StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future"));
122 + }
123 +
124 + let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(body).unwrap_or(""));
125 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
126 + .expect("HMAC-SHA256 accepts any key length");
127 + mac.update(message.as_bytes());
128 + let expected = hex::encode(mac.finalize().into_bytes());
129 +
130 + if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
131 + return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
132 + }
133 +
134 + Ok(())
135 + }
136 +
90 137 /// Constant-time byte comparison to prevent timing attacks.
91 138 fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
92 139 if a.len() != b.len() {
@@ -275,10 +275,12 @@ async fn create_thread(
275 275 #[tracing::instrument(skip_all, name = "internal::thread_stats")]
276 276 async fn thread_stats(
277 277 State(state): State<AppState>,
278 + headers: axum::http::HeaderMap,
278 279 Path(id): Path<String>,
279 280 ) -> Result<Json<ThreadStatsResponse>, Response> {
280 - // Note: stats endpoint doesn't require HMAC auth (read-only, no sensitive data).
281 - // But it's only accessible via the internal route prefix which is not public.
281 + crate::internal_auth::verify_hmac_headers(&state, &headers, b"")
282 + .map_err(|e| e.into_response())?;
283 +
282 284 let thread_id = Uuid::parse_str(&id).map_err(|_| {
283 285 StatusCode::NOT_FOUND.into_response()
284 286 })?;
@@ -96,11 +96,20 @@ impl InternalTestHarness {
96 96 (status, text)
97 97 }
98 98
99 - /// Send an unsigned GET request to the internal API.
99 + /// Send a signed GET request to the internal API.
100 100 async fn get(&self, uri: &str) -> (StatusCode, String) {
101 + let timestamp = chrono::Utc::now().timestamp().to_string();
102 + let message = format!("{}\n", timestamp);
103 + let mut mac =
104 + Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key");
105 + mac.update(message.as_bytes());
106 + let signature = hex::encode(mac.finalize().into_bytes());
107 +
101 108 let mut request = Request::builder()
102 109 .method(Method::GET)
103 110 .uri(uri)
111 + .header("X-Internal-Timestamp", &timestamp)
112 + .header("X-Internal-Signature", &signature)
104 113 .body(Body::empty())
105 114 .expect("build request");
106 115
@@ -176,12 +176,15 @@ impl MtClient {
176 176 &self,
177 177 thread_id: Uuid,
178 178 ) -> Result<ThreadStatsResponse, MtClientError> {
179 + let (timestamp, signature) = self.sign_request("");
179 180 let resp = self
180 181 .http
181 182 .get(format!(
182 183 "{}/internal/threads/{}/stats",
183 184 self.base_url, thread_id
184 185 ))
186 + .header("X-Internal-Timestamp", &timestamp)
187 + .header("X-Internal-Signature", &signature)
185 188 .send()
186 189 .await
187 190 .map_err(MtClientError::Unreachable)?;