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>
2 files changed,
+4 insertions,
-54 deletions
| 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 |
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 |
|
})?;
|