feat: complete Epic 1 production foundation
This commit is contained in:
@@ -37,10 +37,12 @@ pub struct RestAdapter {
|
||||
pub struct OutboundHttpPolicy {
|
||||
allowed_hosts: Vec<String>,
|
||||
denied_hosts: Vec<String>,
|
||||
max_request_bytes: usize,
|
||||
max_response_bytes: usize,
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||
const DEFAULT_MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
impl Default for RestAdapter {
|
||||
fn default() -> Self {
|
||||
@@ -152,12 +154,19 @@ impl RestAdapter {
|
||||
.timeout(Duration::from_millis(request.timeout_ms));
|
||||
|
||||
if let Some(body) = &request.body {
|
||||
enforce_request_body_limit(body, self.policy.max_request_bytes)?;
|
||||
builder = builder.json(body);
|
||||
}
|
||||
|
||||
if let Some(context) = trusted_context {
|
||||
context.mark_dispatch_started();
|
||||
}
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
let headers = normalize_headers(response.headers());
|
||||
if status.is_redirection() {
|
||||
return Err(RestAdapterError::RedirectNotAllowed);
|
||||
}
|
||||
let body = decode_body(response, self.policy.max_response_bytes).await?;
|
||||
|
||||
if !status.is_success() {
|
||||
@@ -195,10 +204,13 @@ fn upstream_outcome(error: &RestAdapterError) -> UpstreamOutcome {
|
||||
UpstreamOutcome::ServerError
|
||||
}
|
||||
RestAdapterError::UnexpectedStatus { .. } => UpstreamOutcome::UnexpectedStatus,
|
||||
RestAdapterError::Transport(error) if error.is_timeout() => UpstreamOutcome::Timeout,
|
||||
RestAdapterError::Transport(_) => UpstreamOutcome::TransportError,
|
||||
RestAdapterError::Transport { timeout: true, .. } => UpstreamOutcome::Timeout,
|
||||
RestAdapterError::Transport { .. } => UpstreamOutcome::TransportError,
|
||||
RestAdapterError::ResponseTooLarge { .. } => UpstreamOutcome::ResponseTooLarge,
|
||||
RestAdapterError::TargetNotAllowed { .. } => UpstreamOutcome::Rejected,
|
||||
RestAdapterError::RequestTooLarge { .. } => UpstreamOutcome::InvalidRequest,
|
||||
RestAdapterError::TargetNotAllowed { .. } | RestAdapterError::RedirectNotAllowed => {
|
||||
UpstreamOutcome::Rejected
|
||||
}
|
||||
RestAdapterError::WindowExpired => UpstreamOutcome::WindowExpired,
|
||||
RestAdapterError::InvalidSseEvent => UpstreamOutcome::InvalidResponse,
|
||||
RestAdapterError::InvalidBaseUrl { .. }
|
||||
@@ -215,6 +227,7 @@ impl Default for OutboundHttpPolicy {
|
||||
Self {
|
||||
allowed_hosts: Vec::new(),
|
||||
denied_hosts: Vec::new(),
|
||||
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
||||
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
|
||||
}
|
||||
}
|
||||
@@ -226,6 +239,25 @@ impl OutboundHttpPolicy {
|
||||
denied_hosts: Vec<String>,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<Self, RestAdapterError> {
|
||||
Self::try_new_with_limits(
|
||||
allowed_hosts,
|
||||
denied_hosts,
|
||||
DEFAULT_MAX_REQUEST_BYTES,
|
||||
max_response_bytes,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn try_new_with_limits(
|
||||
allowed_hosts: Vec<String>,
|
||||
denied_hosts: Vec<String>,
|
||||
max_request_bytes: usize,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<Self, RestAdapterError> {
|
||||
if max_request_bytes == 0 {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: "outbound request limit must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
if max_response_bytes == 0 {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: "outbound response limit must be greater than zero".to_owned(),
|
||||
@@ -234,6 +266,7 @@ impl OutboundHttpPolicy {
|
||||
Ok(Self {
|
||||
allowed_hosts: validate_host_patterns(allowed_hosts)?,
|
||||
denied_hosts: validate_host_patterns(denied_hosts)?,
|
||||
max_request_bytes,
|
||||
max_response_bytes,
|
||||
})
|
||||
}
|
||||
@@ -250,9 +283,14 @@ impl OutboundHttpPolicy {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_request_bytes(mut self, max_request_bytes: usize) -> Self {
|
||||
self.max_request_bytes = max_request_bytes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn validate_base_url(&self, base_url: &str) -> Result<(), RestAdapterError> {
|
||||
let url = reqwest::Url::parse(base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
||||
url: base_url.to_owned(),
|
||||
url: "url".to_owned(),
|
||||
})?;
|
||||
self.validate_url(&url)
|
||||
}
|
||||
@@ -263,18 +301,18 @@ impl OutboundHttpPolicy {
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: url.to_string(),
|
||||
target: "url".to_owned(),
|
||||
});
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| RestAdapterError::TargetNotAllowed {
|
||||
target: url.to_string(),
|
||||
target: "host".to_owned(),
|
||||
})?;
|
||||
self.validate_host(host)?;
|
||||
if !self.is_explicitly_allowed(host) && is_local_hostname(host) {
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: host.to_owned(),
|
||||
target: "host".to_owned(),
|
||||
});
|
||||
}
|
||||
if let Ok(address) = host.parse::<IpAddr>()
|
||||
@@ -282,7 +320,7 @@ impl OutboundHttpPolicy {
|
||||
&& !is_public_ip(address)
|
||||
{
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: host.to_owned(),
|
||||
target: "ip".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -290,16 +328,29 @@ impl OutboundHttpPolicy {
|
||||
|
||||
fn validate_host(&self, host: &str) -> Result<(), RestAdapterError> {
|
||||
let host = normalize_host(host);
|
||||
let denied = self
|
||||
.denied_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host));
|
||||
if denied {
|
||||
return Err(RestAdapterError::TargetNotAllowed { target: host });
|
||||
if self.is_denied_host(&host) {
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: "host".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_denied_host(&self, host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
self.denied_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host))
|
||||
}
|
||||
|
||||
fn is_denied_ip(&self, address: IpAddr) -> bool {
|
||||
ip_match_hosts(address).iter().any(|host| {
|
||||
self.denied_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, host))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_explicitly_allowed(&self, host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
self.allowed_hosts
|
||||
@@ -326,12 +377,15 @@ impl Resolve for PolicyDnsResolver {
|
||||
.await
|
||||
.map_err(|error| Box::new(error) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||
let addresses = resolved
|
||||
.filter(|address| explicitly_allowed || is_public_ip(address.ip()))
|
||||
.filter(|address| {
|
||||
!policy.is_denied_ip(address.ip())
|
||||
&& (explicitly_allowed || is_public_ip(address.ip()))
|
||||
})
|
||||
.collect::<Vec<SocketAddr>>();
|
||||
if addresses.is_empty() {
|
||||
return Err(boxed_io_error(format!(
|
||||
"outbound target {host} did not resolve to an allowed address"
|
||||
)));
|
||||
return Err(boxed_io_error(
|
||||
"outbound target did not resolve to an allowed address".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Box::new(addresses.into_iter()) as Addrs)
|
||||
})
|
||||
@@ -396,6 +450,26 @@ fn is_public_ip(address: IpAddr) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn ip_match_hosts(address: IpAddr) -> Vec<String> {
|
||||
match address {
|
||||
IpAddr::V4(address) => vec![address.to_string()],
|
||||
IpAddr::V6(address) => {
|
||||
let mut hosts = vec![address.to_string()];
|
||||
if let Some(mapped) = address.to_ipv4_mapped() {
|
||||
hosts.push(mapped.to_string());
|
||||
} else {
|
||||
let segments = address.segments();
|
||||
if segments[..6].iter().all(|segment| *segment == 0) {
|
||||
let [a, b] = segments[6].to_be_bytes();
|
||||
let [c, d] = segments[7].to_be_bytes();
|
||||
hosts.push(Ipv4Addr::new(a, b, c, d).to_string());
|
||||
}
|
||||
}
|
||||
hosts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_public_ipv4(address: Ipv4Addr) -> bool {
|
||||
let octets = address.octets();
|
||||
!(address.is_private()
|
||||
@@ -439,12 +513,12 @@ fn is_public_ipv6(address: Ipv6Addr) -> bool {
|
||||
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
|
||||
let base_url =
|
||||
reqwest::Url::parse(&target.base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
||||
url: target.base_url.clone(),
|
||||
url: "url".to_owned(),
|
||||
})?;
|
||||
let path = substitute_path_params(&target.path_template, &request.path_params);
|
||||
let mut url = base_url.join(path.trim_start_matches('/')).map_err(|_| {
|
||||
RestAdapterError::InvalidBaseUrl {
|
||||
url: target.base_url.clone(),
|
||||
url: "url".to_owned(),
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -475,24 +549,49 @@ fn build_headers(
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
for (name, value) in &target.static_headers {
|
||||
insert_header(&mut headers, name, value)?;
|
||||
insert_header(&mut headers, name, value, HeaderSource::StaticTarget)?;
|
||||
}
|
||||
|
||||
for (name, value) in &request.headers {
|
||||
insert_header(&mut headers, name, value)?;
|
||||
let trusted = request
|
||||
.trusted_header_names
|
||||
.iter()
|
||||
.any(|trusted| trusted.eq_ignore_ascii_case(name));
|
||||
insert_header(
|
||||
&mut headers,
|
||||
name,
|
||||
value,
|
||||
HeaderSource::PreparedRequest { trusted },
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), RestAdapterError> {
|
||||
#[derive(Clone, Copy)]
|
||||
enum HeaderSource {
|
||||
StaticTarget,
|
||||
PreparedRequest { trusted: bool },
|
||||
}
|
||||
|
||||
fn insert_header(
|
||||
headers: &mut HeaderMap,
|
||||
name: &str,
|
||||
value: &str,
|
||||
source: HeaderSource,
|
||||
) -> Result<(), RestAdapterError> {
|
||||
let header_name =
|
||||
HeaderName::try_from(name).map_err(|_| RestAdapterError::InvalidHeaderName {
|
||||
header: name.to_owned(),
|
||||
})?;
|
||||
if is_reserved_correlation_header(&header_name) {
|
||||
if is_ignored_reserved_header(&header_name) {
|
||||
return Ok(());
|
||||
}
|
||||
if is_forbidden_reserved_header(&header_name, source) {
|
||||
return Err(RestAdapterError::InvalidHeaderName {
|
||||
header: header_name.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
let header_value =
|
||||
HeaderValue::try_from(value).map_err(|_| RestAdapterError::InvalidHeaderValue {
|
||||
header: name.to_owned(),
|
||||
@@ -502,7 +601,7 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_reserved_correlation_header(name: &HeaderName) -> bool {
|
||||
fn is_ignored_reserved_header(name: &HeaderName) -> bool {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"traceparent"
|
||||
@@ -514,6 +613,22 @@ fn is_reserved_correlation_header(name: &HeaderName) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_forbidden_reserved_header(name: &HeaderName, source: HeaderSource) -> bool {
|
||||
let framing = matches!(
|
||||
name.as_str(),
|
||||
"host" | "content-length" | "transfer-encoding" | "connection" | "upgrade"
|
||||
);
|
||||
let credential_or_safety = matches!(
|
||||
name.as_str(),
|
||||
"authorization" | "cookie" | "idempotency-key"
|
||||
);
|
||||
let untrusted_credential_or_safety = match source {
|
||||
HeaderSource::StaticTarget => credential_or_safety,
|
||||
HeaderSource::PreparedRequest { trusted } => credential_or_safety && !trusted,
|
||||
};
|
||||
framing || untrusted_credential_or_safety
|
||||
}
|
||||
|
||||
fn set_span_parent_from_traceparent(span: &Span, traceparent: &str) -> bool {
|
||||
let mut parts = traceparent.split('-');
|
||||
let (Some("00"), Some(trace_id), Some(parent_id), Some(flags), None) = (
|
||||
@@ -612,6 +727,59 @@ async fn decode_body(
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_request_body_limit(
|
||||
body: &Value,
|
||||
max_request_bytes: usize,
|
||||
) -> Result<(), RestAdapterError> {
|
||||
if max_request_bytes == 0 {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: "outbound request limit must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
let mut writer = LimitWriter::new(max_request_bytes);
|
||||
let result = serde_json::to_writer(&mut writer, body);
|
||||
if writer.exceeded {
|
||||
return Err(RestAdapterError::RequestTooLarge {
|
||||
limit_bytes: max_request_bytes,
|
||||
});
|
||||
}
|
||||
result.map_err(|_| RestAdapterError::InvalidHeaderValue {
|
||||
header: "body".to_owned(),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct LimitWriter {
|
||||
written: usize,
|
||||
limit: usize,
|
||||
exceeded: bool,
|
||||
}
|
||||
|
||||
impl LimitWriter {
|
||||
fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
written: 0,
|
||||
limit,
|
||||
exceeded: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Write for LimitWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
if self.written.saturating_add(buf.len()) > self.limit {
|
||||
self.exceeded = true;
|
||||
return Err(io::Error::other("request body exceeds configured limit"));
|
||||
}
|
||||
self.written += buf.len();
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
|
||||
headers
|
||||
.iter()
|
||||
@@ -633,3 +801,38 @@ fn to_reqwest_method(method: HttpMethod) -> reqwest::Method {
|
||||
HttpMethod::Delete => reqwest::Method::DELETE,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{OutboundHttpPolicy, ip_match_hosts};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn denied_resolved_ip_overrides_explicitly_allowed_hostname() {
|
||||
let policy = OutboundHttpPolicy::try_new_with_limits(
|
||||
vec!["api.example.test".to_owned()],
|
||||
vec!["169.254.169.254".to_owned()],
|
||||
4 * 1024 * 1024,
|
||||
4 * 1024 * 1024,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.is_explicitly_allowed("api.example.test"));
|
||||
assert!(policy.is_denied_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_resolved_ip_checks_ipv4_mapped_ipv6_alias() {
|
||||
let policy = OutboundHttpPolicy::try_new_with_limits(
|
||||
vec!["api.example.test".to_owned()],
|
||||
vec!["127.0.0.1".to_owned()],
|
||||
4 * 1024 * 1024,
|
||||
4 * 1024 * 1024,
|
||||
)
|
||||
.unwrap();
|
||||
let mapped = IpAddr::V6(Ipv6Addr::from(0xffff_7f00_0001u128));
|
||||
|
||||
assert!(ip_match_hosts(mapped).contains(&"127.0.0.1".to_owned()));
|
||||
assert!(policy.is_denied_ip(mapped));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,18 @@ pub enum RestAdapterError {
|
||||
InvalidHeaderName { header: String },
|
||||
#[error("invalid header value for {header}")]
|
||||
InvalidHeaderValue { header: String },
|
||||
#[error("outbound target is not allowed: {target}")]
|
||||
#[error("outbound target is not allowed")]
|
||||
TargetNotAllowed { target: String },
|
||||
#[error("outbound redirects are not allowed")]
|
||||
RedirectNotAllowed,
|
||||
#[error("rest request exceeds the configured limit of {limit_bytes} bytes")]
|
||||
RequestTooLarge { limit_bytes: usize },
|
||||
#[error("rest response exceeds the configured limit of {limit_bytes} bytes")]
|
||||
ResponseTooLarge { limit_bytes: usize },
|
||||
#[error("invalid outbound HTTP configuration: {details}")]
|
||||
InvalidConfiguration { details: String },
|
||||
#[error("request failed")]
|
||||
Transport(#[from] reqwest::Error),
|
||||
Transport { timeout: bool, connect: bool },
|
||||
#[error("sse collection window expired before stream completed")]
|
||||
WindowExpired,
|
||||
#[error("rest endpoint returned status {status}")]
|
||||
@@ -28,3 +32,12 @@ pub enum RestAdapterError {
|
||||
#[error("sse stream produced malformed event payload")]
|
||||
InvalidSseEvent,
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for RestAdapterError {
|
||||
fn from(value: reqwest::Error) -> Self {
|
||||
Self::Transport {
|
||||
timeout: value.is_timeout(),
|
||||
connect: value.is_connect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ mod model;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crank_core::{
|
||||
AdapterResponse, ExecutionMode, PreparedRequest, Protocol, ProtocolAdapter,
|
||||
AdapterResponse, DispatchEvidence, ExecutionMode, PreparedRequest, Protocol, ProtocolAdapter,
|
||||
ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target,
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ impl ProtocolAdapter for RestAdapter {
|
||||
path_params: prepared.path_params.clone(),
|
||||
query_params: prepared.query_params.clone(),
|
||||
headers: prepared.headers.clone(),
|
||||
trusted_header_names: prepared.trusted_header_names.clone(),
|
||||
body: prepared.body.clone(),
|
||||
timeout_ms: prepared.timeout_ms,
|
||||
};
|
||||
@@ -55,6 +56,43 @@ fn rest_target(target: &Target) -> Result<&RestTarget, ProtocolAdapterError> {
|
||||
|
||||
impl From<RestAdapterError> for ProtocolAdapterError {
|
||||
fn from(value: RestAdapterError) -> Self {
|
||||
ProtocolAdapterError::Message(value.to_string())
|
||||
match value {
|
||||
RestAdapterError::InvalidBaseUrl { .. }
|
||||
| RestAdapterError::InvalidConfiguration { .. } => Self::InvalidConfiguration,
|
||||
RestAdapterError::InvalidPathParameter { .. }
|
||||
| RestAdapterError::InvalidQueryParameter { .. }
|
||||
| RestAdapterError::InvalidHeaderName { .. }
|
||||
| RestAdapterError::InvalidHeaderValue { .. } => Self::InvalidPreparedRequest,
|
||||
RestAdapterError::RedirectNotAllowed => Self::TargetRejected,
|
||||
RestAdapterError::TargetNotAllowed { .. } => Self::TargetRejected,
|
||||
RestAdapterError::RequestTooLarge { .. } => Self::RequestTooLarge,
|
||||
RestAdapterError::ResponseTooLarge { .. } => Self::ResponseTooLarge {
|
||||
dispatch: DispatchEvidence::MayHaveDispatched,
|
||||
},
|
||||
RestAdapterError::Transport { timeout, connect } if timeout => Self::Timeout {
|
||||
dispatch: if connect {
|
||||
DispatchEvidence::NotDispatched
|
||||
} else {
|
||||
DispatchEvidence::MayHaveDispatched
|
||||
},
|
||||
},
|
||||
RestAdapterError::Transport { connect, .. } => Self::Transport {
|
||||
dispatch: if connect {
|
||||
DispatchEvidence::NotDispatched
|
||||
} else {
|
||||
DispatchEvidence::MayHaveDispatched
|
||||
},
|
||||
},
|
||||
RestAdapterError::WindowExpired => Self::Timeout {
|
||||
dispatch: DispatchEvidence::MayHaveDispatched,
|
||||
},
|
||||
RestAdapterError::UnexpectedStatus { status, .. } => Self::UnexpectedStatus {
|
||||
status,
|
||||
dispatch: DispatchEvidence::MayHaveDispatched,
|
||||
},
|
||||
RestAdapterError::InvalidSseEvent => Self::InvalidResponse {
|
||||
dispatch: DispatchEvidence::MayHaveDispatched,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -11,6 +11,8 @@ pub struct RestRequest {
|
||||
pub query_params: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub trusted_header_names: BTreeSet<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<Value>,
|
||||
pub timeout_ms: u64,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod integration {
|
||||
mod client;
|
||||
mod outbound_security;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
@@ -9,7 +9,8 @@ use axum::{
|
||||
};
|
||||
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
|
||||
use crank_core::{
|
||||
HttpMethod, PreparedRequest, ProtocolAdapter, RestTarget, RuntimeRequestContext, Target,
|
||||
DispatchEvidence, HttpMethod, PreparedRequest, ProtocolAdapter, ProtocolAdapterError,
|
||||
RestTarget, RuntimeRequestContext, Target,
|
||||
};
|
||||
use opentelemetry::{
|
||||
global,
|
||||
@@ -38,6 +39,7 @@ async fn executes_rest_request_and_normalizes_json_response() {
|
||||
headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let response = adapter.execute(&target, &request).await.unwrap();
|
||||
@@ -55,6 +57,77 @@ async fn executes_rest_request_and_normalizes_json_response() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_auth_profile_header_is_allowed_after_runtime_resolution() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = test_adapter();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/users/{user_id}".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestRequest {
|
||||
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::from([(
|
||||
"authorization".to_owned(),
|
||||
"Bearer resolved-auth-profile-token".to_owned(),
|
||||
)]),
|
||||
trusted_header_names: BTreeSet::from(["authorization".to_owned()]),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
};
|
||||
|
||||
let response = adapter.execute(&target, &request).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response.body["authorization"],
|
||||
"Bearer resolved-auth-profile-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn untrusted_prepared_credential_header_is_rejected_before_dispatch() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = test_adapter();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/users/{user_id}".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestRequest {
|
||||
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::from([(
|
||||
"authorization".to_owned(),
|
||||
"Bearer user-mapped-secret".to_owned(),
|
||||
)]),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let error = adapter.execute(&target, &request).await.unwrap_err();
|
||||
|
||||
assert!(matches!(error, RestAdapterError::InvalidHeaderName { .. }));
|
||||
assert!(!format!("{error:?} {error}").contains("user-mapped-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_error_debug_is_redacted() {
|
||||
let error = RestAdapterError::Transport {
|
||||
timeout: false,
|
||||
connect: true,
|
||||
};
|
||||
let rendered = format!("{error:?} {error}");
|
||||
|
||||
assert!(!rendered.contains("http://"));
|
||||
assert!(!rendered.contains("story19-transport-canary"));
|
||||
assert!(rendered.contains("Transport"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_context_overrides_mapped_correlation_headers() {
|
||||
let base_url = spawn_test_server().await;
|
||||
@@ -134,6 +207,7 @@ async fn current_trace_context_overrides_mapped_traceparent() {
|
||||
)]),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let response = test_adapter()
|
||||
@@ -171,6 +245,7 @@ async fn user_configured_propagation_headers_are_removed_without_trusted_context
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(json!({ "name": "Ada" })),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let response = test_adapter().execute(&target, &request).await.unwrap();
|
||||
@@ -196,17 +271,54 @@ async fn returns_unexpected_status_with_normalized_body() {
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let error = adapter.execute(&target, &request).await.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
&error,
|
||||
RestAdapterError::UnexpectedStatus {
|
||||
status: 502,
|
||||
body: Value::Object(_)
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
ProtocolAdapterError::from(error),
|
||||
ProtocolAdapterError::UnexpectedStatus {
|
||||
status: 502,
|
||||
dispatch: DispatchEvidence::MayHaveDispatched,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_failure_is_known_not_dispatched() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let target = RestTarget {
|
||||
base_url: format!("http://{address}"),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/write".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(json!({"value": 1})),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
};
|
||||
|
||||
let error = test_adapter().execute(&target, &request).await.unwrap_err();
|
||||
assert!(matches!(
|
||||
ProtocolAdapterError::from(error),
|
||||
ProtocolAdapterError::Transport {
|
||||
dispatch: DispatchEvidence::NotDispatched,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -244,10 +356,7 @@ async fn does_not_follow_redirects() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RestAdapterError::UnexpectedStatus { status: 303, .. }
|
||||
));
|
||||
assert!(matches!(error, RestAdapterError::RedirectNotAllowed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -281,6 +390,7 @@ fn empty_request() -> RestRequest {
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +444,9 @@ async fn create_user(
|
||||
.get("tracestate")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
let baggage = headers.get("baggage").and_then(|value| value.to_str().ok());
|
||||
let authorization = headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
|
||||
let mut response = json!({
|
||||
"id": user_id,
|
||||
@@ -370,6 +483,12 @@ async fn create_user(
|
||||
if let Some(baggage) = baggage {
|
||||
response.insert("baggage".to_owned(), Value::String(baggage.to_owned()));
|
||||
}
|
||||
if let Some(authorization) = authorization {
|
||||
response.insert(
|
||||
"authorization".to_owned(),
|
||||
Value::String(authorization.to_owned()),
|
||||
);
|
||||
}
|
||||
|
||||
Json(Value::Object(response.clone()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
net::SocketAddr,
|
||||
process::Command,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
routing::{any, post},
|
||||
};
|
||||
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
|
||||
use crank_core::{HttpMethod, ProtocolAdapterError, RestTarget};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[tokio::test]
|
||||
async fn forbidden_credential_and_framing_headers_fail_before_bytes_leave() {
|
||||
let observed_requests = Arc::new(AtomicUsize::new(0));
|
||||
let base_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
|
||||
let adapter = RestAdapter::with_policy(OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]));
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/capture".to_owned(),
|
||||
static_headers: BTreeMap::from([
|
||||
(
|
||||
"authorization".to_owned(),
|
||||
"Bearer story19-static-secret".to_owned(),
|
||||
),
|
||||
("host".to_owned(), "metadata.internal".to_owned()),
|
||||
]),
|
||||
};
|
||||
|
||||
let error = adapter
|
||||
.execute(&target, &json_request(json!({"value": "must-not-leave"})))
|
||||
.await
|
||||
.expect_err("forbidden headers must reject before dispatch");
|
||||
|
||||
assert!(matches!(error, RestAdapterError::InvalidHeaderName { .. }));
|
||||
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
|
||||
let rendered = format!("{error:?} {error}");
|
||||
assert!(!rendered.contains("story19-static-secret"));
|
||||
assert!(!rendered.contains("metadata.internal"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_body_limit_fails_before_bytes_leave() {
|
||||
let observed_requests = Arc::new(AtomicUsize::new(0));
|
||||
let base_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
|
||||
let adapter = RestAdapter::with_policy(
|
||||
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]).with_max_request_bytes(16),
|
||||
);
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/capture".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let error = adapter
|
||||
.execute(
|
||||
&target,
|
||||
&json_request(json!({"payload": "story19-body-canary-that-exceeds-limit"})),
|
||||
)
|
||||
.await
|
||||
.expect_err("oversized request body must fail before dispatch");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RestAdapterError::RequestTooLarge { limit_bytes: 16 }
|
||||
));
|
||||
let rendered = format!("{error:?} {error}");
|
||||
assert_eq!(
|
||||
ProtocolAdapterError::from(error),
|
||||
ProtocolAdapterError::RequestTooLarge
|
||||
);
|
||||
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
|
||||
assert!(!rendered.contains("story19-body-canary"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_environment_is_ignored_by_default() {
|
||||
const CHILD_ENV: &str = "CRANK_OUTBOUND_PROXY_CHILD";
|
||||
const TARGET_ENV: &str = "CRANK_OUTBOUND_PROXY_TARGET";
|
||||
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
let target = std::env::var(TARGET_ENV).expect("target url passed by parent");
|
||||
let adapter = RestAdapter::default();
|
||||
let request = json_request(json!({"payload": "proxy-env-canary"}));
|
||||
let rest_target = RestTarget {
|
||||
base_url: target,
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/capture".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let _ = adapter.execute(&rest_target, &request).await.expect_err(
|
||||
"unresolvable target should fail locally instead of being sent through proxy env",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let observed_requests = Arc::new(AtomicUsize::new(0));
|
||||
let proxy_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
|
||||
let current_exe = std::env::current_exe().expect("current test binary");
|
||||
let status = Command::new(current_exe)
|
||||
.arg("--exact")
|
||||
.arg("integration::outbound_security::proxy_environment_is_ignored_by_default")
|
||||
.arg("--nocapture")
|
||||
.env(CHILD_ENV, "1")
|
||||
.env(TARGET_ENV, "http://public.example.test/capture")
|
||||
.env("HTTP_PROXY", &proxy_url)
|
||||
.env("HTTPS_PROXY", &proxy_url)
|
||||
.env("ALL_PROXY", &proxy_url)
|
||||
.status()
|
||||
.expect("spawn child proxy regression");
|
||||
|
||||
assert!(status.success());
|
||||
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_target_diagnostic_is_redacted() {
|
||||
let error = OutboundHttpPolicy::default()
|
||||
.validate_base_url("http://user:story19-url-secret@127.0.0.1:8080/private")
|
||||
.expect_err("userinfo and private target must be rejected");
|
||||
|
||||
let rendered = format!("{error:?} {error}");
|
||||
assert!(!rendered.contains("story19-url-secret"));
|
||||
assert!(!rendered.contains("127.0.0.1"));
|
||||
assert!(!rendered.contains("/private"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_rule_wins_over_explicit_allow() {
|
||||
let policy = OutboundHttpPolicy::try_new(
|
||||
vec!["api.example.test".to_owned()],
|
||||
vec!["api.example.test".to_owned()],
|
||||
4 * 1024 * 1024,
|
||||
)
|
||||
.expect("valid policy");
|
||||
|
||||
let error = policy
|
||||
.validate_base_url("https://api.example.test/users")
|
||||
.expect_err("deny must override allow");
|
||||
|
||||
assert!(matches!(error, RestAdapterError::TargetNotAllowed { .. }));
|
||||
}
|
||||
|
||||
fn json_request(body: Value) -> RestRequest {
|
||||
RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(body),
|
||||
timeout_ms: 1_000,
|
||||
..RestRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_counting_server(observed_requests: Arc<AtomicUsize>) -> String {
|
||||
let app = Router::new()
|
||||
.route("/capture", post(capture))
|
||||
.fallback(any(capture_any))
|
||||
.with_state(observed_requests);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
async fn capture(State(observed_requests): State<Arc<AtomicUsize>>) -> Json<Value> {
|
||||
observed_requests.fetch_add(1, Ordering::SeqCst);
|
||||
Json(json!({"ok": true}))
|
||||
}
|
||||
|
||||
async fn capture_any(State(observed_requests): State<Arc<AtomicUsize>>) -> StatusCode {
|
||||
observed_requests.fetch_add(1, Ordering::SeqCst);
|
||||
StatusCode::OK
|
||||
}
|
||||
Reference in New Issue
Block a user