Close outbound credential and metadata bypasses
This commit is contained in:
parent
3088aa439e
commit
c4402985af
2 changed files with 194 additions and 23 deletions
|
|
@ -7,7 +7,7 @@ use std::{
|
|||
|
||||
use ipnet::IpNet;
|
||||
use remora_outbound::{
|
||||
CanonicalUrl, OutboundError, OutboundPolicy, OutboundPolicyBuilder, Resolver,
|
||||
CanonicalUrl, OutboundError, OutboundPolicy, OutboundPolicyBuilder, Resolver, SystemResolver,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
|
|
@ -163,7 +163,18 @@ async fn allowlists_never_override_metadata_denial() {
|
|||
authorize_literal(&policy, "10.1.2.3")
|
||||
.await
|
||||
.expect("allowlisted private address");
|
||||
for address in ["169.254.169.254", "fd00:ec2::254", "::ffff:169.254.169.254"] {
|
||||
for address in [
|
||||
"169.254.169.254",
|
||||
"169.254.170.2",
|
||||
"169.254.0.23",
|
||||
"::ffff:169.254.170.2",
|
||||
"100.100.100.200",
|
||||
"::ffff:100.100.100.200",
|
||||
"192.0.0.192",
|
||||
"fd00:ec2::254",
|
||||
"fd20:ce::254",
|
||||
"fd00:db8:deca:deed::fe",
|
||||
] {
|
||||
assert_eq!(
|
||||
authorize_literal(&policy, address).await,
|
||||
Err(OutboundError::AddressDenied)
|
||||
|
|
@ -171,6 +182,19 @@ async fn allowlists_never_override_metadata_denial() {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_resolver_returns_complete_deduplicated_localhost_answers() {
|
||||
let answers = SystemResolver
|
||||
.resolve("localhost", 80)
|
||||
.await
|
||||
.expect("localhost resolution");
|
||||
assert!(!answers.is_empty());
|
||||
assert!(answers.iter().all(IpAddr::is_loopback));
|
||||
for (index, address) in answers.iter().enumerate() {
|
||||
assert!(!answers[..index].contains(address));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheme_port_policy_is_exact() {
|
||||
let resolver = ScriptedResolver::with("example.test", vec![vec![ip("93.184.216.34")]]);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ struct ObservedRequest {
|
|||
host: Option<String>,
|
||||
authorization: Option<String>,
|
||||
cookie: Option<String>,
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ScriptedResolver {
|
||||
|
|
@ -134,6 +135,7 @@ async fn read_request(stream: &mut (impl AsyncRead + Unpin)) -> ObservedRequest
|
|||
host: headers.get("host").cloned(),
|
||||
authorization: headers.get("authorization").cloned(),
|
||||
cookie: headers.get("cookie").cloned(),
|
||||
api_key: headers.get("x-api-key").cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,9 +203,10 @@ async fn redirects_reauthorize_and_strip_cross_origin_credentials() {
|
|||
HeaderValue::from_static("Bearer synthetic"),
|
||||
);
|
||||
headers.insert("cookie", HeaderValue::from_static("session=synthetic"));
|
||||
headers.insert("x-api-key", HeaderValue::from_static("synthetic-api-key"));
|
||||
let request = OutboundRequest::get(&format!("http://source.test:{port}/redirect"))
|
||||
.expect("request")
|
||||
.with_headers(headers)
|
||||
.with_credential_headers(headers)
|
||||
.expect("headers");
|
||||
let result = client.execute(request).await.expect("response");
|
||||
assert_eq!(result.final_url().host_str(), "other.test");
|
||||
|
|
@ -214,12 +217,40 @@ async fn redirects_reauthorize_and_strip_cross_origin_credentials() {
|
|||
Some("Bearer synthetic")
|
||||
);
|
||||
assert_eq!(observed[0].cookie.as_deref(), Some("session=synthetic"));
|
||||
assert_eq!(observed[0].api_key.as_deref(), Some("synthetic-api-key"));
|
||||
assert_eq!(observed[1].authorization, None);
|
||||
assert_eq!(observed[1].cookie, None);
|
||||
assert_eq!(observed[1].api_key, None);
|
||||
assert_eq!(resolver.calls("source.test"), 1);
|
||||
assert_eq!(resolver.calls("other.test"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_headers_must_be_classified_as_credentials() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", HeaderValue::from_static("synthetic-api-key"));
|
||||
assert_eq!(
|
||||
OutboundRequest::get("https://example.com/")
|
||||
.expect("request")
|
||||
.with_public_headers(headers)
|
||||
.expect_err("unclassified header"),
|
||||
OutboundError::InvalidHeaders
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_debug_redacts_url_and_credential_values() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", HeaderValue::from_static("synthetic-api-key"));
|
||||
let request = OutboundRequest::get("https://example.com/sensitive?token=synthetic")
|
||||
.expect("request")
|
||||
.with_credential_headers(headers)
|
||||
.expect("credential headers");
|
||||
let debug = format!("{request:?}");
|
||||
assert!(!debug.contains("sensitive"));
|
||||
assert!(!debug.contains("synthetic-api-key"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn denied_redirect_is_rejected_before_dial() {
|
||||
let (port, _, server) = serve_http(1, |request| {
|
||||
|
|
@ -375,6 +406,62 @@ async fn tls_verification_uses_canonical_hostname_not_pinned_address() {
|
|||
server.await.expect("server");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ipv6_pinning_preserves_http_authority_and_tls_server_name() {
|
||||
let certified =
|
||||
rcgen::generate_simple_self_signed(vec!["source6.test".to_owned()]).expect("certificate");
|
||||
let certificate_der = certified.cert.der().clone();
|
||||
let key_der = certified.signing_key.serialize_der();
|
||||
let tls = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![certificate_der.clone()],
|
||||
rustls::pki_types::PrivateKeyDer::Pkcs8(key_der.into()),
|
||||
)
|
||||
.expect("TLS config");
|
||||
let listener = match TcpListener::bind("[::1]:0").await {
|
||||
Ok(listener) => listener,
|
||||
Err(error)
|
||||
if error.kind() == io::ErrorKind::AddrNotAvailable
|
||||
&& std::env::var_os("REMORA_OUTBOUND_REQUIRE_IPV6").is_none() =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("IPv6 bind: {error}"),
|
||||
};
|
||||
let port = listener.local_addr().expect("address").port();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("accept");
|
||||
let mut stream = tokio_rustls::TlsAcceptor::from(Arc::new(tls))
|
||||
.accept(stream)
|
||||
.await
|
||||
.expect("TLS handshake");
|
||||
let request = read_request(&mut stream).await;
|
||||
assert_eq!(
|
||||
request.host.as_deref(),
|
||||
Some(format!("source6.test:{port}").as_str())
|
||||
);
|
||||
stream
|
||||
.write_all(response("200 OK", &[], "ok").as_bytes())
|
||||
.await
|
||||
.expect("response");
|
||||
});
|
||||
let policy = OutboundPolicyBuilder::new()
|
||||
.allow_scheme_port("https", port)
|
||||
.allow_non_global("::1/128".parse::<IpNet>().expect("network"))
|
||||
.build();
|
||||
let resolver = ScriptedResolver::new([("source6.test", vec![vec![ip("::1")]])]);
|
||||
let root = reqwest::Certificate::from_der(certificate_der.as_ref()).expect("root");
|
||||
let client = OutboundClient::builder(resolver, policy)
|
||||
.add_root_certificate(root)
|
||||
.build();
|
||||
client
|
||||
.execute(OutboundRequest::get(&format!("https://source6.test:{port}/")).expect("request"))
|
||||
.await
|
||||
.expect("IPv6 TLS response");
|
||||
server.await.expect("server");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_redirect_cannot_downgrade_to_http() {
|
||||
let certified =
|
||||
|
|
|
|||
Loading…
Reference in a new issue