Skip to main content

broadcast_auth/
request.rs

1//! The request context an `Authorization` response is computed against — and,
2//! server-side, that a [`crate::Verifier`] checks a request against.
3
4/// The request fields the Digest response hash covers (RFC 7616 §3.4.1 / RFC
5/// 2326 §14): the method, the request URI, and — for `qop=auth-int` — the
6/// body. Also carries the request's headers and transport peer address, so a
7/// server-side [`crate::Verifier`] scheme can see beyond the `Authorization`
8/// header — e.g. a reverse-proxy forwarded-auth scheme reading
9/// `X-Forwarded-User`/`X-Forwarded-For` (issue #663 extensibility wave part
10/// 1). Client-side use ([`crate::respond`]/[`crate::Authenticator`]) needs
11/// neither field; [`Self::new`] defaults both to empty/`None`.
12///
13/// The `uri` is scheme-specific: an HTTP absolute/relative URL for HTTP
14/// clients, or the RTSP request URI (e.g. `rtsp://host/stream`) for RTSP —
15/// never translate one into the other (RFC 2326 §14).
16#[derive(Clone, Copy)]
17pub struct RequestContext<'a> {
18    /// The request method (`"GET"`, `"DESCRIBE"`, …).
19    pub method: &'a str,
20    /// The request URI, in the caller's protocol's own form.
21    pub uri: &'a str,
22    /// The request body, needed only for Digest `qop=auth-int`. `Some(&[])`
23    /// for a bodyless request still lets `auth-int` be computed.
24    pub body: Option<&'a [u8]>,
25    /// Every request header, as `(name, value)` pairs — [`Self::header`]
26    /// looks one up case-insensitively (header names are case-insensitive,
27    /// RFC 7230 §3.2). Empty for client-side use (`Self::new`'s default): a
28    /// client answering a challenge computes `Authorization` from
29    /// `method`/`uri`/`body` alone. Server-side ([`crate::Verifier::verify`])
30    /// this is how every scheme — including a future one — reads whatever
31    /// header it needs, not just `Authorization`.
32    pub headers: &'a [(&'a str, &'a str)],
33    /// The transport-layer peer address (e.g. the accepted TCP connection's
34    /// remote address), if the caller has one to attach. This is the actual
35    /// connection peer — which, behind a reverse proxy, is the proxy itself,
36    /// not the original client (see `X-Forwarded-For` in [`Self::headers`]
37    /// for that). `None` for client-side use and whenever the caller has no
38    /// transport peer to attach.
39    pub peer_addr: Option<std::net::SocketAddr>,
40}
41
42impl<'a> RequestContext<'a> {
43    /// Builds a context for a bodyless request with no headers/peer attached
44    /// (the common client-side case) — use [`Self::with_headers`]/
45    /// [`Self::with_peer_addr`] to attach either.
46    pub fn new(method: &'a str, uri: &'a str) -> Self {
47        RequestContext {
48            method,
49            uri,
50            body: Some(&[]),
51            headers: &[],
52            peer_addr: None,
53        }
54    }
55
56    /// Attaches a request body (for `qop=auth-int`).
57    pub fn with_body(mut self, body: &'a [u8]) -> Self {
58        self.body = Some(body);
59        self
60    }
61
62    /// Attaches the request's headers (server-side use — see
63    /// [`Self::headers`]).
64    pub fn with_headers(mut self, headers: &'a [(&'a str, &'a str)]) -> Self {
65        self.headers = headers;
66        self
67    }
68
69    /// Attaches the transport peer address (server-side use — see
70    /// [`Self::peer_addr`]).
71    pub fn with_peer_addr(mut self, peer_addr: std::net::SocketAddr) -> Self {
72        self.peer_addr = Some(peer_addr);
73        self
74    }
75
76    /// Looks up a header by name, case-insensitively (RFC 7230 §3.2). Returns
77    /// the first match if [`Self::headers`] carries more than one with the
78    /// same name.
79    pub fn header(&self, name: &str) -> Option<&'a str> {
80        for &(k, v) in self.headers {
81            if k.eq_ignore_ascii_case(name) {
82                return Some(v);
83            }
84        }
85        None
86    }
87}
88
89/// Manual `Debug` (rather than `#[derive(Debug)]`): [`Self::headers`] carries
90/// whatever the caller attached, which — server-side — includes the real
91/// `Authorization`/`Proxy-Authorization` header the request was authenticated
92/// with. Basic's value is a reversible base64 `user:pass` (RFC 7617 §2); a
93/// bare `tracing::debug!(?ctx, ...)` call must not dump it to logs. Every
94/// other header (name and value) is rendered normally — only the value of an
95/// auth header is redacted, and only that header's name is enough to tell
96/// which one.
97impl core::fmt::Debug for RequestContext<'_> {
98    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99        struct Headers<'a>(&'a [(&'a str, &'a str)]);
100        impl core::fmt::Debug for Headers<'_> {
101            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
102                f.debug_list()
103                    .entries(self.0.iter().map(|(name, value)| {
104                        let redact = name.eq_ignore_ascii_case("authorization")
105                            || name.eq_ignore_ascii_case("proxy-authorization");
106                        (*name, if redact { "<redacted>" } else { value })
107                    }))
108                    .finish()
109            }
110        }
111        f.debug_struct("RequestContext")
112            .field("method", &self.method)
113            .field("uri", &self.uri)
114            .field("body", &self.body)
115            .field("headers", &Headers(self.headers))
116            .field("peer_addr", &self.peer_addr)
117            .finish()
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn header_lookup_is_case_insensitive() {
127        let headers: &[(&str, &str)] = &[("X-Forwarded-User", "alice")];
128        let ctx = RequestContext::new("GET", "/x").with_headers(headers);
129        assert_eq!(ctx.header("x-forwarded-user"), Some("alice"));
130        assert_eq!(ctx.header("X-FORWARDED-USER"), Some("alice"));
131        assert_eq!(ctx.header("x-forwarded-for"), None);
132    }
133
134    #[test]
135    fn new_defaults_to_no_headers_and_no_peer() {
136        let ctx = RequestContext::new("GET", "/x");
137        assert_eq!(ctx.header("authorization"), None);
138        assert_eq!(ctx.peer_addr, None);
139    }
140
141    #[test]
142    fn with_peer_addr_round_trips() {
143        let addr: std::net::SocketAddr = "127.0.0.1:8080".parse().unwrap();
144        let ctx = RequestContext::new("GET", "/x").with_peer_addr(addr);
145        assert_eq!(ctx.peer_addr, Some(addr));
146    }
147
148    // Security-blocker regression (pre-release audit): `RequestContext` must
149    // never render an `Authorization`/`Proxy-Authorization` header's value
150    // via `{:?}` — a bare `tracing::debug!(?ctx, ...)` must not leak
151    // credentials to logs. Must FAIL if `Debug` reverts to a plain
152    // `#[derive(Debug)]`.
153    #[test]
154    fn debug_redacts_authorization_header_value_but_keeps_other_fields() {
155        // "admin:hunter2-super-secret" base64-encoded.
156        let auth_value = "Basic YWRtaW46aHVudGVyMi1zdXBlci1zZWNyZXQ=";
157        let headers: &[(&str, &str)] =
158            &[("Authorization", auth_value), ("X-Forwarded-User", "alice")];
159        let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live").with_headers(headers);
160        let debug = format!("{ctx:?}");
161
162        assert!(
163            !debug.contains("YWRtaW46aHVudGVyMi1zdXBlci1zZWNyZXQ"),
164            "leaked base64 secret: {debug}"
165        );
166        assert!(
167            !debug.contains("hunter2"),
168            "leaked password substring: {debug}"
169        );
170        assert!(
171            debug.contains("<redacted>"),
172            "expected redaction marker: {debug}"
173        );
174
175        // Non-secret fields/headers must still be visible for diagnostics.
176        assert!(debug.contains("DESCRIBE"), "method missing: {debug}");
177        assert!(debug.contains("rtsp://cam/live"), "uri missing: {debug}");
178        assert!(
179            debug.contains("Authorization"),
180            "header name should still be shown: {debug}"
181        );
182        assert!(
183            debug.contains("X-Forwarded-User") && debug.contains("alice"),
184            "non-secret header should render normally: {debug}"
185        );
186    }
187
188    // Same regression, case-insensitively, for the proxy variant (RFC 7235
189    // §4.4) and for `Proxy-Authorization` sent in a non-canonical case.
190    #[test]
191    fn debug_redacts_proxy_authorization_header_case_insensitively() {
192        let headers: &[(&str, &str)] = &[("proxy-AUTHORIZATION", "Basic c2VjcmV0LXBhc3N3b3Jk")];
193        let ctx = RequestContext::new("GET", "/x").with_headers(headers);
194        let debug = format!("{ctx:?}");
195        assert!(
196            !debug.contains("c2VjcmV0LXBhc3N3b3Jk"),
197            "leaked base64 secret: {debug}"
198        );
199        assert!(
200            debug.contains("<redacted>"),
201            "expected redaction marker: {debug}"
202        );
203    }
204}