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