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