Skip to main content

http_extract/
forwarded.rs

1//! Strict extraction of untrusted client IP assertions from `Forwarded`.
2//!
3//! [`extract_header_forwarded_for`] implements the crate's deliberately narrow
4//! use of the standardized field: every field element must contain a usable
5//! `for=` IP address, and the resulting chain remains untrusted. Effective
6//! A feature-gated [`crate::extract_client_ip`] convenience can select from
7//! this and other fields, but that selection does not establish trust.
8//!
9//! The field grammar is defined by [RFC 7239, Section 4], the `for` parameter
10//! by [RFC 7239, Section 5.2], node identifiers by [RFC 7239, Section 6], and
11//! the trust limitations by [RFC 7239, Section 8]. This crate implements only
12//! the subset needed to produce a continuous IP chain, not the full RFC object
13//! model.
14//!
15//! [RFC 7239, Section 4]: https://www.rfc-editor.org/rfc/rfc7239.html#section-4
16//! [RFC 7239, Section 5.2]: https://www.rfc-editor.org/rfc/rfc7239.html#section-5.2
17//! [RFC 7239, Section 6]: https://www.rfc-editor.org/rfc/rfc7239.html#section-6
18//! [RFC 7239, Section 8]: https://www.rfc-editor.org/rfc/rfc7239.html#section-8
19
20use std::net::IpAddr;
21
22use http::{HeaderMap, HeaderName, Request};
23
24use crate::{Error, header::extract_single_header_text};
25
26/// The standardized `Forwarded` field name from [RFC 7239, Section 4].
27///
28/// [RFC 7239, Section 4]: https://www.rfc-editor.org/rfc/rfc7239.html#section-4
29pub const FORWARDED: HeaderName = HeaderName::from_static("forwarded");
30
31/// Extract RFC 7239 `Forwarded` `for=` values as an untrusted IP chain.
32///
33/// Addresses are returned in wire order, from the remotest assertion to the
34/// one nearest the server. A missing field returns `None`; an empty field is
35/// invalid. Parsing does not make any address trustworthy. This crate's strict
36/// profile requires exactly one field line and exactly one usable IP `for=`
37/// parameter in every element. Duplicate or non-text field lines, ambiguous
38/// element boundaries, missing or duplicate `for` parameters, and unknown,
39/// obfuscated, or non-IP `for` nodes return an error. They are never skipped
40/// because doing so would change the chain's meaning. Parameters other than
41/// `for` are ignored without validating their names or values. Establish an
42/// out-of-band proxy trust policy before using the chain for a security
43/// decision.
44pub fn extract_header_forwarded_for(headers: &HeaderMap) -> Result<Option<Vec<IpAddr>>, Error> {
45    extract_single_header_text(headers, &FORWARDED)?
46        .map(parse_forwarded_for)
47        .transpose()
48}
49
50/// Extract the untrusted `Forwarded` `for=` IP chain from a complete request.
51///
52/// This reads `request.headers()` and delegates to
53/// [`extract_header_forwarded_for`], preserving its missing and strict error
54/// behavior. It does not establish trust in the returned assertions.
55pub fn extract_request_forwarded_for<B>(
56    request: &Request<B>,
57) -> Result<Option<Vec<IpAddr>>, Error> {
58    extract_header_forwarded_for(request.headers())
59}
60
61/// Extract the rightmost `Forwarded` `for=` IP address from a header.
62///
63/// This reads `headers` and delegates to
64/// [`extract_header_forwarded_for`], preserving its missing and strict error
65/// behavior. It does not establish trust in the returned assertion.
66pub fn extract_rightmost_forwarded(headers: &HeaderMap) -> Result<Option<IpAddr>, Error> {
67    Ok(extract_header_forwarded_for(headers)?.and_then(|ips| ips.last().copied()))
68}
69
70fn parse_forwarded_for(value: &str) -> Result<Vec<IpAddr>, Error> {
71    split_quoted(value, ',')?
72        .into_iter()
73        .map(parse_forwarded_element_for)
74        .collect()
75}
76
77fn parse_forwarded_element_for(element: &str) -> Result<IpAddr, Error> {
78    if trim_ows(element).is_empty() {
79        return Err(invalid());
80    }
81
82    let mut forwarded_for = None;
83    for parameter in split_quoted(element, ';')? {
84        let parameter = trim_ows(parameter);
85        if parameter.is_empty() {
86            return Err(invalid());
87        }
88        let Some((name, value)) = parameter.split_once('=') else {
89            continue;
90        };
91        if !trim_ows(name).eq_ignore_ascii_case("for") {
92            continue;
93        }
94        if forwarded_for.is_some() {
95            return Err(invalid());
96        }
97
98        let (value, quoted) = parse_parameter_value(value)?;
99        forwarded_for = Some(parse_forwarded_node(&value, quoted)?);
100    }
101
102    forwarded_for.ok_or_else(invalid)
103}
104
105fn parse_forwarded_node(value: &str, quoted: bool) -> Result<IpAddr, Error> {
106    if !quoted {
107        return value
108            .parse::<std::net::Ipv4Addr>()
109            .map(IpAddr::V4)
110            .map_err(|_| invalid());
111    }
112
113    if let Some(rest) = value.strip_prefix('[') {
114        let (address, suffix) = rest.split_once(']').ok_or_else(invalid)?;
115        if !suffix.is_empty() {
116            let port = suffix.strip_prefix(':').ok_or_else(invalid)?;
117            validate_node_port(port)?;
118        }
119        return address
120            .parse::<std::net::Ipv6Addr>()
121            .map(IpAddr::V6)
122            .map_err(|_| invalid());
123    }
124
125    let (address, port) = value
126        .split_once(':')
127        .map_or((value, None), |(address, port)| (address, Some(port)));
128    if let Some(port) = port {
129        validate_node_port(port)?;
130    }
131    address
132        .parse::<std::net::Ipv4Addr>()
133        .map(IpAddr::V4)
134        .map_err(|_| invalid())
135}
136
137fn validate_node_port(value: &str) -> Result<(), Error> {
138    let numeric =
139        !value.is_empty() && value.len() <= 5 && value.bytes().all(|byte| byte.is_ascii_digit());
140    let obfuscated =
141        value.len() > 1 && value.starts_with('_') && value.bytes().all(is_obfuscated_byte);
142    if numeric || obfuscated {
143        Ok(())
144    } else {
145        Err(invalid())
146    }
147}
148
149fn split_quoted(value: &str, delimiter: char) -> Result<Vec<&str>, Error> {
150    let mut output = Vec::new();
151    let mut quoted = false;
152    let mut escaped = false;
153    let mut start = 0;
154
155    for (index, character) in value.char_indices() {
156        if escaped {
157            escaped = false;
158        } else if quoted && character == '\\' {
159            escaped = true;
160        } else if character == '"' {
161            quoted = !quoted;
162        } else if !quoted && character == delimiter {
163            output.push(&value[start..index]);
164            start = index + character.len_utf8();
165        }
166    }
167    if quoted || escaped {
168        return Err(invalid());
169    }
170    output.push(&value[start..]);
171    Ok(output)
172}
173
174fn parse_parameter_value(value: &str) -> Result<(String, bool), Error> {
175    let value = trim_ows(value);
176    if !value.starts_with('"') {
177        if value.is_empty() || !value.bytes().all(is_token_byte) {
178            return Err(invalid());
179        }
180        return Ok((value.to_owned(), false));
181    }
182    if value.len() < 2 || !value.ends_with('"') {
183        return Err(invalid());
184    }
185
186    let mut output = String::with_capacity(value.len() - 2);
187    let mut escaped = false;
188    for character in value[1..value.len() - 1].chars() {
189        if escaped {
190            if !is_quoted_pair_character(character) {
191                return Err(invalid());
192            }
193            output.push(character);
194            escaped = false;
195        } else if character == '\\' {
196            escaped = true;
197        } else if is_quoted_text_character(character) {
198            output.push(character);
199        } else {
200            return Err(invalid());
201        }
202    }
203    if escaped {
204        return Err(invalid());
205    }
206    Ok((output, true))
207}
208
209fn trim_ows(value: &str) -> &str {
210    value.trim_matches([' ', '\t'])
211}
212
213const fn is_token_byte(byte: u8) -> bool {
214    byte.is_ascii_alphanumeric()
215        || matches!(
216            byte,
217            b'!' | b'#'
218                | b'$'
219                | b'%'
220                | b'&'
221                | b'\''
222                | b'*'
223                | b'+'
224                | b'-'
225                | b'.'
226                | b'^'
227                | b'_'
228                | b'`'
229                | b'|'
230                | b'~'
231        )
232}
233
234const fn is_obfuscated_byte(byte: u8) -> bool {
235    byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')
236}
237
238const fn is_quoted_text_character(character: char) -> bool {
239    matches!(character, '\t' | ' ' | '!' | '#'..='[' | ']'..='~')
240}
241
242const fn is_quoted_pair_character(character: char) -> bool {
243    matches!(character, '\t' | ' '..='~')
244}
245
246const fn invalid() -> Error {
247    Error::invalid_header(FORWARDED)
248}
249
250#[cfg(test)]
251mod tests {
252    use std::net::IpAddr;
253
254    use http::{HeaderMap, HeaderValue};
255
256    use super::*;
257
258    #[test]
259    fn returns_none_when_forwarded_is_absent() {
260        assert_eq!(
261            extract_header_forwarded_for(&HeaderMap::new()).unwrap(),
262            None
263        );
264    }
265
266    #[test]
267    fn extracts_every_for_ip_in_wire_order() {
268        let mut headers = HeaderMap::new();
269        headers.insert(
270            &FORWARDED,
271            "for=192.0.2.60;proto=https, For=\"[2001:db8:cafe::17]:4711\";by=_edge, for=\"198.51.100.4:_port\";ext=\"a,b;c\""
272                .parse()
273                .unwrap(),
274        );
275
276        assert_eq!(
277            extract_header_forwarded_for(&headers).unwrap().unwrap(),
278            vec![
279                "192.0.2.60".parse::<IpAddr>().unwrap(),
280                "2001:db8:cafe::17".parse::<IpAddr>().unwrap(),
281                "198.51.100.4".parse::<IpAddr>().unwrap(),
282            ]
283        );
284    }
285
286    #[test]
287    fn extracts_for_ip_without_parsing_other_parameters() {
288        let mut headers = HeaderMap::new();
289        headers.insert(
290            &FORWARDED,
291            "for=52.159.243.17;host=example.vercel.app;proto=https;sig=c2lnbmF0dXJlCg==;exp=1786417338"
292                .parse()
293                .unwrap(),
294        );
295
296        assert_eq!(
297            extract_header_forwarded_for(&headers).unwrap().unwrap(),
298            vec!["52.159.243.17".parse::<IpAddr>().unwrap()]
299        );
300    }
301
302    #[test]
303    fn ignores_malformed_parameters_other_than_for() {
304        for parameter in ["broken", "=value", "bad name=value", "proto="] {
305            let mut headers = HeaderMap::new();
306            headers.insert(
307                &FORWARDED,
308                format!("for=192.0.2.1;{parameter}").parse().unwrap(),
309            );
310
311            assert_eq!(
312                extract_header_forwarded_for(&headers).unwrap().unwrap(),
313                vec!["192.0.2.1".parse::<IpAddr>().unwrap()],
314                "unexpectedly rejected {parameter:?}"
315            );
316        }
317    }
318
319    #[test]
320    fn request_entry_point_delegates_to_headers() {
321        let request = Request::builder()
322            .header(&FORWARDED, "for=192.0.2.1")
323            .body(())
324            .unwrap();
325        assert_eq!(
326            extract_request_forwarded_for(&request).unwrap(),
327            Some(vec!["192.0.2.1".parse().unwrap()])
328        );
329    }
330
331    #[test]
332    fn rejects_a_duplicate_or_non_text_field() {
333        let mut headers = HeaderMap::new();
334        headers.append(&FORWARDED, "for=192.0.2.1".parse().unwrap());
335        headers.append(&FORWARDED, "for=198.51.100.2".parse().unwrap());
336        assert!(matches!(
337            extract_header_forwarded_for(&headers),
338            Err(Error::DuplicateHeader { .. })
339        ));
340
341        headers.clear();
342        headers.insert(&FORWARDED, HeaderValue::from_bytes(&[0xff]).unwrap());
343        assert!(matches!(
344            extract_header_forwarded_for(&headers),
345            Err(Error::InvalidHeader { .. })
346        ));
347    }
348
349    #[test]
350    fn rejects_any_element_that_cannot_extend_a_continuous_ip_chain() {
351        for value in [
352            "",
353            "for=192.0.2.1,",
354            "for=192.0.2.1,,for=198.51.100.2",
355            "proto=https",
356            "for=unknown",
357            "for=_hidden",
358            "for=example.com",
359            "for=192.0.2.1:443",
360            "for=\"[not-an-ip]\"",
361            "for=192.0.2.1;for=198.51.100.2",
362            "for=192.0.2.1;proto=\"unterminated",
363        ] {
364            let mut headers = HeaderMap::new();
365            headers.insert(&FORWARDED, value.parse().unwrap());
366            assert!(
367                matches!(
368                    extract_header_forwarded_for(&headers),
369                    Err(Error::InvalidHeader { .. })
370                ),
371                "unexpectedly accepted {value:?}"
372            );
373        }
374    }
375}