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