use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use zeroize::Zeroizing;
pub const MAX_HEADERS: usize = 64;
const HOP_BY_HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"upgrade",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Connect {
host: String,
port: u16,
},
Http {
host: String,
port: u16,
head: Vec<u8>,
},
}
#[derive(Clone, PartialEq, Eq)]
pub struct ProxyCredential(Zeroizing<Vec<u8>>);
impl std::fmt::Debug for ProxyCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ProxyCredential(..)")
}
}
impl AsRef<[u8]> for ProxyCredential {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request {
pub method: String,
pub target: Target,
pub head_len: usize,
pub credentials: Vec<ProxyCredential>,
}
impl Request {
#[must_use]
pub fn host(&self) -> &str {
match &self.target {
Target::Connect { host, .. } | Target::Http { host, .. } => host,
}
}
#[must_use]
pub fn port(&self) -> u16 {
match &self.target {
Target::Connect { port, .. } | Target::Http { port, .. } => *port,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RequestError {
#[error("malformed request: {0}")]
Malformed(String),
#[error("{0}")]
Unsupported(&'static str),
}
pub fn parse(buf: &[u8]) -> Result<Option<Request>, RequestError> {
let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS];
let mut req = httparse::Request::new(&mut headers);
let head_len = match req.parse(buf) {
Ok(httparse::Status::Complete(n)) => n,
Ok(httparse::Status::Partial) => return Ok(None),
Err(e) => return Err(RequestError::Malformed(e.to_string())),
};
let (Some(method), Some(raw_target), Some(version)) = (req.method, req.path, req.version)
else {
return Err(RequestError::Malformed(
"incomplete request line".to_owned(),
));
};
let target = if method == "CONNECT" {
let (host, port) = authority(raw_target, None)?;
Target::Connect { host, port }
} else {
http_target(method, raw_target, version, req.headers)?
};
let credentials = req
.headers
.iter()
.filter(|h| h.name.eq_ignore_ascii_case("proxy-authorization"))
.map(|h| ProxyCredential(Zeroizing::new(h.value.to_vec())))
.collect();
Ok(Some(Request {
method: method.to_owned(),
target,
head_len,
credentials,
}))
}
fn http_target(
method: &str,
raw_target: &str,
version: u8,
headers: &[httparse::Header<'_>],
) -> Result<Target, RequestError> {
let Some((scheme, rest)) = raw_target.split_once("://") else {
return Err(RequestError::Unsupported(
"only absolute-form requests (http://host/path) are proxied",
));
};
if !scheme.eq_ignore_ascii_case("http") {
return Err(RequestError::Unsupported(
"only http:// is forwarded; use CONNECT for https",
));
}
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let (raw_authority, path) = rest.split_at(end);
let path = path.split('#').next().unwrap_or_default();
let (host, port) = authority(raw_authority, Some(80))?;
check_framing(version, headers)?;
let mut connection_tokens: Vec<String> = Vec::new();
for h in headers
.iter()
.filter(|h| h.name.eq_ignore_ascii_case("connection"))
{
let value = String::from_utf8_lossy(h.value);
connection_tokens.extend(value.split(',').map(|t| t.trim().to_ascii_lowercase()));
}
let slash = if path.starts_with('/') { "" } else { "/" };
let mut head = format!("{method} {slash}{path} HTTP/1.{version}\r\n").into_bytes();
let mut has_host = false;
for h in headers {
let name = h.name.to_ascii_lowercase();
if HOP_BY_HOP.contains(&name.as_str()) || connection_tokens.contains(&name) {
continue;
}
if name == "host" {
if has_host || host_header(h.value) != Some((host.clone(), port)) {
return Err(RequestError::Unsupported(
"Host header does not match the request target",
));
}
has_host = true;
}
head.extend_from_slice(h.name.as_bytes());
head.extend_from_slice(b": ");
head.extend_from_slice(h.value);
head.extend_from_slice(b"\r\n");
}
if !has_host {
head.extend_from_slice(format!("Host: {raw_authority}\r\n").as_bytes());
}
head.extend_from_slice(b"Connection: close\r\n\r\n");
Ok(Target::Http { host, port, head })
}
fn check_framing(version: u8, headers: &[httparse::Header<'_>]) -> Result<(), RequestError> {
const AMBIGUOUS: RequestError = RequestError::Unsupported(
"ambiguous body framing (Content-Length with Transfer-Encoding, repeated, \
list-form length, or a transfer coding other than chunked)",
);
let mut lengths: Vec<&[u8]> = Vec::new();
let mut encodings: Vec<&[u8]> = Vec::new();
for h in headers {
if h.name.eq_ignore_ascii_case("content-length") {
lengths.push(h.value);
} else if h.name.eq_ignore_ascii_case("transfer-encoding") {
encodings.push(h.value);
}
}
if version == 0 && !encodings.is_empty() {
return Err(RequestError::Malformed(
"Transfer-Encoding in an HTTP/1.0 request".to_owned(),
));
}
if lengths.len() > 1 || (!lengths.is_empty() && !encodings.is_empty()) {
return Err(AMBIGUOUS);
}
if encodings.len() > 1 {
return Err(AMBIGUOUS);
}
if let Some(bytes) = encodings.first() {
let value = std::str::from_utf8(bytes).map_err(|_| AMBIGUOUS)?;
let tokens: Vec<String> = value
.split(',')
.map(|t| t.trim().to_ascii_lowercase())
.collect();
if tokens.as_slice() != ["chunked"] {
return Err(AMBIGUOUS);
}
}
if let Some(bytes) = lengths.first() {
let value = std::str::from_utf8(bytes).map_err(|_| AMBIGUOUS)?;
let trimmed = value.trim();
let digits_only = !trimmed.is_empty() && trimmed.bytes().all(|b| b.is_ascii_digit());
if !digits_only || trimmed.parse::<u64>().is_err() {
return Err(AMBIGUOUS);
}
}
Ok(())
}
fn host_header(value: &[u8]) -> Option<(String, u16)> {
authority(std::str::from_utf8(value).ok()?.trim(), Some(80)).ok()
}
fn authority(raw: &str, default_port: Option<u16>) -> Result<(String, u16), RequestError> {
const BAD: RequestError = RequestError::Unsupported("invalid host or port in request target");
if raw.contains('@') {
return Err(RequestError::Unsupported(
"credentials in the request target are refused",
));
}
let (host, port) = if let Some(rest) = raw.strip_prefix('[') {
let (v6, after) = rest.split_once(']').ok_or(BAD)?;
let ip: Ipv6Addr = v6.parse().map_err(|_| BAD)?;
let port = match after {
"" => None,
p => Some(p.strip_prefix(':').ok_or(BAD)?),
};
(ip.to_string(), port)
} else {
let (host, port) = match raw.split_once(':') {
Some((h, p)) => (h, Some(p)),
None => (raw, None),
};
(normalise_name(host).ok_or(BAD)?, port)
};
let port = match (port, default_port) {
(Some(p), _) => p.parse::<u16>().ok().filter(|p| *p != 0).ok_or(BAD)?,
(None, Some(d)) => d,
(None, None) => return Err(BAD),
};
Ok((host, port))
}
pub(crate) fn normalise_name(raw: &str) -> Option<String> {
let name = raw.strip_suffix('.').unwrap_or(raw).to_ascii_lowercase();
if let Ok(ip) = name.parse::<Ipv4Addr>() {
return Some(IpAddr::V4(ip).to_string());
}
let valid = !name.is_empty()
&& name.len() <= 253
&& name.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
});
let last = name.rsplit('.').next().unwrap_or_default();
let numeric = last.bytes().all(|b| b.is_ascii_digit()) || last.starts_with("0x");
(valid && !numeric).then_some(name)
}
#[cfg(test)]
mod tests;