Усилить безопасность и надёжность выполнения операций
CI / Rust Checks (push) Successful in 5m7s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m9s
CI / Deploy (push) Successful in 1m41s

This commit is contained in:
2026-07-11 14:08:07 +03:00
parent 626f2845e2
commit 8318e4b560
40 changed files with 1343 additions and 185 deletions
+320 -10
View File
@@ -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);
+6
View File
@@ -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")]
+1 -1
View File
@@ -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" })),
)
}