use sandlock_core::{Sandbox};
use std::io::{BufRead, BufReader, Read as _, Write as _};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::thread;
fn temp_file(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"sandlock-test-http-{}-{}",
name,
std::process::id()
))
}
fn base_policy() -> sandlock_core::SandboxBuilder {
Sandbox::builder()
.fs_read("/usr")
.fs_read("/lib")
.fs_read_if_exists("/lib64")
.fs_read("/bin")
.fs_read("/etc")
.fs_read("/proc")
.fs_read("/dev")
.fs_read("/tmp")
.fs_write("/tmp")
}
fn spawn_http_server(n: usize) -> (u16, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = thread::spawn(move || {
for _ in 0..n {
if let Ok(mut stream) = listener.accept().map(|(s, _)| s) {
handle_http_conn(&mut stream);
}
}
});
(port, handle)
}
fn spawn_http_server_v6(n: usize) -> (u16, thread::JoinHandle<()>) {
let listener = TcpListener::bind("[::1]:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = thread::spawn(move || {
for _ in 0..n {
if let Ok(mut stream) = listener.accept().map(|(s, _)| s) {
handle_http_conn(&mut stream);
}
}
});
(port, handle)
}
fn handle_http_conn(stream: &mut TcpStream) {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut content_length = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
if line.to_lowercase().starts_with("content-length:") {
content_length = line.split(':').nth(1)
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
}
if line == "\r\n" || line == "\n" {
break;
}
}
if content_length > 0 {
let mut body = vec![0u8; content_length];
let _ = reader.read_exact(&mut body);
}
let response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok";
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
fn http_script(url: &str, out: &std::path::Path) -> String {
format!(
concat!(
"import urllib.request, urllib.error\n",
"try:\n",
" resp = urllib.request.urlopen('{url}')\n",
" open('{out}', 'w').write('OK:' + str(resp.status))\n",
"except urllib.error.HTTPError as e:\n",
" open('{out}', 'w').write('HTTP:' + str(e.code))\n",
"except Exception as e:\n",
" open('{out}', 'w').write('ERR:' + str(e))\n",
),
url = url,
out = out.display(),
)
}
fn post_script(url: &str, out: &std::path::Path) -> String {
format!(
concat!(
"import urllib.request, urllib.error\n",
"try:\n",
" req = urllib.request.Request('{url}', method='POST', data=b'test')\n",
" resp = urllib.request.urlopen(req)\n",
" open('{out}', 'w').write('OK:' + str(resp.status))\n",
"except urllib.error.HTTPError as e:\n",
" open('{out}', 'w').write('HTTP:' + str(e.code))\n",
"except Exception as e:\n",
" open('{out}', 'w').write('ERR:' + str(e))\n",
),
url = url,
out = out.display(),
)
}
#[tokio::test]
async fn test_http_allow_get() {
let out = temp_file("allow-get");
let (port, srv) = spawn_http_server(1);
let policy = base_policy()
.http_allow(&format!("GET 127.0.0.1/*"))
.http_port(port)
.build()
.unwrap();
let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success(), "exit={:?}", result.code());
let content = std::fs::read_to_string(&out).unwrap_or_default();
assert!(content.starts_with("OK:200"), "expected OK:200, got: {}", content);
srv.join().unwrap();
let _ = std::fs::remove_file(&out);
}
#[tokio::test]
async fn test_http_deny_non_matching() {
let out = temp_file("deny-nonmatch");
let (port, _srv) = spawn_http_server(1);
let policy = base_policy()
.http_allow(&format!("GET 127.0.0.1/allowed"))
.http_port(port)
.build()
.unwrap();
let script = http_script(&format!("http://127.0.0.1:{}/denied", port), &out);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success(), "exit={:?}", result.code());
let content = std::fs::read_to_string(&out).unwrap_or_default();
assert!(content.starts_with("HTTP:403"), "expected HTTP:403, got: {}", content);
let _ = std::fs::remove_file(&out);
}
#[tokio::test]
async fn test_http_deny_precedence() {
let out_allowed = temp_file("deny-prec-allowed");
let out_denied = temp_file("deny-prec-denied");
let (port, srv) = spawn_http_server(1);
let policy = base_policy()
.http_allow(&format!("* 127.0.0.1/*"))
.http_deny(&format!("* 127.0.0.1/secret"))
.http_port(port)
.build()
.unwrap();
let script = http_script(&format!("http://127.0.0.1:{}/public", port), &out_allowed);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success());
let content = std::fs::read_to_string(&out_allowed).unwrap_or_default();
assert!(content.starts_with("OK:200"), "expected OK:200 for /public, got: {}", content);
let script = http_script(&format!("http://127.0.0.1:{}/secret", port), &out_denied);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success());
let content = std::fs::read_to_string(&out_denied).unwrap_or_default();
assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for /secret, got: {}", content);
srv.join().unwrap();
let _ = std::fs::remove_file(&out_allowed);
let _ = std::fs::remove_file(&out_denied);
}
#[tokio::test]
async fn test_http_no_acl_unrestricted() {
let out = temp_file("no-acl");
let (port, srv) = spawn_http_server(1);
let policy = base_policy()
.net_allow(format!(":{}", port))
.build()
.unwrap();
let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success(), "exit={:?}", result.code());
let content = std::fs::read_to_string(&out).unwrap_or_default();
assert!(content.starts_with("OK:200"), "expected OK:200 (unrestricted), got: {}", content);
srv.join().unwrap();
let _ = std::fs::remove_file(&out);
}
#[tokio::test]
async fn test_http_method_filtering() {
let out_get = temp_file("method-get");
let out_post = temp_file("method-post");
let (port, srv) = spawn_http_server(1);
let policy = base_policy()
.http_allow(&format!("GET 127.0.0.1/anything"))
.http_port(port)
.build()
.unwrap();
let script = http_script(&format!("http://127.0.0.1:{}/anything", port), &out_get);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success());
let content = std::fs::read_to_string(&out_get).unwrap_or_default();
assert!(content.starts_with("OK:200"), "expected OK:200 for GET, got: {}", content);
let script = post_script(&format!("http://127.0.0.1:{}/anything", port), &out_post);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success());
let content = std::fs::read_to_string(&out_post).unwrap_or_default();
assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for POST, got: {}", content);
srv.join().unwrap();
let _ = std::fs::remove_file(&out_get);
let _ = std::fs::remove_file(&out_post);
}
#[tokio::test]
async fn test_http_multiple_allow_rules() {
let out_get = temp_file("multi-get");
let out_other = temp_file("multi-other");
let (port, srv) = spawn_http_server(1);
let policy = base_policy()
.http_allow(&format!("GET 127.0.0.1/get"))
.http_allow(&format!("POST 127.0.0.1/post"))
.http_port(port)
.build()
.unwrap();
let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out_get);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success());
let content = std::fs::read_to_string(&out_get).unwrap_or_default();
assert!(content.starts_with("OK:200"), "expected OK:200 for /get, got: {}", content);
let script = http_script(&format!("http://127.0.0.1:{}/anything", port), &out_other);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success());
let content = std::fs::read_to_string(&out_other).unwrap_or_default();
assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for /anything, got: {}", content);
srv.join().unwrap();
let _ = std::fs::remove_file(&out_get);
let _ = std::fs::remove_file(&out_other);
}
#[tokio::test]
async fn test_http_wildcard_host() {
let out_get = temp_file("wildcard-get");
let out_denied = temp_file("wildcard-denied");
let (port, srv) = spawn_http_server(1);
let policy = base_policy()
.http_allow(&format!("* 127.0.0.1/*"))
.http_deny("* */admin/*")
.http_port(port)
.build()
.unwrap();
let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out_get);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success());
let content = std::fs::read_to_string(&out_get).unwrap_or_default();
assert!(content.starts_with("OK:200"), "expected OK:200 for /get, got: {}", content);
let script = http_script(&format!("http://127.0.0.1:{}/admin/settings", port), &out_denied);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success());
let content = std::fs::read_to_string(&out_denied).unwrap_or_default();
assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for /admin/settings, got: {}", content);
srv.join().unwrap();
let _ = std::fs::remove_file(&out_get);
let _ = std::fs::remove_file(&out_denied);
}
#[tokio::test]
async fn test_http_non_intercepted_port() {
let out = temp_file("non-intercept");
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let srv = std::thread::spawn(move || {
if let Ok((mut conn, _)) = listener.accept() {
let _ = std::io::Write::write_all(&mut conn, b"HELLO");
}
});
let policy = base_policy()
.http_allow("GET example.com/get")
.net_allow(format!(":{}", port))
.build()
.unwrap();
let script = format!(
concat!(
"import socket\n",
"try:\n",
" c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
" c.settimeout(2)\n",
" c.connect(('127.0.0.1', {port}))\n",
" data = c.recv(10)\n",
" c.close()\n",
" open('{out}', 'w').write('OK:' + data.decode())\n",
"except Exception as e:\n",
" open('{out}', 'w').write('ERR:' + str(e))\n",
),
out = out.display(),
port = port,
);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
srv.join().unwrap();
assert!(result.success(), "exit={:?}", result.code());
let content = std::fs::read_to_string(&out).unwrap_or_default();
assert!(content.starts_with("OK:HELLO"), "expected OK:HELLO, got: {}", content);
let _ = std::fs::remove_file(&out);
}
#[tokio::test]
async fn test_http_acl_ipv6_allow() {
let out = temp_file("ipv6-allow");
let (port, srv) = spawn_http_server_v6(1);
let policy = base_policy()
.http_allow("GET */*")
.http_port(port)
.build()
.unwrap();
let script = http_script(&format!("http://[::1]:{}/get", port), &out);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success(), "exit={:?}", result.code());
let content = std::fs::read_to_string(&out).unwrap_or_default();
assert!(content.starts_with("OK:200"), "expected OK:200 for IPv6 allow, got: {}", content);
srv.join().unwrap();
let _ = std::fs::remove_file(&out);
}
#[tokio::test]
async fn test_http_acl_ipv6_deny() {
let out = temp_file("ipv6-deny");
let (port, _srv) = spawn_http_server_v6(1);
let policy = base_policy()
.http_allow("GET */allowed")
.http_port(port)
.build()
.unwrap();
let script = http_script(&format!("http://[::1]:{}/denied", port), &out);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success(), "exit={:?}", result.code());
let content = std::fs::read_to_string(&out).unwrap_or_default();
assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for IPv6 deny, got: {}", content);
let _ = std::fs::remove_file(&out);
}
#[tokio::test]
async fn test_http_ipv6_non_intercepted_port() {
let out = temp_file("ipv6-non-intercept");
let listener = std::net::TcpListener::bind("[::1]:0").unwrap();
let port = listener.local_addr().unwrap().port();
let srv = std::thread::spawn(move || {
if let Ok((mut conn, _)) = listener.accept() {
let _ = std::io::Write::write_all(&mut conn, b"HELLO6");
}
});
let policy = base_policy()
.http_allow("GET example.com/get")
.net_allow(format!(":{}", port))
.build()
.unwrap();
let script = format!(
concat!(
"import socket\n",
"try:\n",
" c = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)\n",
" c.settimeout(2)\n",
" c.connect(('::1', {port}))\n",
" data = c.recv(10)\n",
" c.close()\n",
" open('{out}', 'w').write('OK:' + data.decode())\n",
"except Exception as e:\n",
" open('{out}', 'w').write('ERR:' + str(e))\n",
),
out = out.display(),
port = port,
);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
srv.join().unwrap();
assert!(result.success(), "exit={:?}", result.code());
let content = std::fs::read_to_string(&out).unwrap_or_default();
assert!(content.starts_with("OK:HELLO6"), "expected OK:HELLO6, got: {}", content);
let _ = std::fs::remove_file(&out);
}
#[tokio::test]
async fn test_http_acl_ipv6_method_filtering() {
let out_get = temp_file("ipv6-method-get");
let out_post = temp_file("ipv6-method-post");
let (port, srv) = spawn_http_server_v6(1);
let policy = base_policy()
.http_allow("GET */*")
.http_port(port)
.build()
.unwrap();
let script = http_script(&format!("http://[::1]:{}/anything", port), &out_get);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success());
let content = std::fs::read_to_string(&out_get).unwrap_or_default();
assert!(content.starts_with("OK:200"), "expected OK:200 for IPv6 GET, got: {}", content);
let script = post_script(&format!("http://[::1]:{}/anything", port), &out_post);
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script])
.await
.unwrap();
assert!(result.success());
let content = std::fs::read_to_string(&out_post).unwrap_or_default();
assert!(content.starts_with("HTTP:403"), "expected HTTP:403 for IPv6 POST, got: {}", content);
srv.join().unwrap();
let _ = std::fs::remove_file(&out_get);
let _ = std::fs::remove_file(&out_post);
}