Skip to main content

openmoat_proxy/
request.rs

1//! The request head a client sends the proxy: `CONNECT host:port` or one
2//! absolute-form plain-HTTP request (`GET http://host/path HTTP/1.1`).
3//!
4//! Hosts come out in the form openmoat-core's net rules match: lowercase, without
5//! a trailing dot or IPv6 brackets, IP literals in their canonical spelling.
6
7use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
8
9use openmoat_core::{MAX_DNS_LABEL_LEN, MAX_DNS_NAME_LEN};
10use zeroize::Zeroizing;
11
12pub(crate) mod origin;
13
14/// Most headers one request head may carry.
15pub const MAX_HEADERS: usize = 64;
16
17/// Headers that describe one connection, not the request (RFC 9110 §7.6.1),
18/// plus the proxy credentials no upstream should see. `Transfer-Encoding` is
19/// kept: the body is relayed byte for byte, so its framing must arrive intact.
20const HOP_BY_HOP: &[&str] = &[
21    "connection",
22    "keep-alive",
23    "proxy-connection",
24    "proxy-authenticate",
25    "proxy-authorization",
26    "te",
27    "trailer",
28    "upgrade",
29];
30
31/// What the client asked the proxy to reach.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Target {
34    /// `CONNECT host:port`: a tunnel, checked by the TLS SNI it then carries.
35    Connect {
36        /// Normalised host.
37        host: String,
38        /// Port; never 0.
39        port: u16,
40    },
41    /// One plain-HTTP request, rewritten for the upstream.
42    Http {
43        /// Normalised host from the absolute-form target.
44        host: String,
45        /// Port; 80 when the target names none.
46        port: u16,
47        /// The head to send upstream: origin-form request line, hop-by-hop
48        /// headers removed, `Host` present and `Connection: close` added.
49        head: Vec<u8>,
50    },
51}
52
53/// One `Proxy-Authorization` value (ADR-027): zeroed when dropped, never
54/// printed, and never part of the head forwarded upstream.
55#[derive(Clone, PartialEq, Eq)]
56pub struct ProxyCredential(Zeroizing<Vec<u8>>);
57
58impl std::fmt::Debug for ProxyCredential {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.write_str("ProxyCredential(..)")
61    }
62}
63
64impl AsRef<[u8]> for ProxyCredential {
65    fn as_ref(&self) -> &[u8] {
66        &self.0
67    }
68}
69
70/// A parsed request head.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Request {
73    /// The method, as sent (`CONNECT`, `GET`, …).
74    pub method: String,
75    /// Where the request goes.
76    pub target: Target,
77    /// Bytes of the input the head occupied; what follows is body or tunnel data.
78    pub head_len: usize,
79    /// Every `Proxy-Authorization` header the head carried, in order.
80    pub credentials: Vec<ProxyCredential>,
81}
82
83impl Request {
84    /// The target host.
85    #[must_use]
86    pub fn host(&self) -> &str {
87        match &self.target {
88            Target::Connect { host, .. } | Target::Http { host, .. } => host,
89        }
90    }
91
92    /// The target port.
93    #[must_use]
94    pub fn port(&self) -> u16 {
95        match &self.target {
96            Target::Connect { port, .. } | Target::Http { port, .. } => *port,
97        }
98    }
99}
100
101/// Why a request head is refused.
102#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
103#[non_exhaustive]
104pub enum RequestError {
105    /// Not HTTP/1.x, or a syntax error `httparse` reports.
106    #[error("malformed request: {0}")]
107    Malformed(String),
108    /// A well-formed request the proxy does not serve.
109    #[error("{0}")]
110    Unsupported(&'static str),
111}
112
113/// Parse one request head from the start of `buf`. `Ok(None)` means the head is
114/// not complete yet; the caller bounds how much it reads.
115pub fn parse(buf: &[u8]) -> Result<Option<Request>, RequestError> {
116    let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS];
117    let mut req = httparse::Request::new(&mut headers);
118    let head_len = match req.parse(buf) {
119        Ok(httparse::Status::Complete(n)) => n,
120        Ok(httparse::Status::Partial) => return Ok(None),
121        Err(e) => return Err(RequestError::Malformed(e.to_string())),
122    };
123    let (Some(method), Some(raw_target), Some(version)) = (req.method, req.path, req.version)
124    else {
125        return Err(RequestError::Malformed(
126            "incomplete request line".to_owned(),
127        ));
128    };
129    let target = if method == "CONNECT" {
130        let (host, port) = authority(raw_target, None)?;
131        Target::Connect { host, port }
132    } else {
133        http_target(method, raw_target, version, req.headers)?
134    };
135    let credentials = req
136        .headers
137        .iter()
138        .filter(|h| h.name.eq_ignore_ascii_case("proxy-authorization"))
139        .map(|h| ProxyCredential(Zeroizing::new(h.value.to_vec())))
140        .collect();
141    Ok(Some(Request {
142        method: method.to_owned(),
143        target,
144        head_len,
145        credentials,
146    }))
147}
148
149fn http_target(
150    method: &str,
151    raw_target: &str,
152    version: u8,
153    headers: &[httparse::Header<'_>],
154) -> Result<Target, RequestError> {
155    let Some((scheme, rest)) = raw_target.split_once("://") else {
156        return Err(RequestError::Unsupported(
157            "only absolute-form requests (http://host/path) are proxied",
158        ));
159    };
160    if !scheme.eq_ignore_ascii_case("http") {
161        return Err(RequestError::Unsupported(
162            "only http:// is forwarded; use CONNECT for https",
163        ));
164    }
165    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
166    let (raw_authority, path) = rest.split_at(end);
167    let path = path.split('#').next().unwrap_or_default();
168    let (host, port) = authority(raw_authority, Some(80))?;
169    check_framing(version, headers)?;
170
171    let mut connection_tokens: Vec<String> = Vec::new();
172    for h in headers
173        .iter()
174        .filter(|h| h.name.eq_ignore_ascii_case("connection"))
175    {
176        let value = String::from_utf8_lossy(h.value);
177        connection_tokens.extend(value.split(',').map(|t| t.trim().to_ascii_lowercase()));
178    }
179    let slash = if path.starts_with('/') { "" } else { "/" };
180    let mut head = format!("{method} {slash}{path} HTTP/1.{version}\r\n").into_bytes();
181    let mut has_host = false;
182    for h in headers {
183        let name = h.name.to_ascii_lowercase();
184        if HOP_BY_HOP.contains(&name.as_str()) || connection_tokens.contains(&name) {
185            continue;
186        }
187        if name == "host" {
188            if has_host || host_header(h.value) != Some((host.clone(), port)) {
189                return Err(RequestError::Unsupported(
190                    "Host header does not match the request target",
191                ));
192            }
193            has_host = true;
194        }
195        head.extend_from_slice(h.name.as_bytes());
196        head.extend_from_slice(b": ");
197        head.extend_from_slice(h.value);
198        head.extend_from_slice(b"\r\n");
199    }
200    if !has_host {
201        head.extend_from_slice(format!("Host: {raw_authority}\r\n").as_bytes());
202    }
203    head.extend_from_slice(b"Connection: close\r\n\r\n");
204    Ok(Target::Http { host, port, head })
205}
206
207/// Refuse the framing ambiguities request smuggling relies on: both
208/// `Content-Length` and `Transfer-Encoding`, repeated or list-form length,
209/// or a `Transfer-Encoding` whose tokens are anything but a single `chunked`.
210/// HTTP/1.0 has no transfer codings, so a 1.0 request carrying
211/// `Transfer-Encoding` is malformed (RFC 9112 §6.1): a server may read its
212/// body by another rule. So is a length past [`MAX_SIZE`](crate::body::MAX_SIZE),
213/// and a framing header spelled with `_`, which servers that pass headers on
214/// as CGI-style variables read as the header itself.
215fn check_framing(version: u8, headers: &[httparse::Header<'_>]) -> Result<(), RequestError> {
216    const AMBIGUOUS: RequestError = RequestError::Unsupported(
217        "ambiguous body framing (Content-Length with Transfer-Encoding, repeated, \
218         list-form or too large a length, a transfer coding other than chunked, or a \
219         framing header spelled with `_`)",
220    );
221    let mut lengths: Vec<&[u8]> = Vec::new();
222    let mut encodings: Vec<&[u8]> = Vec::new();
223    for h in headers {
224        let dashed = h.name.replace('_', "-");
225        if h.name.contains('_')
226            && (dashed.eq_ignore_ascii_case("content-length")
227                || dashed.eq_ignore_ascii_case("transfer-encoding"))
228        {
229            return Err(AMBIGUOUS);
230        }
231        if h.name.eq_ignore_ascii_case("content-length") {
232            lengths.push(h.value);
233        } else if h.name.eq_ignore_ascii_case("transfer-encoding") {
234            encodings.push(h.value);
235        }
236    }
237    if version == 0 && !encodings.is_empty() {
238        return Err(RequestError::Malformed(
239            "Transfer-Encoding in an HTTP/1.0 request".to_owned(),
240        ));
241    }
242    if lengths.len() > 1 || (!lengths.is_empty() && !encodings.is_empty()) {
243        return Err(AMBIGUOUS);
244    }
245    if encodings.len() > 1 {
246        return Err(AMBIGUOUS);
247    }
248    if let Some(bytes) = encodings.first() {
249        let value = std::str::from_utf8(bytes).map_err(|_| AMBIGUOUS)?;
250        let tokens: Vec<String> = value
251            .split(',')
252            .map(|t| t.trim().to_ascii_lowercase())
253            .collect();
254        if tokens.as_slice() != ["chunked"] {
255            return Err(AMBIGUOUS);
256        }
257    }
258    if let Some(bytes) = lengths.first() {
259        let value = std::str::from_utf8(bytes).map_err(|_| AMBIGUOUS)?;
260        let trimmed = value.trim();
261        // RFC 9112 §8.6: a `Content-Length` is one or more decimal digits.
262        // The explicit all-ASCII-digit check rejects `+10`, `-10`, embedded
263        // whitespace (`10 10`), hex (`0x0a`) and the list form (`10, 10`);
264        // the number is then held to the largest chunk size the body follows.
265        let digits_only = !trimmed.is_empty() && trimmed.bytes().all(|b| b.is_ascii_digit());
266        let in_range = trimmed
267            .parse::<u64>()
268            .is_ok_and(|n| n <= crate::body::MAX_SIZE);
269        if !digits_only || !in_range {
270            return Err(AMBIGUOUS);
271        }
272    }
273    Ok(())
274}
275
276fn host_header(value: &[u8]) -> Option<(String, u16)> {
277    authority(std::str::from_utf8(value).ok()?.trim(), Some(80)).ok()
278}
279
280/// `host[:port]` or `[v6][:port]`, with no userinfo. `default_port` is `None`
281/// when a port is required (CONNECT).
282fn authority(raw: &str, default_port: Option<u16>) -> Result<(String, u16), RequestError> {
283    const BAD: RequestError = RequestError::Unsupported("invalid host or port in request target");
284    if raw.contains('@') {
285        return Err(RequestError::Unsupported(
286            "credentials in the request target are refused",
287        ));
288    }
289    let (host, port) = if let Some(rest) = raw.strip_prefix('[') {
290        let (v6, after) = rest.split_once(']').ok_or(BAD)?;
291        let ip: Ipv6Addr = v6.parse().map_err(|_| BAD)?;
292        let port = match after {
293            "" => None,
294            p => Some(p.strip_prefix(':').ok_or(BAD)?),
295        };
296        (ip.to_string(), port)
297    } else {
298        let (host, port) = match raw.split_once(':') {
299            Some((h, p)) => (h, Some(p)),
300            None => (raw, None),
301        };
302        (normalise_name(host).ok_or(BAD)?, port)
303    };
304    // A port is digits only (RFC 3986 §3.2.3); `u16::from_str` also takes a
305    // leading `+`, which a server reading the forwarded `Host` may not.
306    let port = match (port, default_port) {
307        (Some(p), _) => Some(p)
308            .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
309            .and_then(|p| p.parse::<u16>().ok())
310            .filter(|p| *p != 0)
311            .ok_or(BAD)?,
312        (None, Some(d)) => d,
313        (None, None) => return Err(BAD),
314    };
315    Ok((host, port))
316}
317
318/// A DNS name or IPv4 literal in canonical form; `None` when it is neither.
319pub(crate) fn normalise_name(raw: &str) -> Option<String> {
320    let name = raw.strip_suffix('.').unwrap_or(raw).to_ascii_lowercase();
321    if let Ok(ip) = name.parse::<Ipv4Addr>() {
322        return Some(IpAddr::V4(ip).to_string());
323    }
324    let valid = !name.is_empty()
325        && name.len() <= MAX_DNS_NAME_LEN
326        && name.split('.').all(|label| {
327            !label.is_empty()
328                && label.len() <= MAX_DNS_LABEL_LEN
329                && label
330                    .bytes()
331                    .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
332        });
333    // No top-level domain is a number. A name whose last label is digits or
334    // `0x…` is an address in another spelling (`1572394766`, `0x5d.0xb8.0xd7.0x0e`,
335    // `0177.0.0.1`) that the system resolver turns into an IP, past rules that
336    // name the address the usual way.
337    let last = name.rsplit('.').next().unwrap_or_default();
338    let numeric = last.bytes().all(|b| b.is_ascii_digit()) || last.starts_with("0x");
339    (valid && !numeric).then_some(name)
340}
341
342#[cfg(test)]
343mod tests;