#![cfg(feature = "outbound-http")]
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener};
use plecto_host::test_support::{TestSigner, bound_sbom, filter_extauthz_component};
use plecto_host::{
AllowEntry, Header, Host, HttpRequest, LoadOptions, LoadedFilter, RequestDecision,
RequestTrace, Scheme, SignedArtifact,
};
fn spawn_ok_server() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut s) = stream else { continue };
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf);
let _ = s.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok");
}
});
addr
}
fn signed_load(opts: LoadOptions) -> (Host, LoadedFilter) {
let bytes = filter_extauthz_component();
let signer = TestSigner::new().unwrap();
let component_signature = signer.sign(&bytes).unwrap();
let sbom = bound_sbom(&bytes);
let sbom_signature = signer.sign(&sbom).unwrap();
let host = Host::new(signer.trust_policy().unwrap()).unwrap();
let artifact = SignedArtifact {
component_bytes: &bytes,
component_signature: &component_signature,
sbom: &sbom,
sbom_signature: &sbom_signature,
};
let filter = host
.load("filter-extauthz", &artifact, opts)
.expect("load filter-extauthz");
(host, filter)
}
fn request(authz_url: &str) -> HttpRequest {
HttpRequest {
method: "GET".to_string(),
path: "/protected".to_string(),
authority: "gateway.test".to_string(),
scheme: "https".to_string(),
headers: vec![Header {
name: "x-authz-url".to_string(),
value: authz_url.as_bytes().to_vec(),
}],
}
}
fn outbound_opts(allow: Vec<AllowEntry>) -> LoadOptions {
LoadOptions::untrusted().with_outbound_http(
allow,
vec![],
Some(2_000),
Some(5_000),
Some(64 * 1024),
Some(8),
)
}
fn short_circuit_body(f: &LoadedFilter, r: &HttpRequest) -> (u16, String) {
let (decision, _logs) = f
.on_request(r, &RequestTrace::root())
.expect("run on_request");
match decision {
RequestDecision::ShortCircuit(resp) => (
resp.status,
String::from_utf8_lossy(&resp.body).into_owned(),
),
other => panic!("expected a fail-closed short-circuit, got {other:?}"),
}
}
#[test]
fn unlisted_destination_is_denied_before_any_connection() {
let opts = outbound_opts(vec![AllowEntry {
scheme: Scheme::Https,
host: "authz.allowed.test".to_string(),
port: 443,
}]);
let (_host, filter) = signed_load(opts);
let (status, body) = short_circuit_body(&filter, &request("https://evil.example.test/authz"));
assert_eq!(status, 403, "an unlisted destination must fail closed");
assert!(
body.contains("HttpRequestDenied"),
"the allowlist gate denies it (body: {body:?})"
);
}
#[test]
fn allowlisted_name_resolving_to_loopback_is_ssrf_blocked() {
let addr = spawn_ok_server();
let opts = outbound_opts(vec![AllowEntry {
scheme: Scheme::Http,
host: "localhost".to_string(),
port: addr.port(),
}]);
let (_host, filter) = signed_load(opts);
let url = format!("http://localhost:{}/authz", addr.port());
let (status, body) = short_circuit_body(&filter, &request(&url));
assert_eq!(status, 403, "a loopback-resolving target must fail closed");
assert!(
body.contains("DestinationIpProhibited"),
"the SSRF guard blocks the resolved loopback address (body: {body:?})"
);
}
#[test]
fn no_authz_url_fails_closed() {
let opts = outbound_opts(vec![AllowEntry {
scheme: Scheme::Https,
host: "authz.allowed.test".to_string(),
port: 443,
}]);
let (_host, filter) = signed_load(opts);
let req = HttpRequest {
method: "GET".to_string(),
path: "/protected".to_string(),
authority: "gateway.test".to_string(),
scheme: "https".to_string(),
headers: vec![],
};
let (status, _body) = short_circuit_body(&filter, &req);
assert_eq!(status, 403);
}