Skip to main content

sentinel_core/normalize/
http.rs

1//! HTTP URL normalizer.
2//!
3//! Replaces numeric path segments with `{id}`, UUID segments with `{uuid}`,
4//! strips query parameters, and prepends the HTTP method.
5
6use std::borrow::Cow;
7
8/// Check if a string is a UUID (8-4-4-4-12 hex with dashes).
9/// Hand-coded for performance, avoids regex engine overhead on the hot path.
10fn is_uuid(s: &str) -> bool {
11    if s.len() != 36 {
12        return false;
13    }
14    let b = s.as_bytes();
15    b[8] == b'-'
16        && b[13] == b'-'
17        && b[18] == b'-'
18        && b[23] == b'-'
19        && b.iter()
20            .enumerate()
21            .all(|(i, &c)| matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit())
22}
23
24/// Result of HTTP URL normalization.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct HttpNormalized {
27    pub template: String,
28    pub params: Vec<String>,
29}
30
31/// Check if a segment is purely numeric (ASCII digits, non-empty).
32fn is_numeric(seg: &str) -> bool {
33    !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit())
34}
35
36/// Count occurrences of `target` in `s`.
37fn bytecount(s: &str, target: u8) -> usize {
38    s.bytes().filter(|&b| b == target).count()
39}
40
41/// Normalize an HTTP target URL.
42///
43/// Replaces numeric segments with `{id}`, UUID segments with `{uuid}`,
44/// strips query params, and prepends the method. The callee host is kept in
45/// the template for DNS-addressed calls (`GET user-svc/api/x`) so two calls
46/// to the same path on different backends do not merge into one group and
47/// raise a false redundant/N+1 finding. IP-literal authorities are dropped,
48/// so load-balanced replicas (pods behind one service) still group together.
49#[must_use]
50pub fn normalize_http(method: &str, target: &str) -> HttpNormalized {
51    // Split scheme + authority from the path, keeping the authority so a
52    // DNS host can stay in the grouping template.
53    let (authority, path_and_query) = split_origin(target);
54
55    // Strip query params
56    let (path, query_params) = match path_and_query.split_once('?') {
57        Some((p, q)) => (p, Some(q)),
58        None => (path_and_query, None),
59    };
60
61    // Collect query params as extracted values (capped to prevent unbounded allocation).
62    // Each pair is heap-allocated via to_string(). A Cow<str> backed by the source
63    // would avoid this, but NormalizedEvent.params is Vec<String> throughout the
64    // pipeline, so the allocation is unavoidable without a larger refactor. Pre-size
65    // the Vec from the ampersand count to avoid the doubling growth on the hot path.
66    let mut params = match query_params {
67        Some(q) => {
68            let cap = (bytecount(q, b'&') + 1).min(100);
69            let mut out = Vec::with_capacity(cap);
70            for pair in q.split('&').take(100) {
71                out.push(pair.to_string());
72            }
73            out
74        }
75        None => Vec::new(),
76    };
77
78    let normalized_path = normalize_path_segments(path, &mut params);
79
80    // `normalized_path` starts with `/` whenever an authority was present
81    // (it is sliced from the authority's trailing `/`), so a DNS host slots
82    // in as `GET host/path` with no extra separator handling.
83    let template = match authority.and_then(host_group_prefix) {
84        Some(host) => format!("{method} {host}{normalized_path}"),
85        None => format!("{method} {normalized_path}"),
86    };
87    HttpNormalized { template, params }
88}
89
90/// Normalize path segments: replace numeric with `{id}`, UUIDs with `{uuid}`.
91fn normalize_path_segments(path: &str, params: &mut Vec<String>) -> String {
92    if path.is_empty() || path == "/" {
93        return "/".to_string();
94    }
95    let mut result = String::with_capacity(path.len() + 8);
96    for (idx, seg) in path.split('/').enumerate() {
97        if idx > 0 {
98            result.push('/');
99        }
100        if seg.is_empty() {
101            // leading or trailing slash
102        } else if is_uuid(seg) {
103            params.push(seg.to_string());
104            result.push_str("{uuid}");
105        } else if is_numeric(seg) {
106            params.push(seg.to_string());
107            result.push_str("{id}");
108        } else {
109            result.push_str(seg);
110        }
111    }
112    result
113}
114
115/// Split scheme + authority from an `http(s)` URL. Returns
116/// `(authority, path_and_query)`: `authority` is `None` for a relative URL
117/// (no scheme), and `path_and_query` defaults to `/` when the URL has an
118/// authority but no path (`http://host`).
119fn split_origin(target: &str) -> (Option<&str>, &str) {
120    match target
121        .strip_prefix("http://")
122        .or_else(|| target.strip_prefix("https://"))
123    {
124        // RFC 3986: the authority ends at the first '/', '?' or '#'.
125        // Terminating only on '/' would fold a query or fragment into the
126        // authority, leaking it (verbatim, secrets included) into the
127        // grouping template on a URL with no path (`http://host?token=...`).
128        // A '#' terminator means there is no path (fragments never carry a
129        // path), and the fragment is never sent to the server, so the path
130        // is just `/`. A fragment that follows an actual path is left in the
131        // path unchanged (it is handled by `normalize_path_segments`).
132        Some(rest) => match rest.find(['/', '?', '#']) {
133            Some(idx) if rest.as_bytes()[idx] == b'#' => (Some(&rest[..idx]), "/"),
134            Some(idx) => (Some(&rest[..idx]), &rest[idx..]),
135            None => (Some(rest), "/"),
136        },
137        None => (None, target),
138    }
139}
140
141/// The DNS host to keep in the grouping template, or `None` when the
142/// authority is an IP literal (kept anonymous so load-balanced replicas
143/// still dedup) or empty. Strips RFC 3986 userinfo and the port, drops a
144/// single trailing DNS root dot (`svc.` == `svc`), and lowercases the host
145/// (DNS is case-insensitive) so casing variants group.
146fn host_group_prefix(authority: &str) -> Option<Cow<'_, str>> {
147    // Strip userinfo: "user:pass@host:port" -> "host:port". Safe because
148    // split_origin already trimmed any query/fragment (which may contain
149    // '@'), so the remaining '@' can only be the userinfo delimiter.
150    let host_port = authority.rsplit('@').next().unwrap_or(authority);
151    // IPv6 literal ("[::1]:8080"): always an address, drop it.
152    if host_port.starts_with('[') {
153        return None;
154    }
155    // Strip the port: "host:port" -> "host", then the DNS root dot.
156    let host = host_port.split(':').next().unwrap_or(host_port);
157    let host = host.strip_suffix('.').unwrap_or(host);
158    if host.is_empty() || is_ipv4_literal(host) {
159        return None;
160    }
161    if host.bytes().any(|b| b.is_ascii_uppercase()) {
162        Some(Cow::Owned(host.to_ascii_lowercase()))
163    } else {
164        Some(Cow::Borrowed(host))
165    }
166}
167
168/// Whether `host` is a dotted-decimal IPv4 literal (exactly four all-digit
169/// octets). Such authorities are load-balanced replica addresses, so they
170/// are dropped from the template while DNS hostnames are kept. Counts in
171/// `usize` and bails past four labels so a host with hundreds of numeric
172/// dot-labels cannot overflow the counter.
173fn is_ipv4_literal(host: &str) -> bool {
174    let mut octets = 0usize;
175    for part in host.split('.') {
176        if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
177            return false;
178        }
179        octets += 1;
180        if octets > 4 {
181            return false;
182        }
183    }
184    octets == 4
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn simple_path_with_numeric_id() {
193        let r = normalize_http("GET", "/api/orders/42/submit");
194        assert_eq!(r.template, "GET /api/orders/{id}/submit");
195        assert_eq!(r.params, vec!["42"]);
196    }
197
198    #[test]
199    fn uuid_segment() {
200        let r = normalize_http("GET", "/api/users/a1b2c3d4-e5f6-7890-abcd-ef1234567890");
201        assert_eq!(r.template, "GET /api/users/{uuid}");
202        assert_eq!(r.params, vec!["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]);
203    }
204
205    #[test]
206    fn full_url_keeps_dns_host() {
207        // The DNS host stays in the template (and the port is dropped) so
208        // calls to the same path on different backends stay distinct groups.
209        let r = normalize_http("GET", "http://user-svc:5000/api/users/user-123");
210        assert_eq!(r.template, "GET user-svc/api/users/user-123");
211    }
212
213    #[test]
214    fn query_params_stripped() {
215        let r = normalize_http("GET", "/api/users?page=2&size=10");
216        assert_eq!(r.template, "GET /api/users");
217        assert_eq!(r.params, vec!["page=2", "size=10"]);
218    }
219
220    #[test]
221    fn full_url_with_query() {
222        let r = normalize_http("POST", "https://svc.internal/api/items/99?expand=true");
223        assert_eq!(r.template, "POST svc.internal/api/items/{id}");
224        assert_eq!(r.params, vec!["expand=true", "99"]);
225    }
226
227    #[test]
228    fn multiple_numeric_segments() {
229        let r = normalize_http("DELETE", "/api/orders/42/items/7");
230        assert_eq!(r.template, "DELETE /api/orders/{id}/items/{id}");
231        assert_eq!(r.params, vec!["42", "7"]);
232    }
233
234    #[test]
235    fn root_path() {
236        let r = normalize_http("GET", "/");
237        assert_eq!(r.template, "GET /");
238        assert!(r.params.is_empty());
239    }
240
241    #[test]
242    fn no_numeric_or_uuid_segments() {
243        let r = normalize_http("GET", "/api/health");
244        assert_eq!(r.template, "GET /api/health");
245        assert!(r.params.is_empty());
246    }
247
248    #[test]
249    fn port_in_url_not_treated_as_id() {
250        // Host kept, port dropped, and the port digits are not an {id}.
251        let r = normalize_http("GET", "http://localhost:8080/api/items");
252        assert_eq!(r.template, "GET localhost/api/items");
253    }
254
255    #[test]
256    fn url_without_path_keeps_host() {
257        let r = normalize_http("GET", "http://example.com");
258        assert_eq!(r.template, "GET example.com/");
259        assert!(r.params.is_empty());
260    }
261
262    #[test]
263    fn https_url_without_path() {
264        let r = normalize_http("GET", "https://example.com");
265        assert_eq!(r.template, "GET example.com/");
266    }
267
268    #[test]
269    fn dns_hosts_disambiguate_same_path() {
270        // The core fix: same method + path on two DNS backends must NOT
271        // collapse into one template (which would raise a false redundant).
272        let a = normalize_http("POST", "http://ms-23205/vs2nqhh1hq");
273        let b = normalize_http("POST", "http://ms-53745/vs2nqhh1hq");
274        assert_eq!(a.template, "POST ms-23205/vs2nqhh1hq");
275        assert_eq!(b.template, "POST ms-53745/vs2nqhh1hq");
276        assert_ne!(a.template, b.template);
277    }
278
279    #[test]
280    fn ipv4_hosts_are_dropped_keeping_replica_dedup() {
281        // Load-balanced pod replicas share a service; their IP authorities
282        // must collapse to one template so the dedup stays intentional.
283        let a = normalize_http("GET", "http://10.0.0.1:8080/api/x");
284        let b = normalize_http("GET", "http://10.0.0.2:8080/api/x");
285        assert_eq!(a.template, "GET /api/x");
286        assert_eq!(a.template, b.template);
287    }
288
289    #[test]
290    fn ipv6_host_is_dropped() {
291        let r = normalize_http("GET", "http://[2001:db8::1]:8080/api/x");
292        assert_eq!(r.template, "GET /api/x");
293    }
294
295    #[test]
296    fn host_is_lowercased() {
297        let r = normalize_http("GET", "http://User-SVC.Example.COM/api/x");
298        assert_eq!(r.template, "GET user-svc.example.com/api/x");
299    }
300
301    #[test]
302    fn userinfo_is_stripped_from_host() {
303        let r = normalize_http("GET", "http://user:pass@svc.internal/api/x");
304        assert_eq!(r.template, "GET svc.internal/api/x");
305    }
306
307    #[test]
308    fn relative_url_has_no_host() {
309        // No authority to key on, behavior unchanged from before the fix.
310        let r = normalize_http("GET", "/api/x");
311        assert_eq!(r.template, "GET /api/x");
312    }
313
314    #[test]
315    fn query_only_url_does_not_leak_into_host() {
316        // Regression: a query on a path-less URL must not fold into the
317        // authority and leak (e.g. a token) verbatim into the template.
318        let r = normalize_http("GET", "http://api.example.com?token=abc123secret");
319        assert_eq!(r.template, "GET api.example.com/");
320        assert!(!r.template.contains("token"), "{}", r.template);
321    }
322
323    #[test]
324    fn query_with_userinfo_does_not_leak() {
325        let r = normalize_http("GET", "http://user:pass@svc.internal?token=xyz");
326        assert_eq!(r.template, "GET svc.internal/");
327        assert!(!r.template.contains("token"), "{}", r.template);
328    }
329
330    #[test]
331    fn fragment_only_url_does_not_pollute_host() {
332        // A path-less fragment is never sent to the server, so it is dropped
333        // and must not become part of the host token.
334        let r = normalize_http("GET", "http://svc.internal#section");
335        assert_eq!(r.template, "GET svc.internal/");
336    }
337
338    #[test]
339    fn trailing_dns_dot_groups_with_bare_host() {
340        // `svc.` (DNS root label) and `svc` are the same host.
341        let dotted = normalize_http("GET", "http://user-svc./api/x");
342        let bare = normalize_http("GET", "http://user-svc/api/x");
343        assert_eq!(dotted.template, "GET user-svc/api/x");
344        assert_eq!(dotted.template, bare.template);
345    }
346
347    #[test]
348    fn pathological_numeric_host_does_not_overflow() {
349        // 260 all-numeric dot-labels must not overflow the octet counter
350        // (debug panic) nor wrap-classify as an IPv4 literal.
351        let host = vec!["1"; 260].join(".");
352        let r = normalize_http("GET", &format!("http://{host}/x"));
353        // Not four octets, so it is treated as a DNS host and kept.
354        assert_eq!(r.template, format!("GET {host}/x"));
355        assert!(is_ipv4_literal("1.2.3.4"));
356        assert!(!is_ipv4_literal(&host));
357    }
358
359    #[test]
360    fn non_uuid_36_char_segment_not_replaced() {
361        // 36 chars but not a valid UUID format
362        let r = normalize_http("GET", "/api/users/abcdefghijklmnopqrstuvwxyz1234567890");
363        assert_eq!(
364            r.template,
365            "GET /api/users/abcdefghijklmnopqrstuvwxyz1234567890"
366        );
367        assert!(r.params.is_empty());
368    }
369
370    #[test]
371    fn empty_path() {
372        let r = normalize_http("GET", "");
373        assert_eq!(r.template, "GET /");
374    }
375
376    #[test]
377    fn trailing_slash() {
378        let r = normalize_http("GET", "/api/users/");
379        assert_eq!(r.template, "GET /api/users/");
380        assert!(r.params.is_empty());
381    }
382
383    #[test]
384    fn single_numeric_segment() {
385        let r = normalize_http("GET", "/42");
386        assert_eq!(r.template, "GET /{id}");
387        assert_eq!(r.params, vec!["42"]);
388    }
389
390    #[test]
391    fn mixed_uuid_and_numeric() {
392        let r = normalize_http(
393            "PUT",
394            "/api/org/a1b2c3d4-e5f6-7890-abcd-ef1234567890/user/99",
395        );
396        assert_eq!(r.template, "PUT /api/org/{uuid}/user/{id}");
397        assert_eq!(r.params, vec!["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "99"]);
398    }
399
400    #[test]
401    fn is_uuid_valid() {
402        assert!(is_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
403        assert!(is_uuid("00000000-0000-0000-0000-000000000000"));
404        assert!(is_uuid("AAAABBBB-CCCC-DDDD-EEEE-FFFFFFFFFFFF"));
405    }
406
407    #[test]
408    fn is_uuid_invalid() {
409        assert!(!is_uuid("not-a-uuid-at-all"));
410        assert!(!is_uuid("")); // too short
411        assert!(!is_uuid("a1b2c3d4-e5f6-7890-abcd-ef123456789")); // 35 chars
412        assert!(!is_uuid("a1b2c3d4-e5f6-7890-abcd-ef12345678901")); // 37 chars
413        assert!(!is_uuid("a1b2c3d4xe5f6-7890-abcd-ef1234567890")); // wrong dash pos
414        assert!(!is_uuid("g1b2c3d4-e5f6-7890-abcd-ef1234567890")); // 'g' not hex
415    }
416
417    #[test]
418    fn uppercase_uuid_detected() {
419        let r = normalize_http("GET", "/api/item/A1B2C3D4-E5F6-7890-ABCD-EF1234567890");
420        assert_eq!(r.template, "GET /api/item/{uuid}");
421    }
422
423    // -- Fragment handling --
424
425    #[test]
426    fn fragment_not_stripped_from_path() {
427        // Fragments are rare in server-side URLs; the segment "42#section" is not
428        // purely numeric so it passes through as-is (fragment is not separated)
429        let r = normalize_http("GET", "/api/users/42#section");
430        assert_eq!(r.template, "GET /api/users/42#section");
431    }
432
433    // -- Malformed/edge-case query params --
434
435    #[test]
436    fn trailing_question_mark_only() {
437        let r = normalize_http("GET", "/api/users?");
438        assert_eq!(r.template, "GET /api/users");
439        assert_eq!(r.params, vec![""]);
440    }
441
442    #[test]
443    fn empty_query_param_values() {
444        let r = normalize_http("GET", "/api/users?id=&name=");
445        assert_eq!(r.template, "GET /api/users");
446        assert_eq!(r.params, vec!["id=", "name="]);
447    }
448
449    #[test]
450    fn double_ampersand_in_query() {
451        let r = normalize_http("GET", "/api/users?a=1&&b=2");
452        assert_eq!(r.template, "GET /api/users");
453        assert_eq!(r.params, vec!["a=1", "", "b=2"]);
454    }
455
456    // -- Double slashes --
457
458    #[test]
459    fn double_slash_in_path_preserved() {
460        let r = normalize_http("GET", "/api//users/42");
461        assert_eq!(r.template, "GET /api//users/{id}");
462    }
463
464    // -- URL-encoded segments (pass through as-is) --
465
466    #[test]
467    fn url_encoded_numeric_not_detected() {
468        // %34%32 = "42" but URL-encoded, not decoded before detection
469        let r = normalize_http("GET", "/api/users/%34%32");
470        assert_eq!(r.template, "GET /api/users/%34%32");
471        assert!(r.params.is_empty());
472    }
473
474    // -- Query params capped at 100 --
475
476    #[test]
477    fn query_params_capped_at_100() {
478        let params: Vec<String> = (0..200).map(|i| format!("p{i}={i}")).collect();
479        let url = format!("/api/test?{}", params.join("&"));
480        let r = normalize_http("GET", &url);
481        assert_eq!(r.params.len(), 100);
482    }
483}