Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ pub enum WardenError {
repeat_count: usize,
},

#[error("refusing to proxy request to {upstream} — that is wardn's own listen address; point the base URL at a provider prefix instead (e.g. http://127.0.0.1:7777/anthropic)")]
SelfForward { upstream: String },

#[error("OAuth token refresh failed for '{credential}': {reason}")]
OAuthRefreshFailed { credential: String, reason: String },

Expand Down
29 changes: 28 additions & 1 deletion src/proxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,36 @@ async fn handle_proxy_request(
.map(|pq| pq.as_str().to_string())
.unwrap_or_else(|| path.clone());

let route = route::resolve_route(&path_and_query, &host, &state.config.upstreams);
// The daemon listens on plain HTTP, so an origin-form request (the
// normal client shape) necessarily arrived over http; a client sending
// absolute-form URIs (real forward-proxy style) may carry an explicit
// scheme. Use it when present — never assume https, which made every
// http request to the proxy fail.
let scheme = req.uri().scheme_str().unwrap_or("http").to_string();

let route = route::resolve_route(&path_and_query, &host, &scheme, &state.config.upstreams);
let domain = route.domain.clone();

// Refuse to forward to our own listen address. A base-URL override
// without a provider prefix (e.g. `ANTHROPIC_BASE_URL=http://127.0.0.1:7777`)
// resolves, via Host-header fallback, back to this daemon — forwarding
// would loop the request through the proxy until the loop guard trips.
if route.provider_slug.is_none()
&& route::is_self_upstream(&route.upstream_base, &state.host, state.port)
{
tracing::warn!(
request_id = %request_id,
agent = %agent_id,
method = %method,
path = %path,
upstream = %route.upstream_base,
"refusing to forward to own listen address"
);
return Err(WardenError::SelfForward {
upstream: route.upstream_base.clone(),
});
}

tracing::info!(
request_id = %request_id,
agent = %agent_id,
Expand Down
97 changes: 88 additions & 9 deletions src/proxy/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ pub struct RouteTarget {
/// can't be used to pick a destination.
/// 2. **Host-header routing** — fallback for explicit forward-proxy usage
/// (e.g. `/etc/hosts` pinning + a client that sends the real API's
/// `Host` header). Preserves the original wardn behavior.
/// `Host` header). The upstream is reached with the same scheme the
/// client used — the daemon only speaks plain HTTP, so assuming `https`
/// (as old versions did) made every http request fail.
pub fn resolve_route(
path_and_query: &str,
host_header: &str,
scheme: &str,
upstreams: &HashMap<String, String>,
) -> RouteTarget {
if let Some(target) = resolve_provider_prefix(path_and_query, upstreams) {
Expand All @@ -48,13 +51,48 @@ pub fn resolve_route(
.to_string();

RouteTarget {
upstream_base: format!("https://{domain}"),
// Preserve the port from the Host header — dropping it would
// silently send the request to the scheme-default port (80/443)
// instead of the port the client actually used.
upstream_base: format!("{scheme}://{host_header}"),
domain,
upstream_path_and_query: path_and_query.to_string(),
provider_slug: None,
}
}

/// True if `upstream_base` resolves to the daemon's own listen address.
/// Forwarding there would send the request straight back into this proxy —
/// a tight loop, only stopped much later by the loop guard. Only
/// Host-header fallback routing can hit this; provider-prefixed routes go
/// to configured upstreams.
pub fn is_self_upstream(upstream_base: &str, own_host: &str, own_port: u16) -> bool {
let Ok(url) = url::Url::parse(upstream_base) else {
return false;
};
let Some(host) = url.host_str() else {
return false;
};
if url.port().unwrap_or(80) != own_port {
return false;
}

fn is_loopback(h: &str) -> bool {
matches!(h, "127.0.0.1" | "::1" | "localhost")
}

let own = own_host.trim_start_matches('[').trim_end_matches(']');
if own == "0.0.0.0" || own == "::" {
// Bound to every interface — any host on our port is us.
return true;
}
if is_loopback(own) {
is_loopback(host)
} else {
host == own
}
}

fn resolve_provider_prefix(
path_and_query: &str,
upstreams: &HashMap<String, String>,
Expand Down Expand Up @@ -106,7 +144,7 @@ mod tests {

#[test]
fn test_provider_prefix_routes_to_configured_upstream() {
let target = resolve_route("/anthropic/v1/messages", "127.0.0.1:7777", &upstreams());
let target = resolve_route("/anthropic/v1/messages", "127.0.0.1:7777", "http", &upstreams());

assert_eq!(target.upstream_base, "https://api.anthropic.com");
assert_eq!(target.domain, "api.anthropic.com");
Expand All @@ -116,22 +154,27 @@ mod tests {

#[test]
fn test_provider_prefix_preserves_query_string() {
let target = resolve_route("/openai/v1/models?limit=10", "127.0.0.1:7777", &upstreams());
let target = resolve_route(
"/openai/v1/models?limit=10",
"127.0.0.1:7777",
"http",
&upstreams(),
);

assert_eq!(target.upstream_path_and_query, "/v1/models?limit=10");
}

#[test]
fn test_provider_prefix_bare_slug_maps_to_root() {
let target = resolve_route("/anthropic", "127.0.0.1:7777", &upstreams());
let target = resolve_route("/anthropic", "127.0.0.1:7777", "http", &upstreams());
assert_eq!(target.upstream_path_and_query, "/");
}

#[test]
fn test_unknown_prefix_falls_back_to_host_routing() {
// "unknown" isn't a configured upstream slug, so this must NOT be
// treated as a provider prefix — it falls back to Host routing.
let target = resolve_route("/unknown/v1/whatever", "api.example.com", &upstreams());
let target = resolve_route("/unknown/v1/whatever", "api.example.com", "https", &upstreams());

assert_eq!(target.domain, "api.example.com");
assert_eq!(target.upstream_base, "https://api.example.com");
Expand All @@ -141,20 +184,56 @@ mod tests {

#[test]
fn test_host_header_routing_when_no_prefix_matches() {
let target = resolve_route("/v1/chat/completions", "api.openai.com:443", &upstreams());
let target = resolve_route(
"/v1/chat/completions",
"api.openai.com:443",
"https",
&upstreams(),
);

assert_eq!(target.domain, "api.openai.com");
assert_eq!(target.upstream_base, "https://api.openai.com");
assert_eq!(target.upstream_base, "https://api.openai.com:443");
assert_eq!(target.upstream_path_and_query, "/v1/chat/completions");
assert_eq!(target.provider_slug, None); // Host-header routing has no slug
}

#[test]
fn test_host_header_routing_uses_request_scheme() {
// The reported bug: a plain-http request must be forwarded over
// http, not assumed to be https.
let target = resolve_route("/anything", "127.0.0.1:7777", "http", &upstreams());

assert_eq!(target.upstream_base, "http://127.0.0.1:7777");
assert_eq!(target.domain, "127.0.0.1");
assert_eq!(target.upstream_path_and_query, "/anything");
assert_eq!(target.provider_slug, None);
}

#[test]
fn test_host_header_routing_preserves_port() {
let target = resolve_route("/v1/messages", "api.example.com:8080", "https", &upstreams());

assert_eq!(target.upstream_base, "https://api.example.com:8080");
assert_eq!(target.domain, "api.example.com");
}

#[test]
fn test_self_upstream_detection() {
assert!(is_self_upstream("http://127.0.0.1:7777/x", "127.0.0.1", 7777));
assert!(is_self_upstream("http://localhost:7777/x", "127.0.0.1", 7777));
assert!(is_self_upstream("http://127.0.0.1:7777/x", "0.0.0.0", 7777));
assert!(is_self_upstream("http://127.0.0.1:7777/x", "[::1]", 7777));
assert!(!is_self_upstream("http://127.0.0.1:9999/x", "127.0.0.1", 7777));
assert!(!is_self_upstream("http://api.example.com:7777/x", "127.0.0.1", 7777));
assert!(!is_self_upstream("not a url", "127.0.0.1", 7777));
}

#[test]
fn test_base_url_override_does_not_loop_back_to_proxy() {
// The exact bug this fixes: an SDK pointed at
// http://127.0.0.1:7777/anthropic sends Host: 127.0.0.1:7777, which
// must NOT resolve to the proxy itself.
let target = resolve_route("/anthropic/v1/messages", "127.0.0.1:7777", &upstreams());
let target = resolve_route("/anthropic/v1/messages", "127.0.0.1:7777", "http", &upstreams());

assert_ne!(target.domain, "127.0.0.1");
assert_eq!(target.domain, "api.anthropic.com");
Expand Down
89 changes: 89 additions & 0 deletions tests/proxy_e2e_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -714,3 +714,92 @@ async fn test_e2e_scrub_pattern_redacts_derived_secret_wardn_never_injected() {
);
assert!(body_str.contains("[REDACTED]"));
}

#[tokio::test]
async fn test_e2e_host_header_fallback_uses_request_scheme() {
// Regression for https://github.com/rohansx/wardn/issues/3: host-header
// fallback routing hardcoded `https`, so any plain-http request to the
// proxy failed with "io error: error sending request for url
// (https://...)". The upstream must be reached over the same scheme the
// client used.
let mock_server = MockServer::start().await;
let (state, placeholder) = state_with_upstream(
&mock_server,
"unused-slug",
"OPENAI_KEY",
"sk-proj-real-key-123456",
"agent-1",
)
.await;

let upstream_host_port = mock_server.uri().strip_prefix("http://").unwrap().to_string();

Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer sk-proj-real-key-123456"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
.mount(&mock_server)
.await;

// Origin-form URI (no scheme — as real curl/SDK clients send it), with
// the Host header naming the upstream. Must be forwarded over http.
let req = Request::builder()
.method("GET")
.uri("/v1/models")
.header("host", &upstream_host_port)
.header("x-warden-agent", "agent-1")
.header("authorization", format!("Bearer {placeholder}"))
.body(Body::empty())
.unwrap();

let resp = proxy::build_router(state).oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"http host-header request must reach the http upstream"
);

let received = mock_server.received_requests().await.unwrap();
assert_eq!(received.len(), 1);
}

#[tokio::test]
async fn test_e2e_self_targeted_request_refused_without_loop() {
// A base-URL override without a provider prefix (e.g.
// `OPENAI_BASE_URL=http://127.0.0.1:7777/`) sends Host: 127.0.0.1:7777
// and would resolve back to the daemon itself. The proxy must refuse up
// front with a clear error instead of forwarding to itself and looping.
let mock_server = MockServer::start().await;
let (state, _placeholder) = state_with_upstream(
&mock_server,
"unused-slug",
"OPENAI_KEY",
"sk-proj-real-key-123456",
"agent-1",
)
.await;

let req = Request::builder()
.method("GET")
.uri("/anything")
.header("host", "127.0.0.1:7777") // state.host:state.port
.header("x-warden-agent", "agent-1")
.body(Body::empty())
.unwrap();

let resp = proxy::build_router(state).oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);

let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], "proxy_error");
assert!(
json["message"]
.as_str()
.unwrap()
.contains("own listen address"),
"expected a self-forward refusal, got: {json}"
);
}