Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -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.

58 changes: 55 additions & 3 deletions services/api/src/correlation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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.
Expand All @@ -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);
}
Expand All @@ -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());
}
}