max / makenotwork
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", ×tamp) | |
| 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", ×tamp) | |
| 187 | + | .header("X-Internal-Signature", &signature) | |
| 185 | 188 | .send() | |
| 186 | 189 | .await | |
| 187 | 190 | .map_err(MtClientError::Unreachable)?; |