main
tag: launch-2026-06-01
tag: magicmirror-v0.1.1
tag: magicmirror-v0.3.0
tag: mnw-cli-v0.1.2
tag: mnw-cli-v0.1.3
tag: mnw-cli-v0.1.4
tag: pom-v0.4.1
tag: pom-v0.4.2
tag: pom-v0.4.3
tag: pom-v0.4.4
tag: pom-v0.4.5
tag: wam-v0.3.0
tag: wam-v0.3.1
Files
Commits
Tags
Notes
Issues
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
})?;