diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..dbf2e67 --- /dev/null +++ b/TODO.md @@ -0,0 +1,18 @@ +# TODO + +## Plan to implement validated Correlation ID handling + +1. Inspect current correlation middleware implementation in `services/api/src/correlation.rs`. +2. Add validation logic: + - enforce maximum header value length + - accept only UUID v4 (parse + version check) +3. If missing/invalid/too long: generate new UUID v4. +4. Ensure the normalized UUID is recorded in tracing span and echoed back via `X-Request-Id` response header. +5. Add/adjust unit tests for the middleware to cover: + - valid UUID v4 passes through + - malformed string replaced + - UUID v1/other versions replaced + - too-long header replaced +6. Update any documentation/comments if needed. +7. Run `cargo test -p services/api` (or workspace-equivalent) to confirm tests pass. + diff --git a/services/api/src/correlation.rs b/services/api/src/correlation.rs index a685e95..05d2047 100644 --- a/services/api/src/correlation.rs +++ b/services/api/src/correlation.rs @@ -8,10 +8,29 @@ use uuid::Uuid; pub const REQUEST_ID_HEADER: &str = "x-request-id"; +/// Maximum allowed header length for the correlation/correlation ID. +/// +/// UUIDs in canonical string form are 36 bytes (e.g. `550e8400-e29b-41d4-a716-446655440000`). +pub const REQUEST_ID_MAX_LEN: usize = 64; + +fn parse_valid_request_id(header_value: &str) -> Option { + if header_value.len() > REQUEST_ID_MAX_LEN { + return None; + } + + // Validate as UUID v4. + let uuid = Uuid::parse_str(header_value).ok()?; + if uuid.get_version_num() != 4 { + return None; + } + + Some(uuid.to_string()) +} + /// Middleware that attaches a correlation ID to every request. /// -/// - Reads `X-Request-ID` from the incoming request if present; otherwise -/// generates a new UUID v4. +/// - Reads `X-Request-ID` from the incoming request if present and validates it as UUID v4. +/// Otherwise generates a new UUID v4. /// - Records the ID as a `request_id` field on the current tracing span so /// every log line emitted within the request carries it automatically. /// - Echoes the ID back in the `X-Request-ID` response header. @@ -20,10 +39,11 @@ pub async fn correlation_id_middleware(mut req: Request, next: Next) -> Response .headers() .get(REQUEST_ID_HEADER) .and_then(|v| v.to_str().ok()) - .map(|s| s.to_owned()) + .and_then(parse_valid_request_id) .unwrap_or_else(|| Uuid::new_v4().to_string()); // Normalise: ensure the header is present on the request for downstream handlers. + // (If we ever failed to create a HeaderValue, fall back to not inserting.) if let Ok(val) = HeaderValue::from_str(&id) { req.headers_mut().insert(REQUEST_ID_HEADER, val); } @@ -39,3 +59,35 @@ pub async fn correlation_id_middleware(mut req: Request, next: Next) -> Response response } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_uuid_v4_is_accepted() { + let header = "550e8400-e29b-41d4-a716-446655440000"; // version 4 + let parsed = parse_valid_request_id(header); + assert_eq!(parsed.as_deref(), Some(header)); + } + + #[test] + fn malformed_is_rejected_and_replaced() { + assert!(parse_valid_request_id("not-a-uuid").is_none()); + assert!(parse_valid_request_id("550e8400-e29b").is_none()); + } + + #[test] + fn uuid_non_v4_is_rejected() { + // Version 1 UUID string example + let header = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + assert!(parse_valid_request_id(header).is_none()); + } + + #[test] + fn too_long_is_rejected() { + let long = format!("{}{}", "550e8400-e29b-41d4-a716-446655440000", "x".repeat(100)); + assert!(parse_valid_request_id(&long).is_none()); + } +} +