use core::fmt;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio_rustls::rustls::pki_types::ServerName;
use crate::terminal_safe;
pub const MAX_HEADER_BYTES: usize = 64 * 1024;
#[derive(Clone, PartialEq, Eq)]
pub struct Target {
pub https: bool,
pub host: String,
pub port: u16,
pub path: String,
}
impl fmt::Debug for Target {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Target {{ https: {}, host: {:?}, port: {}, path: <redacted> }}",
self.https, self.host, self.port
)
}
}
#[derive(Debug)]
pub enum FetchError {
Url(String),
Transport(std::io::Error),
Status(u16),
Redirect {
location: String,
},
Chunked,
TooLarge {
limit: usize,
},
HeadersTooLarge {
limit: usize,
},
Timeout,
Truncated {
expected: usize,
got: usize,
},
}
impl fmt::Display for FetchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Url(reason) => write!(f, "not a fetchable url: {reason}"),
Self::Transport(source) => write!(f, "fetch failed: {source}"),
Self::Status(code) => write!(f, "fetch answered {code}"),
Self::Redirect { location } => write!(f, "fetch was redirected to {location}"),
Self::Chunked => {
write!(
f,
"fetch response used transfer-encoding, which this client refuses to decode"
)
}
Self::TooLarge { limit } => write!(f, "fetch response exceeded the {limit}-byte limit"),
Self::HeadersTooLarge { limit } => {
write!(f, "fetch response headers exceeded the {limit}-byte limit")
}
Self::Timeout => write!(f, "fetch timed out"),
Self::Truncated { expected, got } => {
write!(
f,
"fetch response was truncated: expected {expected} bytes, got {got}"
)
}
}
}
}
impl core::error::Error for FetchError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Transport(source) => Some(source),
Self::Url(_)
| Self::Status(_)
| Self::Redirect { .. }
| Self::Chunked
| Self::TooLarge { .. }
| Self::HeadersTooLarge { .. }
| Self::Timeout
| Self::Truncated { .. } => None,
}
}
}
impl From<std::io::Error> for FetchError {
fn from(source: std::io::Error) -> Self {
Self::Transport(source)
}
}
pub const CREDENTIALS_REFUSAL: &str = concat!(
"credentials before the host (`user@` or `user:pass@`) are not supported; ",
"the url is not echoed, since it carries one"
);
pub fn url_for_message(url: &str) -> &str {
if url.contains('@') {
"a url that may carry credentials"
} else {
url
}
}
fn authority_of(rest: &str) -> &str {
match rest.find(['/', '?', '#']) {
Some(i) => &rest[..i],
None => rest,
}
}
pub fn url_carries_credentials(url: &str) -> bool {
let rest = url.split_once("://").map_or(url, |(_scheme, rest)| rest);
let rest = rest.strip_prefix("//").unwrap_or(rest);
authority_of(rest).contains('@')
}
pub fn parse_url(url: &str) -> Result<Target, FetchError> {
if url_carries_credentials(url) {
return Err(FetchError::Url(CREDENTIALS_REFUSAL.to_owned()));
}
let (https, rest) = match url.strip_prefix("https://") {
Some(rest) => (true, rest),
None => match url.strip_prefix("http://") {
Some(rest) => (false, rest),
None => {
return Err(FetchError::Url(format!(
"{} does not start with http:// or https://",
url_for_message(url)
)));
}
},
};
let authority = authority_of(rest);
let remainder = &rest[authority.len()..];
let remainder = remainder.split('#').next().unwrap_or(remainder);
let path = match remainder.chars().next() {
Some('/') => remainder.to_owned(),
Some(_) => format!("/{remainder}"),
None => "/".to_owned(),
};
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) => (
h,
p.parse().map_err(|_err| {
FetchError::Url(format!("{} has a non-numeric port", url_for_message(url)))
})?,
),
None => (authority, if https { 443 } else { 80 }),
};
if host.is_empty() {
return Err(FetchError::Url(format!(
"{} has no host",
url_for_message(url)
)));
}
Ok(Target {
https,
host: host.to_string(),
port,
path,
})
}
pub fn tls_connector() -> &'static tokio_rustls::TlsConnector {
static CONNECTOR: std::sync::LazyLock<tokio_rustls::TlsConnector> =
std::sync::LazyLock::new(|| {
let roots = tokio_rustls::rustls::RootCertStore::from_iter(
webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
);
let provider = Arc::new(tokio_rustls::rustls::crypto::ring::default_provider());
let config = tokio_rustls::rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.expect("ring's default cipher suites cover rustls's own default protocol versions")
.with_root_certificates(roots)
.with_no_client_auth();
tokio_rustls::TlsConnector::from(Arc::new(config))
});
&CONNECTOR
}
pub async fn get(target: &Target, limit: usize, timeout: Duration) -> Result<Vec<u8>, FetchError> {
match tokio::time::timeout(timeout, get_inner(target, limit)).await {
Ok(result) => result,
Err(_elapsed) => Err(FetchError::Timeout),
}
}
async fn get_inner(target: &Target, limit: usize) -> Result<Vec<u8>, FetchError> {
let request = build_get_request(target);
let tcp = TcpStream::connect((target.host.as_str(), target.port)).await?;
if target.https {
let domain = ServerName::try_from(target.host.clone())
.map_err(|source| FetchError::Transport(std::io::Error::other(source)))?;
let tls = tls_connector()
.connect(domain, tcp)
.await
.map_err(peer_transport_error)?;
exchange(tls, &request, limit).await
} else {
exchange(tcp, &request, limit).await
}
}
fn peer_transport_error(source: std::io::Error) -> FetchError {
FetchError::Transport(std::io::Error::new(
source.kind(),
terminal_safe::sanitise(&source.to_string()).0,
))
}
fn build_get_request(target: &Target) -> String {
let default_port = if target.https { 443 } else { 80 };
let host = if target.port == default_port {
target.host.clone()
} else {
format!("{}:{}", target.host, target.port)
};
format!(
"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n",
path = target.path,
)
}
async fn exchange<S: AsyncRead + AsyncWrite + Unpin>(
mut stream: S,
request: &str,
limit: usize,
) -> Result<Vec<u8>, FetchError> {
stream.write_all(request.as_bytes()).await?;
stream.flush().await?;
read_response(stream, limit).await
}
async fn read_response<S: AsyncRead + Unpin>(
stream: S,
limit: usize,
) -> Result<Vec<u8>, FetchError> {
let mut headers = BufReader::new(stream).take(MAX_HEADER_BYTES as u64);
let mut status_line = String::new();
headers.read_line(&mut status_line).await?;
let code = parse_status_line(&status_line)?;
let mut location: Option<String> = None;
let mut transfer_encoding = false;
let mut content_length: Option<u64> = None;
let mut content_length_ok = true;
loop {
let mut line = String::new();
let read = headers.read_line(&mut line).await?;
if read == 0 {
if headers.limit() == 0 {
return Err(FetchError::HeadersTooLarge {
limit: MAX_HEADER_BYTES,
});
}
return Err(FetchError::Transport(std::io::Error::other(
"response headers ended without a blank line",
)));
}
if line == "\r\n" || line == "\n" {
break;
}
let Some((name, value)) = line.split_once(':') else {
return Err(FetchError::Transport(std::io::Error::other(
"malformed header line: no colon",
)));
};
let value = value.trim();
match name.trim().to_ascii_lowercase().as_str() {
"location" => location = Some(terminal_safe::sanitise(value).0),
"transfer-encoding" => transfer_encoding = true,
"content-length" => match value.parse::<u64>() {
Ok(parsed) => match content_length {
Some(existing) if existing != parsed => content_length_ok = false,
Some(_) => {}
None => content_length = Some(parsed),
},
Err(_err) => content_length_ok = false,
},
_ => {}
}
}
if (300..400).contains(&code)
&& let Some(location) = location
{
return Err(FetchError::Redirect { location });
}
if !(200..300).contains(&code) {
return Err(FetchError::Status(code));
}
if transfer_encoding {
return Err(FetchError::Chunked);
}
if !content_length_ok {
return Err(FetchError::Transport(std::io::Error::other(
"content-length header was not a number, or two content-length headers disagreed",
)));
}
let Some(content_length) = content_length else {
return Err(FetchError::Transport(std::io::Error::other(
"response carried no content-length",
)));
};
if content_length > limit as u64 {
return Err(FetchError::TooLarge { limit });
}
let expected = content_length as usize;
let mut reader = headers.into_inner();
let mut body = vec![0u8; expected];
let mut filled = 0;
while filled < expected {
let read = reader.read(&mut body[filled..]).await?;
if read == 0 {
return Err(FetchError::Truncated {
expected,
got: filled,
});
}
filled += read;
if filled > limit {
return Err(FetchError::TooLarge { limit });
}
}
Ok(body)
}
fn parse_status_line(status_line: &str) -> Result<u16, FetchError> {
let mut parts = status_line.split_whitespace();
match parts.next() {
Some(version) if version.starts_with("HTTP/") => {}
_ => {
return Err(FetchError::Transport(std::io::Error::other(
"response did not start with an HTTP status line",
)));
}
}
parts
.next()
.and_then(|code| code.parse().ok())
.ok_or_else(|| FetchError::Transport(std::io::Error::other("malformed http status code")))
}
#[cfg(test)]
mod tests {
use super::*;
async fn serve(response: &'static [u8]) -> Target {
serve_owned(response.to_vec()).await
}
async fn serve_owned(response: Vec<u8>) -> Target {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _peer) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf).await;
let _ = stream.write_all(&response).await;
let _ = stream.shutdown().await;
});
Target {
https: false,
host: "127.0.0.1".to_string(),
port: addr.port(),
path: "/".to_string(),
}
}
async fn serve_endless_header() -> Target {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _peer) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf).await;
if stream
.write_all(b"HTTP/1.1 200 OK\r\nX-Filler: ")
.await
.is_err()
{
return;
}
let filler = vec![b'A'; 8 * 1024];
while stream.write_all(&filler).await.is_ok() {}
});
Target {
https: false,
host: "127.0.0.1".to_string(),
port: addr.port(),
path: "/".to_string(),
}
}
#[tokio::test]
async fn a_hostile_location_header_cannot_drive_the_terminal_it_prints_to() {
let target = serve(
b"HTTP/1.1 302 Found\r\nLocation: \x1b[2J\x1b]0;pwned\x07/gone\r\nContent-Length: 0\r\n\r\n",
)
.await;
let err = get(&target, 1 << 20, Duration::from_secs(5))
.await
.expect_err("refused");
let FetchError::Redirect { location } = &err else {
panic!("wrong variant: {err:?}")
};
assert_eq!(location, "[2J]0;pwned/gone", "location was not sanitised");
assert!(
!err.to_string().chars().any(char::is_control),
"a control character reached the message: {:?}",
err.to_string()
);
}
#[tokio::test]
async fn no_refusal_hands_a_terminal_a_character_the_host_chose() {
let hostile: [(&'static [u8], &str); 6] = [
(
b"HTTP/1.1 302 Found\r\nLocation: \x1b[2J\x07\r\nContent-Length: 0\r\n\r\n",
"a redirect naming a hostile location",
),
(
b"HTTP/1.1 404 \x1b[2JNot Found\r\nContent-Length: 0\r\n\r\n",
"a non-2xx whose reason phrase is hostile",
),
(
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: \x1b[2Jchunked\r\n\r\n0\r\n\r\n",
"a chunked refusal whose header value is hostile",
),
(
b"HTTP/1.1 200 OK\r\nContent-Length: \x1b[2Jnope\r\n\r\n",
"an unparseable content-length that is hostile",
),
(
b"\x1b[2J\x07 NOT HTTP AT ALL\r\n\r\n",
"a status line that is not HTTP and is hostile",
),
(
b"HTTP/1.1 200 OK\r\n\x1b[2Jno-colon-here\r\n\r\n",
"a header line with no colon, hostile",
),
];
for (response, why) in hostile {
let target = serve(response).await;
let err = get(&target, 1 << 20, Duration::from_secs(5))
.await
.expect_err(why);
let shown = err.to_string();
assert!(
!shown.chars().any(char::is_control),
"{why}: a control character reached the message: {shown:?}"
);
}
}
#[tokio::test]
async fn a_header_that_never_ends_is_refused_rather_than_read_forever() {
let target = serve_endless_header().await;
let err = get(&target, 1 << 20, Duration::from_secs(2))
.await
.expect_err("refused");
assert!(
matches!(
err,
FetchError::HeadersTooLarge {
limit: MAX_HEADER_BYTES
}
),
"{err:?}"
);
}
#[tokio::test]
async fn a_header_block_just_under_the_cap_is_still_read() {
let tail = b"\r\nContent-Length: 5\r\n\r\nhello";
let head = b"HTTP/1.1 200 OK\r\nX-Pad: ";
let pad = MAX_HEADER_BYTES - head.len() - tail.len();
let mut response = head.to_vec();
response.extend(std::iter::repeat_n(b'A', pad));
response.extend_from_slice(tail);
let target = serve_owned(response).await;
let body = get(&target, 1 << 20, Duration::from_secs(5))
.await
.expect("read");
assert_eq!(body, b"hello");
}
#[tokio::test]
async fn a_content_length_body_is_read_exactly() {
let target = serve(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello").await;
let body = get(&target, 1 << 20, Duration::from_secs(5))
.await
.expect("read");
assert_eq!(body, b"hello");
}
#[tokio::test]
async fn a_chunked_response_is_refused_rather_than_misparsed() {
let target =
serve(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n")
.await;
assert!(matches!(
get(&target, 1 << 20, Duration::from_secs(5)).await,
Err(FetchError::Chunked)
));
}
#[tokio::test]
async fn a_redirect_is_refused_and_names_where_it_pointed() {
let target = serve(
b"HTTP/1.1 301 Moved\r\nLocation: https://elsewhere/\r\nContent-Length: 0\r\n\r\n",
)
.await;
let err = get(&target, 1 << 20, Duration::from_secs(5))
.await
.expect_err("refused");
let FetchError::Redirect { location } = err else {
panic!("wrong variant: {err:?}")
};
assert_eq!(location, "https://elsewhere/");
}
#[tokio::test]
async fn a_body_over_the_limit_is_refused_before_it_is_read() {
let target = serve(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n").await;
assert!(matches!(
get(&target, 10, Duration::from_secs(5)).await,
Err(FetchError::TooLarge { limit: 10 })
));
}
#[tokio::test]
async fn a_non_2xx_carries_its_status() {
let target = serve(b"HTTP/1.1 500 Oops\r\nContent-Length: 0\r\n\r\n").await;
assert!(matches!(
get(&target, 1 << 20, Duration::from_secs(5)).await,
Err(FetchError::Status(500))
));
}
#[tokio::test]
async fn a_peer_that_closes_mid_body_is_an_error_not_a_short_read() {
let target = serve(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort").await;
let err = get(&target, 1 << 20, Duration::from_secs(5))
.await
.expect_err("refused");
assert!(
matches!(
err,
FetchError::Truncated {
expected: 10,
got: 5
}
),
"{err:?}"
);
}
#[tokio::test]
async fn a_missing_content_length_is_refused() {
let target = serve(b"HTTP/1.1 200 OK\r\n\r\nbody").await;
assert!(get(&target, 1 << 20, Duration::from_secs(5)).await.is_err());
}
#[test]
fn a_url_that_is_not_http_or_https_is_refused() {
assert!(matches!(
parse_url("file:///etc/passwd"),
Err(FetchError::Url(_))
));
assert!(matches!(parse_url("not a url"), Err(FetchError::Url(_))));
}
#[test]
fn a_url_carrying_credentials_is_refused_without_echoing_them() {
for url in [
"https://user:hunter2@example.com:8443/webhook",
"https://user:hunter2@example.com/webhook",
"http://hunter2@example.com/webhook",
"ftp://user:hunter2@example.com/webhook",
"HTTPS://user:hunter2@example.com/webhook",
"user:hunter2@example.com/webhook",
"//user:hunter2@example.com/webhook",
] {
let err = parse_url(url).unwrap_err();
assert!(matches!(err, FetchError::Url(_)), "{url}: {err:?}");
assert_eq!(
err.to_string(),
"not a fetchable url: credentials before the host (`user@` or `user:pass@`) are \
not supported; the url is not echoed, since it carries one",
"{url}"
);
assert!(!format!("{err} {err:?}").contains("hunter2"), "{url}");
}
}
#[test]
fn no_refusal_quotes_a_url_holding_an_at_sign() {
for url in [
"file:///etc/pass@wd",
"https://example.com:notaport/etc/pass@wd",
] {
assert!(!url_carries_credentials(url), "{url}");
let err = parse_url(url).unwrap_err();
let rendered = format!("{err} {err:?}");
assert!(!rendered.contains("pass@wd"), "{url}: {rendered}");
assert!(
rendered.contains("a url that may carry credentials"),
"{url}: {rendered}"
);
}
}
#[test]
fn a_query_joins_the_path_and_a_fragment_is_dropped() {
for (url, path) in [
(
"https://example.com?contact=alice@example.com",
"/?contact=alice@example.com",
),
("https://example.com#a@b", "/"),
("https://example.com/hook#frag", "/hook"),
("https://example.com/hook?a=b#frag", "/hook?a=b"),
("https://example.com/hook?a=b", "/hook?a=b"),
("https://example.com", "/"),
] {
let target = parse_url(url).unwrap_or_else(|err| panic!("{url}: {err}"));
assert_eq!(target.host, "example.com", "{url}");
assert_eq!(target.port, 443, "{url}");
assert_eq!(target.path, path, "{url}");
}
assert!(!url_carries_credentials(
"https://example.com?contact=alice@example.com"
));
}
#[test]
fn no_fragment_reaches_the_request_line() {
let target = parse_url("https://example.com/hook?a=b#sentinelfragment")
.expect("a url with a query and a fragment parses");
let request = build_get_request(&target);
assert!(
request.starts_with("GET /hook?a=b HTTP/1.1\r\n"),
"{request}"
);
assert!(!request.contains("sentinelfragment"), "{request}");
}
#[test]
fn a_targets_debug_redacts_the_path() {
let target = parse_url("https://discord.com/api/webhooks/123/s3cr3t-token")
.expect("a plain https url parses");
let rendered = format!("{target:?}");
assert_eq!(
rendered,
r#"Target { https: true, host: "discord.com", port: 443, path: <redacted> }"#
);
assert!(!rendered.contains("s3cr3t-token"));
}
#[tokio::test]
async fn two_disagreeing_content_lengths_are_refused() {
let target =
serve(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 6\r\n\r\nhello!").await;
assert!(get(&target, 1 << 20, Duration::from_secs(5)).await.is_err());
}
#[tokio::test]
async fn a_status_line_that_is_not_http_is_refused() {
let target = serve(b"NOT HTTP AT ALL\r\n\r\n").await;
assert!(get(&target, 1 << 20, Duration::from_secs(5)).await.is_err());
}
#[tokio::test]
async fn headers_with_no_terminating_blank_line_are_refused() {
let target = serve(b"HTTP/1.1 200 OK\r\nContent-Length: 5").await;
assert!(get(&target, 1 << 20, Duration::from_secs(5)).await.is_err());
}
#[tokio::test]
async fn a_redirect_with_no_location_is_still_refused() {
let target = serve(b"HTTP/1.1 302 Found\r\nContent-Length: 0\r\n\r\n").await;
assert!(matches!(
get(&target, 1 << 20, Duration::from_secs(5)).await,
Err(FetchError::Status(302))
));
}
}