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}