use crate::core::error::{Error, Result};
use blueprint_core::{debug, warn};
use blueprint_std::time::Duration;
use reqwest::{Client, ClientBuilder, Request, Response, header};
use url::Url;
pub struct SecureHttpClient {
client: Client,
max_response_size: usize,
timeout: Duration,
}
impl SecureHttpClient {
pub fn new() -> Result<Self> {
let client = ClientBuilder::new()
.timeout(Duration::from_secs(30))
.user_agent("blueprint-remote-providers/1.0.0")
.use_rustls_tls() .https_only(true) .tcp_keepalive(Duration::from_secs(60))
.connection_verbose(false) .build()
.map_err(|e| Error::ConfigurationError(format!("Failed to create HTTP client: {e}")))?;
Ok(Self {
client,
max_response_size: 10 * 1024 * 1024, timeout: Duration::from_secs(30),
})
}
pub async fn authenticated_request(
&self,
method: reqwest::Method,
url: &str,
auth: &ApiAuthentication,
body: Option<serde_json::Value>,
) -> Result<Response> {
let parsed_url = self.validate_url(url)?;
let mut request_builder = self.client.request(method, parsed_url.clone());
request_builder =
self.add_authentication(request_builder, auth, &parsed_url, body.as_ref())?;
request_builder = request_builder
.header(header::USER_AGENT, "blueprint-remote-providers/1.0.0")
.header("X-Client-Version", "1.0.0")
.header("X-Request-ID", uuid::Uuid::new_v4().to_string());
if let Some(body) = body {
request_builder = request_builder.json(&body);
}
let request = request_builder
.build()
.map_err(|e| Error::ConfigurationError(format!("Failed to build request: {e}")))?;
self.validate_request(&request)?;
debug!("Making authenticated request to: {}", url);
let response = tokio::time::timeout(self.timeout, self.client.execute(request))
.await
.map_err(|_| Error::ConfigurationError("Request timeout".into()))?
.map_err(|e| Error::ConfigurationError(format!("Request failed: {e}")))?;
self.validate_response(&response).await?;
self.validate_certificate_pinning(url, &response)?;
Ok(response)
}
fn validate_url(&self, url: &str) -> Result<Url> {
let parsed =
Url::parse(url).map_err(|e| Error::ConfigurationError(format!("Invalid URL: {e}")))?;
if parsed.scheme() != "https" {
return Err(Error::ConfigurationError("Only HTTPS URLs allowed".into()));
}
let host = parsed
.host_str()
.ok_or_else(|| Error::ConfigurationError("No hostname in URL".into()))?;
if !self.is_allowed_domain(host) {
return Err(Error::ConfigurationError(format!(
"Domain not in allowlist: {host}"
)));
}
if url.contains("..") || url.contains("javascript:") || url.contains("data:") {
return Err(Error::ConfigurationError(
"Suspicious URL pattern detected".into(),
));
}
Ok(parsed)
}
fn is_allowed_domain(&self, host: &str) -> bool {
let allowed_domains = [
"ec2.amazonaws.com",
"s3.amazonaws.com",
"sts.amazonaws.com",
"iam.amazonaws.com",
"compute.googleapis.com",
"storage.googleapis.com",
"iam.googleapis.com",
"management.azure.com",
"storage.azure.com",
"api.digitalocean.com",
"api.vultr.com",
"cloud.lambdalabs.com",
"api.runpod.io",
"console.vast.ai",
"api.coreweave.com",
"api.paperspace.io",
"api.fluidstack.io",
"marketplace.tensordock.com",
"api.akash.network",
"api.io.net",
"api.primeintellect.ai",
"api.render.com",
"api.lium.ai",
"kubernetes.default.svc",
"kubernetes.default.svc.cluster.local",
];
allowed_domains
.iter()
.any(|&domain| host == domain || host.ends_with(&format!(".{domain}")))
}
fn add_authentication(
&self,
mut request_builder: reqwest::RequestBuilder,
auth: &ApiAuthentication,
url: &Url,
body: Option<&serde_json::Value>,
) -> Result<reqwest::RequestBuilder> {
match auth {
ApiAuthentication::Bearer { token } => {
request_builder = request_builder.bearer_auth(token);
}
ApiAuthentication::ApiKey { key, header_name } => {
request_builder = request_builder.header(header_name, key);
}
ApiAuthentication::AwsSignatureV4 {
access_key,
secret_key,
region,
service,
} => {
let auth_header = self.generate_aws_signature_v4(
access_key, secret_key, region, service, url, body,
)?;
request_builder = request_builder.header(header::AUTHORIZATION, auth_header);
}
ApiAuthentication::None => {
warn!("Making unauthenticated request to: {}", url);
}
}
Ok(request_builder)
}
fn generate_aws_signature_v4(
&self,
_access_key: &str,
_secret_key: &str,
_region: &str,
_service: &str,
_url: &Url,
_body: Option<&serde_json::Value>,
) -> Result<String> {
warn!("AWS Signature v4 implementation is simplified - use official AWS SDK in production");
Ok("AWS4-HMAC-SHA256 Credential=simplified".to_string())
}
fn validate_request(&self, request: &Request) -> Result<()> {
if let Some(content_length) = request.headers().get(header::CONTENT_LENGTH) {
let length: usize = content_length
.to_str()
.map_err(|_| Error::ConfigurationError("Invalid content length header".into()))?
.parse()
.map_err(|_| Error::ConfigurationError("Invalid content length value".into()))?;
if length > 50 * 1024 * 1024 {
return Err(Error::ConfigurationError("Request body too large".into()));
}
}
for (name, value) in request.headers() {
let name_str = name.as_str();
let value_str = value
.to_str()
.map_err(|_| Error::ConfigurationError("Invalid header value".into()))?;
if value_str.contains('\n') || value_str.contains('\r') {
return Err(Error::ConfigurationError(format!(
"Header injection detected in {name_str}: {value_str}"
)));
}
}
Ok(())
}
async fn validate_response(&self, response: &Response) -> Result<()> {
if let Some(content_length) = response.headers().get(header::CONTENT_LENGTH) {
let length: usize = content_length
.to_str()
.map_err(|_| Error::ConfigurationError("Invalid response content length".into()))?
.parse()
.map_err(|_| Error::ConfigurationError("Invalid content length format".into()))?;
if length > self.max_response_size {
return Err(Error::ConfigurationError("Response too large".into()));
}
}
if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
let content_type_str = content_type
.to_str()
.map_err(|_| Error::ConfigurationError("Invalid content type header".into()))?;
let allowed_types = [
"application/json",
"application/xml",
"text/xml",
"text/plain",
];
if !allowed_types
.iter()
.any(|&t| content_type_str.starts_with(t))
{
warn!("Unexpected content type: {}", content_type_str);
}
}
Ok(())
}
fn validate_certificate_pinning(&self, _url: &str, _response: &Response) -> Result<()> {
Ok(())
}
pub async fn get(&self, url: &str, auth: &ApiAuthentication) -> Result<Response> {
self.authenticated_request(reqwest::Method::GET, url, auth, None)
.await
}
pub async fn post(
&self,
url: &str,
auth: &ApiAuthentication,
body: Option<serde_json::Value>,
) -> Result<Response> {
self.authenticated_request(reqwest::Method::POST, url, auth, body)
.await
}
pub async fn post_json(
&self,
url: &str,
auth: &ApiAuthentication,
body: serde_json::Value,
) -> Result<Response> {
self.authenticated_request(reqwest::Method::POST, url, auth, Some(body))
.await
}
pub async fn delete(&self, url: &str, auth: &ApiAuthentication) -> Result<Response> {
self.authenticated_request(reqwest::Method::DELETE, url, auth, None)
.await
}
}
#[derive(Clone)]
pub enum ApiAuthentication {
Bearer { token: String },
ApiKey { key: String, header_name: String },
AwsSignatureV4 {
access_key: String,
secret_key: String,
region: String,
service: String,
},
None,
}
impl std::fmt::Debug for ApiAuthentication {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bearer { .. } => f
.debug_struct("Bearer")
.field("token", &"[REDACTED]")
.finish(),
Self::ApiKey { header_name, .. } => f
.debug_struct("ApiKey")
.field("key", &"[REDACTED]")
.field("header_name", header_name)
.finish(),
Self::AwsSignatureV4 {
region, service, ..
} => f
.debug_struct("AwsSignatureV4")
.field("access_key", &"[REDACTED]")
.field("secret_key", &"[REDACTED]")
.field("region", region)
.field("service", service)
.finish(),
Self::None => write!(f, "None"),
}
}
}
impl ApiAuthentication {
pub fn digitalocean(token: String) -> Self {
Self::Bearer { token }
}
pub fn google_cloud(token: String) -> Self {
Self::Bearer { token }
}
pub fn aws(access_key: String, secret_key: String, region: String, service: String) -> Self {
Self::AwsSignatureV4 {
access_key,
secret_key,
region,
service,
}
}
pub fn azure(token: String) -> Self {
Self::Bearer { token }
}
pub fn vultr(api_key: String) -> Self {
Self::Bearer { token: api_key }
}
pub fn lambda_labs(api_key: String) -> Self {
Self::Bearer { token: api_key }
}
pub fn runpod(api_key: String) -> Self {
Self::Bearer { token: api_key }
}
pub fn vast_ai(api_key: String) -> Self {
Self::ApiKey {
key: api_key,
header_name: "Authorization".to_string(),
}
}
pub fn coreweave(token: String) -> Self {
Self::Bearer { token }
}
pub fn paperspace(api_key: String) -> Self {
Self::ApiKey {
key: api_key,
header_name: "x-api-key".to_string(),
}
}
pub fn fluidstack(api_key: String) -> Self {
Self::ApiKey {
key: api_key,
header_name: "api-key".to_string(),
}
}
pub fn tensordock(api_key: String, api_token: String) -> Self {
Self::ApiKey {
key: format!("{api_key}:{api_token}"),
header_name: "Authorization".to_string(),
}
}
pub fn akash(token: String) -> Self {
Self::Bearer { token }
}
pub fn io_net(api_key: String) -> Self {
Self::Bearer { token: api_key }
}
pub fn prime_intellect(api_key: String) -> Self {
Self::Bearer { token: api_key }
}
pub fn render(api_key: String) -> Self {
Self::Bearer { token: api_key }
}
pub fn bittensor_lium(api_key: String) -> Self {
Self::Bearer { token: api_key }
}
}
impl Default for SecureHttpClient {
fn default() -> Self {
Self::new().expect("Failed to create secure HTTP client")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_validation() {
let client = SecureHttpClient::new().unwrap();
assert!(
client
.validate_url("https://api.digitalocean.com/v2/droplets")
.is_ok()
);
assert!(client.validate_url("https://ec2.amazonaws.com/").is_ok());
assert!(
client
.validate_url("http://api.digitalocean.com/v2/droplets")
.is_err()
); assert!(client.validate_url("https://evil.com/api").is_err()); assert!(
client
.validate_url("https://api.digitalocean.com/../../../etc/passwd")
.is_err()
); }
#[test]
fn test_domain_allowlist() {
let client = SecureHttpClient::new().unwrap();
assert!(client.is_allowed_domain("api.digitalocean.com"));
assert!(client.is_allowed_domain("ec2.amazonaws.com"));
assert!(client.is_allowed_domain("compute.googleapis.com"));
assert!(client.is_allowed_domain("management.azure.com"));
assert!(client.is_allowed_domain("us-east-1.ec2.amazonaws.com"));
assert!(!client.is_allowed_domain("evil.com"));
assert!(!client.is_allowed_domain("malicious.site"));
}
#[test]
fn test_authentication_types() {
let _do_auth = ApiAuthentication::digitalocean("test-token".to_string());
let _aws_auth = ApiAuthentication::aws(
"access".to_string(),
"secret".to_string(),
"us-east-1".to_string(),
"ec2".to_string(),
);
let _gcp_auth = ApiAuthentication::google_cloud("gcp-token".to_string());
let _azure_auth = ApiAuthentication::azure("azure-token".to_string());
}
}