feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
+228 -25
View File
@@ -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));
}
}
+15 -2
View File
@@ -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(),
}
}
}
+40 -2
View File
@@ -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,
},
}
}
}
+3 -1
View File
@@ -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,