1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
8
9use openmoat_core::{MAX_DNS_LABEL_LEN, MAX_DNS_NAME_LEN};
10use zeroize::Zeroizing;
11
12pub const MAX_HEADERS: usize = 64;
14
15const 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#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Target {
32 Connect {
34 host: String,
36 port: u16,
38 },
39 Http {
41 host: String,
43 port: u16,
45 head: Vec<u8>,
48 },
49}
50
51#[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#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Request {
71 pub method: String,
73 pub target: Target,
75 pub head_len: usize,
77 pub credentials: Vec<ProxyCredential>,
79}
80
81impl Request {
82 #[must_use]
84 pub fn host(&self) -> &str {
85 match &self.target {
86 Target::Connect { host, .. } | Target::Http { host, .. } => host,
87 }
88 }
89
90 #[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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
101#[non_exhaustive]
102pub enum RequestError {
103 #[error("malformed request: {0}")]
105 Malformed(String),
106 #[error("{0}")]
108 Unsupported(&'static str),
109}
110
111pub 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
205fn 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 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
278fn 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 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
316pub(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 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;