mod common;
use common::{
png_bytes, send_transform_request, spawn_fixture_server, spawn_server, split_response, temp_dir,
};
use std::fs;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::{Duration, Instant};
use truss::ServerConfig;
#[test]
fn ssrf_redirect_to_metadata_endpoint_is_blocked() {
let storage_root = temp_dir("ssrf-redirect-metadata");
let (url, fixture) = spawn_fixture_server(vec![(
"302 Found".to_string(),
vec![(
"Location".to_string(),
"http://169.254.169.254/latest/meta-data".to_string(),
)],
Vec::new(),
)]);
let (addr, handle) = spawn_server(
ServerConfig::new(storage_root, Some("secret".to_string())).with_insecure_url_sources(true),
);
let body =
format!(r#"{{"source":{{"kind":"url","url":"{url}"}},"options":{{"format":"jpeg"}}}}"#);
let response = send_transform_request(addr, &body, Some("secret"));
handle
.join()
.expect("join server thread")
.expect("serve one request");
fixture.join().expect("join fixture server");
let (header, content_type, body) = split_response(&response);
let body = String::from_utf8(body).expect("utf8 response body");
assert!(
header.starts_with("HTTP/1.1 403"),
"redirect to metadata should be blocked, got: {header}"
);
assert_eq!(content_type, "application/problem+json");
assert!(
body.contains("cloud metadata"),
"error should mention cloud metadata, got: {body}"
);
}
#[test]
fn ssrf_ftp_scheme_rejected() {
let storage_root = temp_dir("ssrf-ftp");
let (addr, handle) = spawn_server(
ServerConfig::new(storage_root, Some("secret".to_string())).with_insecure_url_sources(true),
);
let response = send_transform_request(
addr,
r#"{"source":{"kind":"url","url":"ftp://evil.com/image.png"},"options":{"format":"jpeg"}}"#,
Some("secret"),
);
handle
.join()
.expect("join server thread")
.expect("serve one request");
let (header, _, body) = split_response(&response);
let body = String::from_utf8(body).expect("utf8");
assert!(
header.starts_with("HTTP/1.1 400"),
"ftp scheme should be rejected, got: {header}"
);
assert!(body.contains("http"));
}
#[test]
fn ssrf_file_scheme_rejected() {
let storage_root = temp_dir("ssrf-file");
let (addr, handle) = spawn_server(
ServerConfig::new(storage_root, Some("secret".to_string())).with_insecure_url_sources(true),
);
let response = send_transform_request(
addr,
r#"{"source":{"kind":"url","url":"file:///etc/passwd"},"options":{"format":"jpeg"}}"#,
Some("secret"),
);
handle
.join()
.expect("join server thread")
.expect("serve one request");
let (header, _, _) = split_response(&response);
assert!(
header.starts_with("HTTP/1.1 400"),
"file scheme should be rejected, got: {header}"
);
}
#[test]
fn ssrf_data_scheme_rejected() {
let storage_root = temp_dir("ssrf-data");
let (addr, handle) = spawn_server(
ServerConfig::new(storage_root, Some("secret".to_string())).with_insecure_url_sources(true),
);
let response = send_transform_request(
addr,
r#"{"source":{"kind":"url","url":"data:image/png;base64,iVBOR"},"options":{"format":"jpeg"}}"#,
Some("secret"),
);
handle
.join()
.expect("join server thread")
.expect("serve one request");
let (header, _, _) = split_response(&response);
assert!(
header.starts_with("HTTP/1.1 400"),
"data scheme should be rejected, got: {header}"
);
}
#[test]
fn ssrf_url_with_userinfo_rejected() {
let storage_root = temp_dir("ssrf-userinfo");
let (addr, handle) = spawn_server(
ServerConfig::new(storage_root, Some("secret".to_string())).with_insecure_url_sources(true),
);
let response = send_transform_request(
addr,
r#"{"source":{"kind":"url","url":"http://admin:pass@example.com/image.png"},"options":{"format":"jpeg"}}"#,
Some("secret"),
);
handle
.join()
.expect("join server thread")
.expect("serve one request");
let (header, _, body) = split_response(&response);
let body = String::from_utf8(body).expect("utf8");
assert!(
header.starts_with("HTTP/1.1 400"),
"URL with userinfo should be rejected, got: {header}"
);
assert!(body.contains("user"));
}
#[test]
fn ssrf_private_ip_ranges_blocked_in_strict_mode() {
let _storage_root = temp_dir("ssrf-private-strict");
let blocked_urls = [
("http://10.0.0.1/img.png", "10.0.0.0/8 private"),
("http://172.16.0.1/img.png", "172.16.0.0/12 private"),
("http://192.168.1.1/img.png", "192.168.0.0/16 private"),
];
for (url, description) in blocked_urls {
let storage = temp_dir(&format!("ssrf-priv-{}", description.replace('/', "-")));
let (addr, handle) = spawn_server(ServerConfig::new(storage, Some("secret".to_string())));
let body =
format!(r#"{{"source":{{"kind":"url","url":"{url}"}},"options":{{"format":"jpeg"}}}}"#);
let response = send_transform_request(addr, &body, Some("secret"));
handle
.join()
.expect("join server thread")
.expect("serve one request");
let (header, _, _) = split_response(&response);
assert!(
header.starts_with("HTTP/1.1 403"),
"{description} should be blocked in strict mode, got: {header}"
);
}
}
#[test]
fn ssrf_non_standard_port_blocked_in_strict_mode() {
let storage_root = temp_dir("ssrf-port-strict");
let (addr, handle) = spawn_server(ServerConfig::new(storage_root, Some("secret".to_string())));
let response = send_transform_request(
addr,
r#"{"source":{"kind":"url","url":"http://example.com:8080/image.png"},"options":{"format":"jpeg"}}"#,
Some("secret"),
);
handle
.join()
.expect("join server thread")
.expect("serve one request");
let (header, _, body) = split_response(&response);
let body = String::from_utf8(body).expect("utf8");
assert!(
header.starts_with("HTTP/1.1 403"),
"non-standard port should be blocked, got: {header}"
);
assert!(body.contains("port"));
}
#[test]
fn path_traversal_via_transform_request_is_rejected() {
let storage_root = temp_dir("path-traversal-e2e");
fs::write(storage_root.join("legit.png"), png_bytes()).expect("write legit image");
let (addr, handle) = spawn_server(ServerConfig::new(storage_root, Some("secret".to_string())));
let response = send_transform_request(
addr,
r#"{"source":{"kind":"path","path":"../../etc/passwd"},"options":{"format":"jpeg"}}"#,
Some("secret"),
);
handle
.join()
.expect("join server thread")
.expect("serve one request");
let (header, _, _) = split_response(&response);
assert!(
header.starts_with("HTTP/1.1 400"),
"path traversal should be rejected, got: {header}"
);
}
#[test]
fn path_traversal_with_dotdot_in_middle_is_rejected() {
let storage_root = temp_dir("path-traversal-mid");
fs::create_dir_all(storage_root.join("sub")).expect("create subdir");
fs::write(storage_root.join("sub/image.png"), png_bytes()).expect("write image");
let (addr, handle) = spawn_server(ServerConfig::new(storage_root, Some("secret".to_string())));
let response = send_transform_request(
addr,
r#"{"source":{"kind":"path","path":"/sub/../../../etc/passwd"},"options":{"format":"jpeg"}}"#,
Some("secret"),
);
handle
.join()
.expect("join server thread")
.expect("serve one request");
let (header, _, _) = split_response(&response);
assert!(
header.starts_with("HTTP/1.1 400"),
"mid-path traversal should be rejected, got: {header}"
);
}
#[test]
fn dotgit_access_does_not_leak_raw_file_content() {
let storage_root = temp_dir("path-traversal-git");
fs::create_dir_all(storage_root.join(".git/logs")).expect("create .git dir");
let secret_content = b"ref: refs/heads/main\n";
fs::write(storage_root.join(".git/logs/HEAD"), secret_content).expect("write git log");
let (addr, handle) = spawn_server(ServerConfig::new(storage_root, Some("secret".to_string())));
let response = send_transform_request(
addr,
r#"{"source":{"kind":"path","path":"/.git/logs/HEAD"},"options":{"format":"jpeg"}}"#,
Some("secret"),
);
handle
.join()
.expect("join server thread")
.expect("serve one request");
let (header, content_type, body) = split_response(&response);
assert!(
header.starts_with("HTTP/1.1 415"),
".git file should be rejected as non-image (415), got: {header}"
);
assert_eq!(content_type, "application/problem+json");
assert!(
!body
.windows(secret_content.len())
.any(|w| w == secret_content),
"response must not leak raw .git file content"
);
}
#[test]
fn remote_upstream_4xx_returns_502() {
let storage_root = temp_dir("remote-4xx");
let (url, fixture) = spawn_fixture_server(vec![(
"404 Not Found".to_string(),
vec![("Content-Type".to_string(), "text/plain".to_string())],
b"not found".to_vec(),
)]);
let (addr, handle) = spawn_server(
ServerConfig::new(storage_root, Some("secret".to_string())).with_insecure_url_sources(true),
);
let body =
format!(r#"{{"source":{{"kind":"url","url":"{url}"}},"options":{{"format":"jpeg"}}}}"#);
let response = send_transform_request(addr, &body, Some("secret"));
handle
.join()
.expect("join server thread")
.expect("serve one request");
fixture.join().expect("join fixture server");
let (header, content_type, body) = split_response(&response);
let body = String::from_utf8(body).expect("utf8");
assert!(
header.starts_with("HTTP/1.1 502"),
"upstream 404 should map to 502, got: {header}"
);
assert_eq!(content_type, "application/problem+json");
assert!(body.contains("upstream HTTP 404"));
}
#[test]
fn remote_upstream_5xx_returns_502() {
let storage_root = temp_dir("remote-5xx");
let (url, fixture) = spawn_fixture_server(vec![(
"500 Internal Server Error".to_string(),
vec![("Content-Type".to_string(), "text/plain".to_string())],
b"server error".to_vec(),
)]);
let (addr, handle) = spawn_server(
ServerConfig::new(storage_root, Some("secret".to_string())).with_insecure_url_sources(true),
);
let body =
format!(r#"{{"source":{{"kind":"url","url":"{url}"}},"options":{{"format":"jpeg"}}}}"#);
let response = send_transform_request(addr, &body, Some("secret"));
handle
.join()
.expect("join server thread")
.expect("serve one request");
fixture.join().expect("join fixture server");
let (header, _, body) = split_response(&response);
let body = String::from_utf8(body).expect("utf8");
assert!(
header.starts_with("HTTP/1.1 502"),
"upstream 500 should map to 502, got: {header}"
);
assert!(body.contains("upstream HTTP 500"));
}
#[test]
fn remote_upstream_403_returns_502() {
let storage_root = temp_dir("remote-403");
let (url, fixture) = spawn_fixture_server(vec![(
"403 Forbidden".to_string(),
vec![("Content-Type".to_string(), "text/plain".to_string())],
b"forbidden".to_vec(),
)]);
let (addr, handle) = spawn_server(
ServerConfig::new(storage_root, Some("secret".to_string())).with_insecure_url_sources(true),
);
let body =
format!(r#"{{"source":{{"kind":"url","url":"{url}"}},"options":{{"format":"jpeg"}}}}"#);
let response = send_transform_request(addr, &body, Some("secret"));
handle
.join()
.expect("join server thread")
.expect("serve one request");
fixture.join().expect("join fixture server");
let (header, _, body) = split_response(&response);
let body = String::from_utf8(body).expect("utf8");
assert!(
header.starts_with("HTTP/1.1 502"),
"upstream 403 should map to 502, got: {header}"
);
assert!(body.contains("upstream HTTP 403"));
}
#[test]
fn slow_header_client_is_answered_rather_than_held() {
let storage_root = temp_dir("slow-headers");
fs::write(storage_root.join("image.png"), png_bytes()).expect("write source fixture");
let (addr, handle) = spawn_server(ServerConfig::new(storage_root, Some("secret".to_string())));
let started = Instant::now();
let mut stream = TcpStream::connect(addr).expect("connect to test server");
stream
.set_read_timeout(Some(Duration::from_millis(250)))
.expect("set read timeout");
stream
.write_all(b"GET /health HTTP/1.1\r\n")
.expect("write the request line");
stream.flush().expect("flush");
let mut response = Vec::new();
let trickle_until = Instant::now() + Duration::from_secs(40);
while Instant::now() < trickle_until {
let mut chunk = [0_u8; 1024];
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(read) => {
response.extend_from_slice(&chunk[..read]);
break;
}
Err(_) => {}
}
if stream.write_all(b"X-Pad: y\r\n").is_err() || stream.flush().is_err() {
break;
}
}
handle
.join()
.expect("join server thread")
.expect("serve one request");
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(45),
"the connection was held for {elapsed:?}"
);
let response = String::from_utf8_lossy(&response);
assert!(
response.starts_with("HTTP/1.1 408 Request Timeout"),
"unexpected response: {response}"
);
}
#[test]
fn metadata_spellings_are_refused_even_when_insecure_sources_are_allowed() {
let spellings = [
"http://169.254.169.254/latest/meta-data",
"http://169.254.169.254./latest/meta-data",
"http://metadata.google.internal/computeMetadata/v1/",
"http://metadata.google.internal./computeMetadata/v1/",
"http://[::ffff:169.254.169.254]/latest/meta-data",
"http://[::169.254.169.254]/latest/meta-data",
"http://[2002:a9fe:a9fe::]/latest/meta-data",
"http://[fd00:ec2::254]/latest/meta-data",
];
for spelling in spellings {
let storage_root = temp_dir("metadata-spellings");
let (addr, handle) = spawn_server(
ServerConfig::new(storage_root, Some("secret".to_string()))
.with_insecure_url_sources(true),
);
let request_body = format!(
r#"{{"source":{{"kind":"url","url":"{spelling}"}},"options":{{"format":"png"}}}}"#
);
let response = send_transform_request(addr, &request_body, Some("secret"));
handle
.join()
.expect("join server thread")
.expect("serve one request");
let (header, _, body) = split_response(&response);
let body = String::from_utf8_lossy(&body);
assert!(
header.starts_with("HTTP/1.1 403"),
"{spelling} must be refused by the metadata rule: {header}\n{body}"
);
assert!(
body.contains("cloud metadata"),
"{spelling} must be refused for being a metadata endpoint: {body}"
);
}
}
#[test]
#[serial_test::serial]
fn rate_limiting_sheds_the_image_routes_and_not_the_probes() {
let storage_root = temp_dir("rate-limit-exemptions");
fs::write(storage_root.join("image.png"), png_bytes()).expect("write source fixture");
unsafe {
std::env::set_var("TRUSS_STORAGE_ROOT", &storage_root);
std::env::set_var("TRUSS_RATE_LIMIT_RPS", "3");
std::env::set_var("TRUSS_RATE_LIMIT_BURST", "3");
}
let mut config = ServerConfig::from_env().expect("configure a rate limited server");
unsafe {
std::env::remove_var("TRUSS_STORAGE_ROOT");
std::env::remove_var("TRUSS_RATE_LIMIT_RPS");
std::env::remove_var("TRUSS_RATE_LIMIT_BURST");
}
config.shutdown_drain_secs = 0;
let draining = std::sync::Arc::clone(&config.draining);
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let handle = std::thread::spawn(move || truss::serve_with_config(listener, config));
let get = |path: &str| -> u16 {
let response = common::send_raw_request(
addr,
&format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"),
);
let (header, _, _) = split_response(&response);
common::status_code(&header)
};
let mut shed = false;
for _ in 0..12 {
if get("/images/by-path?path=image.png") == 429 {
shed = true;
}
}
assert!(shed, "the bucket should be exhausted by twelve requests");
for path in ["/health/live", "/health/ready", "/health", "/metrics"] {
assert_ne!(get(path), 429, "{path} should not be rate limited");
}
assert_eq!(
get("/images/by-path?path=image.png"),
429,
"an image route is still shed while the bucket is empty"
);
draining.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = std::net::TcpStream::connect(addr);
let _ = handle.join().expect("join the server thread");
}