Усилить безопасность и надёжность выполнения операций
This commit is contained in:
@@ -1,9 +1,18 @@
|
||||
use std::{collections::BTreeMap, time::Duration};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, io,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crank_core::{HttpMethod, RestTarget};
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::{
|
||||
Client,
|
||||
dns::{Addrs, Name, Resolve, Resolving},
|
||||
header::{HeaderMap, HeaderName, HeaderValue},
|
||||
redirect,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -11,9 +20,19 @@ use crate::{RestAdapterError, RestRequest, RestResponse};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RestAdapter {
|
||||
client: Client,
|
||||
client: Result<Client, Arc<str>>,
|
||||
policy: OutboundHttpPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OutboundHttpPolicy {
|
||||
allowed_hosts: Vec<String>,
|
||||
denied_hosts: Vec<String>,
|
||||
max_response_bytes: usize,
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
impl Default for RestAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -22,9 +41,25 @@ impl Default for RestAdapter {
|
||||
|
||||
impl RestAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
}
|
||||
Self::with_policy(OutboundHttpPolicy::default())
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self, RestAdapterError> {
|
||||
Ok(Self::with_policy(OutboundHttpPolicy::from_env()?))
|
||||
}
|
||||
|
||||
pub fn with_policy(policy: OutboundHttpPolicy) -> Self {
|
||||
let resolver = Arc::new(PolicyDnsResolver {
|
||||
policy: policy.clone(),
|
||||
});
|
||||
let client = Client::builder()
|
||||
.redirect(redirect::Policy::none())
|
||||
.no_proxy()
|
||||
.dns_resolver(resolver)
|
||||
.build()
|
||||
.map_err(|error| Arc::<str>::from(error.to_string()));
|
||||
|
||||
Self { client, policy }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
@@ -33,9 +68,15 @@ impl RestAdapter {
|
||||
request: &RestRequest,
|
||||
) -> Result<RestResponse, RestAdapterError> {
|
||||
let url = build_url(target, request)?;
|
||||
self.policy.validate_url(&url)?;
|
||||
let headers = build_headers(target, request)?;
|
||||
let mut builder = self
|
||||
.client
|
||||
let client =
|
||||
self.client
|
||||
.as_ref()
|
||||
.map_err(|details| RestAdapterError::InvalidConfiguration {
|
||||
details: details.to_string(),
|
||||
})?;
|
||||
let mut builder = client
|
||||
.request(to_reqwest_method(target.method), url)
|
||||
.headers(headers)
|
||||
.timeout(Duration::from_millis(request.timeout_ms));
|
||||
@@ -47,7 +88,7 @@ impl RestAdapter {
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
let headers = normalize_headers(response.headers());
|
||||
let body = decode_body(response).await?;
|
||||
let body = decode_body(response, self.policy.max_response_bytes).await?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(RestAdapterError::UnexpectedStatus {
|
||||
@@ -64,6 +105,254 @@ impl RestAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OutboundHttpPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowed_hosts: Vec::new(),
|
||||
denied_hosts: Vec::new(),
|
||||
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutboundHttpPolicy {
|
||||
pub fn from_env() -> Result<Self, RestAdapterError> {
|
||||
let max_response_bytes = match env::var("CRANK_OUTBOUND_MAX_RESPONSE_BYTES") {
|
||||
Ok(value) => {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| RestAdapterError::InvalidConfiguration {
|
||||
details: "CRANK_OUTBOUND_MAX_RESPONSE_BYTES must be a positive integer"
|
||||
.to_owned(),
|
||||
})?
|
||||
}
|
||||
Err(env::VarError::NotPresent) => DEFAULT_MAX_RESPONSE_BYTES,
|
||||
Err(error) => {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: error.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
if max_response_bytes == 0 {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: "CRANK_OUTBOUND_MAX_RESPONSE_BYTES must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
allowed_hosts: host_patterns_from_env("CRANK_OUTBOUND_ALLOWED_HOSTS")?,
|
||||
denied_hosts: host_patterns_from_env("CRANK_OUTBOUND_DENIED_HOSTS")?,
|
||||
max_response_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn allowing_hosts(hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
Self {
|
||||
allowed_hosts: hosts.into_iter().map(Into::into).collect(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_max_response_bytes(mut self, max_response_bytes: usize) -> Self {
|
||||
self.max_response_bytes = max_response_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(),
|
||||
})?;
|
||||
self.validate_url(&url)
|
||||
}
|
||||
|
||||
fn validate_url(&self, url: &reqwest::Url) -> Result<(), RestAdapterError> {
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: url.to_string(),
|
||||
});
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| RestAdapterError::TargetNotAllowed {
|
||||
target: url.to_string(),
|
||||
})?;
|
||||
self.validate_host(host)?;
|
||||
if !self.is_explicitly_allowed(host) && is_local_hostname(host) {
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: host.to_owned(),
|
||||
});
|
||||
}
|
||||
if let Ok(address) = host.parse::<IpAddr>()
|
||||
&& !self.is_explicitly_allowed(host)
|
||||
&& !is_public_ip(address)
|
||||
{
|
||||
return Err(RestAdapterError::TargetNotAllowed {
|
||||
target: host.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_explicitly_allowed(&self, host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
self.allowed_hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, &host))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PolicyDnsResolver {
|
||||
policy: OutboundHttpPolicy,
|
||||
}
|
||||
|
||||
impl Resolve for PolicyDnsResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let host = normalize_host(name.as_str());
|
||||
let policy = self.policy.clone();
|
||||
Box::pin(async move {
|
||||
policy
|
||||
.validate_host(&host)
|
||||
.map_err(|error| boxed_io_error(error.to_string()))?;
|
||||
let explicitly_allowed = policy.is_explicitly_allowed(&host);
|
||||
let resolved = tokio::net::lookup_host((host.as_str(), 0))
|
||||
.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()))
|
||||
.collect::<Vec<SocketAddr>>();
|
||||
if addresses.is_empty() {
|
||||
return Err(boxed_io_error(format!(
|
||||
"outbound target {host} did not resolve to an allowed address"
|
||||
)));
|
||||
}
|
||||
Ok(Box::new(addresses.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed_io_error(message: String) -> Box<dyn std::error::Error + Send + Sync> {
|
||||
Box::new(io::Error::new(io::ErrorKind::PermissionDenied, message))
|
||||
}
|
||||
|
||||
fn host_patterns_from_env(name: &str) -> Result<Vec<String>, RestAdapterError> {
|
||||
let value = match env::var(name) {
|
||||
Ok(value) => value,
|
||||
Err(env::VarError::NotPresent) => return Ok(Vec::new()),
|
||||
Err(error) => {
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: error.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
let wildcard = value.starts_with("*.");
|
||||
let normalized = normalize_host(value.trim_start_matches("*."));
|
||||
let valid_ip = !wildcard && normalized.parse::<IpAddr>().is_ok();
|
||||
if normalized.is_empty()
|
||||
|| normalized.contains('/')
|
||||
|| (!valid_ip && normalized.contains(':'))
|
||||
|| (wildcard && normalized.parse::<IpAddr>().is_ok())
|
||||
{
|
||||
return Err(RestAdapterError::InvalidConfiguration {
|
||||
details: format!("{name} contains an invalid host pattern: {value}"),
|
||||
});
|
||||
}
|
||||
Ok(if wildcard {
|
||||
format!("*.{normalized}")
|
||||
} else {
|
||||
normalized
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_host(host: &str) -> String {
|
||||
host.trim()
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.trim_end_matches('.')
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn is_local_hostname(host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
host == "localhost" || host.ends_with(".localhost")
|
||||
}
|
||||
|
||||
fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
pattern.strip_prefix("*.").map_or_else(
|
||||
|| pattern == host,
|
||||
|suffix| host != suffix && host.ends_with(&format!(".{suffix}")),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_public_ip(address: IpAddr) -> bool {
|
||||
match address {
|
||||
IpAddr::V4(address) => is_public_ipv4(address),
|
||||
IpAddr::V6(address) => is_public_ipv6(address),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_public_ipv4(address: Ipv4Addr) -> bool {
|
||||
let octets = address.octets();
|
||||
!(address.is_private()
|
||||
|| address.is_loopback()
|
||||
|| address.is_link_local()
|
||||
|| address.is_broadcast()
|
||||
|| address.is_documentation()
|
||||
|| address.is_unspecified()
|
||||
|| address.is_multicast()
|
||||
|| octets[0] == 0
|
||||
|| (octets[0] == 100 && (64..=127).contains(&octets[1]))
|
||||
|| (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
|
||||
|| (octets[0] == 198 && (18..=19).contains(&octets[1]))
|
||||
|| octets[0] >= 240)
|
||||
}
|
||||
|
||||
fn is_public_ipv6(address: Ipv6Addr) -> bool {
|
||||
let segments = address.segments();
|
||||
if let Some(address) = address.to_ipv4_mapped() {
|
||||
return is_public_ipv4(address);
|
||||
}
|
||||
if segments[..6].iter().all(|segment| *segment == 0) {
|
||||
let [a, b] = segments[6].to_be_bytes();
|
||||
let [c, d] = segments[7].to_be_bytes();
|
||||
return is_public_ipv4(Ipv4Addr::new(a, b, c, d));
|
||||
}
|
||||
!(address.is_unspecified()
|
||||
|| address.is_loopback()
|
||||
|| address.is_multicast()
|
||||
|| (segments[0] & 0xfe00) == 0xfc00
|
||||
|| (segments[0] & 0xffc0) == 0xfe80
|
||||
|| (segments[0] & 0xffc0) == 0xfec0
|
||||
|| (segments[0] == 0x0064
|
||||
&& segments[1] == 0xff9b
|
||||
&& segments[2..6].iter().all(|segment| *segment == 0))
|
||||
|| (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1)
|
||||
|| segments[0] == 0x2002
|
||||
|| (segments[0] == 0x2001 && matches!(segments[1], 0 | 0x0db8)))
|
||||
}
|
||||
|
||||
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
|
||||
let base_url =
|
||||
reqwest::Url::parse(&target.base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
|
||||
@@ -127,8 +416,29 @@ fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn decode_body(response: reqwest::Response) -> Result<Value, RestAdapterError> {
|
||||
let bytes = response.bytes().await?;
|
||||
async fn decode_body(
|
||||
response: reqwest::Response,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<Value, RestAdapterError> {
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > max_response_bytes as u64)
|
||||
{
|
||||
return Err(RestAdapterError::ResponseTooLarge {
|
||||
limit_bytes: max_response_bytes,
|
||||
});
|
||||
}
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
if bytes.len().saturating_add(chunk.len()) > max_response_bytes {
|
||||
return Err(RestAdapterError::ResponseTooLarge {
|
||||
limit_bytes: max_response_bytes,
|
||||
});
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
if bytes.is_empty() {
|
||||
return Ok(Value::Null);
|
||||
|
||||
@@ -13,6 +13,12 @@ pub enum RestAdapterError {
|
||||
InvalidHeaderName { header: String },
|
||||
#[error("invalid header value for {header}")]
|
||||
InvalidHeaderValue { header: String },
|
||||
#[error("outbound target is not allowed: {target}")]
|
||||
TargetNotAllowed { target: String },
|
||||
#[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),
|
||||
#[error("sse collection window expired before stream completed")]
|
||||
|
||||
@@ -8,7 +8,7 @@ use crank_core::{
|
||||
ProtocolAdapterError, RestTarget, RuntimeRequestContext, Target,
|
||||
};
|
||||
|
||||
pub use client::RestAdapter;
|
||||
pub use client::{OutboundHttpPolicy, RestAdapter};
|
||||
pub use error::RestAdapterError;
|
||||
pub use model::{RestRequest, RestResponse};
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ use std::collections::BTreeMap;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query},
|
||||
http::HeaderMap,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Redirect,
|
||||
routing::{get, post},
|
||||
};
|
||||
use crank_adapter_rest::{RestAdapter, RestAdapterError, RestRequest};
|
||||
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
|
||||
use crank_core::{HttpMethod, RestTarget};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
@@ -14,7 +15,7 @@ use tokio::net::TcpListener;
|
||||
#[tokio::test]
|
||||
async fn executes_rest_request_and_normalizes_json_response() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = RestAdapter::new();
|
||||
let adapter = test_adapter();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Post,
|
||||
@@ -47,7 +48,7 @@ async fn executes_rest_request_and_normalizes_json_response() {
|
||||
#[tokio::test]
|
||||
async fn returns_unexpected_status_with_normalized_body() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = RestAdapter::new();
|
||||
let adapter = test_adapter();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Get,
|
||||
@@ -73,10 +74,94 @@ async fn returns_unexpected_status_with_normalized_body() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_private_targets_by_default() {
|
||||
let policy = OutboundHttpPolicy::default();
|
||||
|
||||
let error = policy
|
||||
.validate_base_url("http://127.0.0.1:8080")
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, RestAdapterError::TargetNotAllowed { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_explicit_private_ipv4_and_ipv6_targets() {
|
||||
let policy = OutboundHttpPolicy::allowing_hosts(["192.168.1.10", "::1"]);
|
||||
|
||||
assert!(policy.validate_base_url("http://192.168.1.10:8080").is_ok());
|
||||
assert!(policy.validate_base_url("http://[::1]:8080").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn does_not_follow_redirects() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = test_adapter();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/redirect".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let error = adapter
|
||||
.execute(&target, &empty_request())
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RestAdapterError::UnexpectedStatus { status: 303, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_responses_over_the_configured_limit() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = RestAdapter::with_policy(
|
||||
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]).with_max_response_bytes(8),
|
||||
);
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/large".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let error = adapter
|
||||
.execute(&target, &empty_request())
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RestAdapterError::ResponseTooLarge { limit_bytes: 8 }
|
||||
));
|
||||
}
|
||||
|
||||
fn empty_request() -> RestRequest {
|
||||
RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
timeout_ms: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_adapter() -> RestAdapter {
|
||||
RestAdapter::with_policy(OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]))
|
||||
}
|
||||
|
||||
async fn spawn_test_server() -> String {
|
||||
let app = Router::new()
|
||||
.route("/users/{user_id}", post(create_user))
|
||||
.route("/fail", get(fail));
|
||||
.route("/fail", get(fail))
|
||||
.route("/redirect", get(|| async { Redirect::to("/large") }))
|
||||
.route(
|
||||
"/large",
|
||||
get(|| async { "response larger than eight bytes" }),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
@@ -113,7 +198,7 @@ async fn create_user(
|
||||
|
||||
async fn fail() -> (axum::http::StatusCode, Json<Value>) {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": "upstream failed" })),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user