pub const HOST_PLACEHOLDER: &str = "{host}";
pub const DEFAULT_HTTP_DENY_MESSAGE: &str = "\
This host is not allowed by the sandbox network policy config.\n\
\n\
Note to agent: `{host}` is not in the allowed-host list. Ask the user to add it to the sandbox network allow list.\n";
pub(crate) fn classify_http_request(buf: &[u8]) -> Option<bool> {
let mut line = buf;
while let Some(rest) = line.strip_prefix(b"\r\n") {
line = rest;
}
if line.is_empty() || line == b"\r" {
return None;
}
if !line[0].is_ascii_alphabetic() {
return Some(false);
}
let Some(end) = line.windows(2).position(|pair| pair == b"\r\n") else {
return line
.iter()
.any(|byte| !byte.is_ascii() || (*byte < b' ' && *byte != b'\r'))
.then_some(false);
};
let mut headers = [];
let mut request = httparse::Request::new(&mut headers);
let parsed = request.parse(&line[..end + 2]);
let is_http1 = parsed.is_ok()
&& matches!(request.version, Some(0 | 1))
&& request.method.is_some()
&& request.path.is_some();
Some(is_http1)
}
pub fn render_http_deny_message(template: &str, host: &str) -> String {
let host = host.trim();
let host = if host.is_empty() { "this host" } else { host };
template.replace(HOST_PLACEHOLDER, host)
}
pub fn http_forbidden_response(body: &str) -> Vec<u8> {
let body = body.as_bytes();
let mut response = Vec::with_capacity(128 + body.len());
response.extend_from_slice(b"HTTP/1.1 403 Forbidden\r\n");
response.extend_from_slice(b"Content-Type: text/plain; charset=utf-8\r\n");
response.extend_from_slice(b"Connection: close\r\n");
response.extend_from_slice(b"Cache-Control: no-store\r\n");
response.extend_from_slice(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes());
response.extend_from_slice(body);
response
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_HTTP_DENY_MESSAGE, classify_http_request, http_forbidden_response,
render_http_deny_message,
};
#[test]
fn classification_requires_a_complete_http1_request_line() {
for request in [
b"GET / HTTP/1.1\r\n".as_slice(),
b"QUERY / HTTP/1.0\r\n",
b"\r\nGET / HTTP/1.1\r\n",
] {
for end in 0..request.len() {
assert_eq!(
classify_http_request(&request[..end]),
None,
"prefix: {:?}",
&request[..end]
);
}
assert_eq!(classify_http_request(request), Some(true));
}
for request in [
b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".as_slice(),
b"SSH-2.0-OpenSSH\r\n",
b"EHLO mail.example\r\n",
b"GET file\r\n",
b"GET / HTTP/3.0\r\n",
b"\x16\x03\x01",
b"\x00binary",
] {
assert_eq!(
classify_http_request(request),
Some(false),
"request: {request:?}"
);
}
}
#[test]
fn default_message_names_the_blocked_host() {
let body = render_http_deny_message(DEFAULT_HTTP_DENY_MESSAGE, "evil.example");
assert!(body.contains("`evil.example`"));
assert!(body.contains("Note to agent:"));
assert!(!body.contains("{host}"));
}
#[test]
fn empty_host_falls_back_to_this_host() {
let body = render_http_deny_message("blocked: {host}", " ");
assert_eq!(body, "blocked: this host");
}
#[test]
fn forbidden_response_is_http11_with_the_body() {
let response = http_forbidden_response("nope\n");
let text = std::str::from_utf8(&response).unwrap();
assert!(text.starts_with("HTTP/1.1 403 Forbidden\r\n"));
assert!(text.contains("Content-Type: text/plain; charset=utf-8\r\n"));
assert!(text.contains("Connection: close\r\n"));
assert!(text.ends_with("\r\n\r\nnope\n"));
assert!(text.contains("Content-Length: 5\r\n"));
}
}