use std::{ collections::BTreeMap, net::SocketAddr, process::Command, sync::{ Arc, atomic::{AtomicUsize, Ordering}, }, time::Duration, }; use axum::{ Json, Router, extract::State, http::StatusCode, routing::{any, post}, }; use crank_adapter_rest::{ ExternalReferenceFetcher, 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.clone(), 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", ); let fetcher = ExternalReferenceFetcher::try_new( OutboundHttpPolicy::default(), vec!["http://public.example.test/".to_owned()], 1024, Duration::from_secs(1), ) .expect("valid external reference fetcher"); let _ = fetcher .get(&target) .await .expect_err("external reference fetcher must not use proxy environment variables"); 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) -> 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::(), ) .await .unwrap(); }); format!("http://{address}") } async fn capture(State(observed_requests): State>) -> Json { observed_requests.fetch_add(1, Ordering::SeqCst); Json(json!({"ok": true})) } async fn capture_any(State(observed_requests): State>) -> StatusCode { observed_requests.fetch_add(1, Ordering::SeqCst); StatusCode::OK }