use std::io::{self, Read, Write};
use std::net::{IpAddr, SocketAddr, TcpStream};
use std::time::Instant;
const MAX_HEAD: usize = 16 * 1024;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct Head {
pub method: String,
pub path: String,
pub query: Vec<(String, String)>,
pub headers: Vec<(String, String)>,
}
impl Head {
pub fn parse(bytes: &[u8]) -> Option<Head> {
let mut headers = [httparse::EMPTY_HEADER; 64];
let mut request = httparse::Request::new(&mut headers);
match request.parse(bytes) {
Ok(httparse::Status::Complete(_)) => {}
_ => return None,
}
let target = request.path?;
let (path, query) = target.split_once('?').unwrap_or((target, ""));
let query = query
.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| {
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
(name.to_string(), value.to_string())
})
.collect();
let headers = request
.headers
.iter()
.map(|h| {
(
h.name.to_ascii_lowercase(),
String::from_utf8_lossy(h.value).trim().to_string(),
)
})
.collect();
Some(Head {
method: request.method?.to_string(),
path: path.to_string(),
query,
headers,
})
}
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v.as_str())
}
pub fn param(&self, name: &str) -> Option<&str> {
self.query
.iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v.as_str())
}
}
pub(crate) fn read_head(stream: &mut impl Read) -> io::Result<Vec<u8>> {
let mut head = Vec::with_capacity(1024);
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if head.len() >= MAX_HEAD {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"request head too long",
));
}
match stream.read(&mut byte)? {
0 => return Err(io::ErrorKind::UnexpectedEof.into()),
_ => head.push(byte[0]),
}
}
Ok(head)
}
pub(crate) struct Deadline<'a> {
stream: &'a TcpStream,
until: Instant,
}
impl<'a> Deadline<'a> {
pub fn new(stream: &'a TcpStream, until: Instant) -> Deadline<'a> {
Deadline { stream, until }
}
}
impl Read for Deadline<'_> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let left = self.until.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::Error::new(io::ErrorKind::TimedOut, "too slow"));
}
self.stream.set_read_timeout(Some(left))?;
let mut stream = self.stream;
stream.read(buf)
}
}
pub(crate) fn same_token(given: &str, expected: &str) -> bool {
let (a, b) = (given.as_bytes(), expected.as_bytes());
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
pub(crate) fn allowed_hosts(local: SocketAddr) -> Vec<String> {
let ip = local.ip();
let names = if ip.is_unspecified() {
let mut names = vec![
"127.0.0.1".to_string(),
"[::1]".to_string(),
"localhost".to_string(),
];
names.extend(machine_names());
names
} else {
let mut names = vec![ip_host(ip)];
if ip.is_loopback() {
names.push("localhost".into());
}
names
};
with_port(&names, local.port())
}
fn with_port(names: &[String], port: u16) -> Vec<String> {
let mut hosts: Vec<String> = names.iter().map(|name| format!("{name}:{port}")).collect();
if port == 80 {
hosts.extend(names.iter().cloned());
}
hosts
}
fn ip_host(ip: IpAddr) -> String {
match ip {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => v4.to_string(),
None => format!("[{v6}]"),
},
}
}
pub(crate) fn connection_hosts(local: SocketAddr) -> Vec<String> {
with_port(&[ip_host(local.ip())], local.port())
}
pub(crate) fn host_entries(given: &str, port: u16) -> Vec<String> {
let given = given.trim().to_ascii_lowercase();
let has_port = match given.rsplit_once(':') {
Some((head, tail)) => {
!tail.is_empty()
&& tail.bytes().all(|b| b.is_ascii_digit())
&& (!head.contains(':') || head.ends_with(']'))
}
None => false,
};
if has_port {
vec![given]
} else {
with_port(&[given], port)
}
}
pub(crate) fn machine_names() -> Vec<String> {
#[cfg(unix)]
let name = rustix::system::uname()
.nodename()
.to_string_lossy()
.into_owned();
#[cfg(windows)]
let name = std::env::var("COMPUTERNAME").unwrap_or_default();
#[cfg(not(any(unix, windows)))]
let name = String::new();
let name = name.trim().trim_end_matches('.').to_ascii_lowercase();
if name.is_empty()
|| name == "localhost"
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
{
return Vec::new();
}
let mut names = vec![name.clone()];
if !name.contains('.') {
names.push(format!("{name}.local"));
}
names
}
pub(crate) fn origin_allowed(
origin: Option<&str>,
host: Option<&str>,
hosts: &[String],
extra: &[String],
) -> bool {
let Some(origin) = origin else {
return false;
};
let origin = origin.trim_end_matches('/');
if extra
.iter()
.any(|o| o.trim_end_matches('/').eq_ignore_ascii_case(origin))
{
return true;
}
let Some(host) = host else {
return false;
};
hosts.iter().any(|h| h.eq_ignore_ascii_case(host))
&& origin
.strip_prefix("http://")
.is_some_and(|o| o.eq_ignore_ascii_case(host))
}
pub(crate) fn websocket_key(head: &Head) -> Option<&str> {
let upgrade = head.header("upgrade")?;
let connection = head.header("connection")?;
let version = head.header("sec-websocket-version")?;
if head.method != "GET"
|| !upgrade.eq_ignore_ascii_case("websocket")
|| !connection
.split(',')
.any(|t| t.trim().eq_ignore_ascii_case("upgrade"))
|| version != "13"
{
return None;
}
head.header("sec-websocket-key").filter(|k| !k.is_empty())
}
pub(crate) fn respond(
stream: &mut impl Write,
status: &str,
content_type: &str,
extra: &[(&str, String)],
body: &[u8],
) -> io::Result<()> {
let mut head = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\
Connection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\n\
Referrer-Policy: no-referrer\r\n",
body.len()
);
for (name, value) in extra {
head.push_str(&format!("{name}: {value}\r\n"));
}
head.push_str("\r\n");
stream.write_all(head.as_bytes())?;
stream.write_all(body)?;
stream.flush()
}
pub(crate) fn refuse(stream: &mut impl Write, status: &str, why: &str) -> io::Result<()> {
respond(
stream,
status,
"text/plain; charset=utf-8",
&[],
format!("{why}\n").as_bytes(),
)
}
pub(crate) fn escape_html(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn head(text: &str) -> Head {
Head::parse(text.as_bytes()).expect("a complete head")
}
#[test]
fn parses_a_head() {
let h = head(
"GET /ws?token=abc&cols=80&rows=24 HTTP/1.1\r\nHost: 127.0.0.1:9\r\nOrigin: http://127.0.0.1:9\r\n\r\n",
);
assert_eq!(h.method, "GET");
assert_eq!(h.path, "/ws");
assert_eq!(h.param("token"), Some("abc"));
assert_eq!(h.param("cols"), Some("80"));
assert_eq!(h.param("nope"), None);
assert_eq!(h.header("host"), Some("127.0.0.1:9"));
assert!(Head::parse(b"GET / HTTP/1.1\r\nHost: x\r\n").is_none());
}
#[test]
fn reads_only_the_head() {
let mut input: &[u8] = b"GET / HTTP/1.1\r\nHost: x\r\n\r\nafter";
let got = read_head(&mut input).unwrap();
assert!(got.ends_with(b"\r\n\r\n"));
assert_eq!(input, b"after");
let mut short: &[u8] = b"GET / HTTP/1.1\r\n";
assert!(read_head(&mut short).is_err());
let long = vec![b'a'; MAX_HEAD + 10];
assert!(read_head(&mut long.as_slice()).is_err());
}
#[test]
fn a_head_has_one_deadline_however_slowly_it_comes() {
use std::net::TcpListener;
use std::time::Duration;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let mut stream = TcpStream::connect(addr).unwrap();
for byte in b"GET / HTTP/1.1\r\nHost: x\r\n\r\n" {
if stream.write_all(&[*byte]).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
});
let (stream, _) = listener.accept().unwrap();
let start = Instant::now();
let until = start + Duration::from_millis(500);
let error = read_head(&mut Deadline::new(&stream, until)).unwrap_err();
assert!(
matches!(
error.kind(),
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
),
"{error}"
);
let took = start.elapsed();
assert!(
took >= Duration::from_millis(450) && took < Duration::from_secs(2),
"{took:?}"
);
}
#[test]
fn tokens_compare_exactly() {
assert!(same_token("abc123", "abc123"));
assert!(!same_token("abc124", "abc123"));
assert!(!same_token("abc12", "abc123"));
assert!(!same_token("", "abc123"));
}
#[test]
fn hosts_follow_the_bound_address() {
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
assert_eq!(
allowed_hosts(local),
vec!["127.0.0.1:8080".to_string(), "localhost:8080".into()]
);
let v6: SocketAddr = "[::1]:8080".parse().unwrap();
assert_eq!(
allowed_hosts(v6),
vec!["[::1]:8080".to_string(), "localhost:8080".into()]
);
let lan: SocketAddr = "192.168.1.5:80".parse().unwrap();
assert_eq!(
allowed_hosts(lan),
vec!["192.168.1.5:80".to_string(), "192.168.1.5".into()]
);
let local80: SocketAddr = "127.0.0.1:80".parse().unwrap();
let hosts = allowed_hosts(local80);
assert!(origin_allowed(
Some("http://127.0.0.1"),
Some("127.0.0.1"),
&hosts,
&[]
));
assert!(origin_allowed(
Some("http://localhost"),
Some("localhost"),
&hosts,
&[]
));
}
#[test]
fn every_interface_still_has_a_host_list() {
let all: SocketAddr = "0.0.0.0:8080".parse().unwrap();
let hosts = allowed_hosts(all);
for host in ["127.0.0.1:8080", "[::1]:8080", "localhost:8080"] {
assert!(hosts.iter().any(|h| h == host), "{host}: {hosts:?}");
}
for name in machine_names() {
assert!(hosts.contains(&format!("{name}:8080")), "{hosts:?}");
}
assert!(!origin_allowed(
Some("http://evil.example:8080"),
Some("evil.example:8080"),
&hosts,
&[]
));
let mut with_lan = hosts.clone();
with_lan.extend(connection_hosts("10.0.0.2:8080".parse().unwrap()));
assert!(origin_allowed(
Some("http://10.0.0.2:8080"),
Some("10.0.0.2:8080"),
&with_lan,
&[]
));
assert!(!origin_allowed(
Some("http://evil.example"),
Some("10.0.0.2:8080"),
&with_lan,
&[]
));
assert_eq!(
connection_hosts("[::ffff:10.0.0.2]:8080".parse().unwrap()),
["10.0.0.2:8080"]
);
assert_eq!(
connection_hosts("[fd00::2]:80".parse().unwrap()),
["[fd00::2]:80", "[fd00::2]"]
);
}
#[test]
fn allowed_hosts_take_the_port_unless_they_name_one() {
assert_eq!(host_entries("Box.LAN", 8080), ["box.lan:8080"]);
assert_eq!(host_entries("box.lan:9000", 8080), ["box.lan:9000"]);
assert_eq!(host_entries("[fd00::2]", 8080), ["[fd00::2]:8080"]);
assert_eq!(host_entries("[fd00::2]:9000", 8080), ["[fd00::2]:9000"]);
assert_eq!(host_entries("box", 80), ["box:80", "box"]);
}
#[test]
fn origins_are_checked() {
let hosts = allowed_hosts("127.0.0.1:8080".parse().unwrap());
let hosts = hosts.as_slice();
let ok =
|origin: Option<&str>, host: Option<&str>| origin_allowed(origin, host, hosts, &[]);
assert!(ok(Some("http://127.0.0.1:8080"), Some("127.0.0.1:8080")));
assert!(ok(Some("http://localhost:8080"), Some("localhost:8080")));
assert!(ok(Some("http://LOCALHOST:8080/"), Some("localhost:8080")));
assert!(!ok(Some("http://evil.example"), Some("127.0.0.1:8080")));
assert!(!ok(Some("http://127.0.0.1:9999"), Some("127.0.0.1:8080")));
assert!(!ok(None, Some("127.0.0.1:8080")));
assert!(!ok(Some("null"), Some("127.0.0.1:8080")));
assert!(!ok(
Some("http://evil.example:8080"),
Some("evil.example:8080")
));
assert!(!ok(Some("https://127.0.0.1:8080"), Some("127.0.0.1:8080")));
assert!(!ok(Some("http://127.0.0.1:8080"), None));
let extra = ["https://term.example.com".to_string()];
assert!(origin_allowed(
Some("https://term.example.com"),
Some("anything"),
hosts,
&extra
));
}
#[test]
fn upgrades_are_recognised() {
let upgrade = "GET /ws HTTP/1.1\r\nHost: h\r\nUpgrade: websocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n";
assert_eq!(
websocket_key(&head(upgrade)),
Some("dGhlIHNhbXBsZSBub25jZQ==")
);
let old = upgrade.replace("Version: 13", "Version: 8");
assert_eq!(websocket_key(&head(&old)), None);
let plain = "GET /ws HTTP/1.1\r\nHost: h\r\n\r\n";
assert_eq!(websocket_key(&head(plain)), None);
}
#[test]
fn responses_close_and_escape() {
let mut out = Vec::new();
respond(
&mut out,
"200 OK",
"text/plain",
&[("X-A", "b".into())],
b"hi",
)
.unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(text.contains("Content-Length: 2\r\n"));
assert!(text.contains("Connection: close\r\n"));
assert!(text.contains("X-A: b\r\n"));
assert!(text.ends_with("\r\n\r\nhi"));
assert_eq!(escape_html("<a & \"b\">"), "<a & "b">");
}
}