use ati::core::auth_generator::AuthCache;
use ati::core::keyring::Keyring;
use ati::core::manifest::ManifestRegistry;
use ati::core::skill::SkillRegistry;
use ati::proxy::server::{build_router, ProxyState};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use std::sync::Arc;
use tower::ServiceExt;
fn build_app(with_env_var: bool, with_allowlist: Option<&str>) -> axum::Router {
let dir = tempfile::tempdir().expect("tempdir");
let manifests_dir = dir.path().join("manifests");
std::fs::create_dir_all(&manifests_dir).expect("manifests dir");
let env_line = if with_env_var {
r#"mcp_url_env = "PARCHA_TOOLS_MCP_URL""#
} else {
""
};
let manifest = format!(
r#"
[provider]
name = "parcha_tools"
description = "test"
handler = "mcp"
mcp_transport = "http"
mcp_url = "http://127.0.0.1:9/mcp"
{env_line}
# Explicit tool entry so /call's registry lookup succeeds. The actual
# MCP dispatch will fail to dial the unreachable upstream, which is
# fine — these tests assert the auth gate behaviour, not the dispatch
# success.
[[tools]]
name = "parcha_tools:foo"
description = "stub"
endpoint = "/foo"
method = "GET"
"#
);
std::fs::write(manifests_dir.join("p.toml"), &manifest).expect("write manifest");
let registry = ManifestRegistry::load(&manifests_dir).expect("load manifest");
drop(dir);
let skill_registry = SkillRegistry::load(std::path::Path::new("/nonexistent")).unwrap();
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let keyring = {
let _guard = ENV_LOCK.lock().unwrap();
if let Some(csv) = with_allowlist {
std::env::set_var("ATI_KEY_PARCHA_TOOLS_ALLOWED_URLS", csv);
let k = Keyring::from_env();
std::env::remove_var("ATI_KEY_PARCHA_TOOLS_ALLOWED_URLS");
k
} else {
std::env::remove_var("ATI_KEY_PARCHA_TOOLS_ALLOWED_URLS");
Keyring::empty()
}
};
let state = Arc::new(ProxyState {
registry,
skill_registry,
keyring,
jwt_config: None, jwks_json: None,
auth_cache: AuthCache::new(),
upstream_url_allowlists: std::sync::Arc::new(std::sync::Mutex::new(
std::collections::HashMap::new(),
)),
});
build_router(state)
}
async fn call_with_header(
app: axum::Router,
upstream_url: Option<&str>,
) -> (StatusCode, serde_json::Value) {
let body = serde_json::json!({"tool_name": "parcha_tools:foo", "args": {}});
let mut builder = Request::builder()
.method("POST")
.uri("/call")
.header("content-type", "application/json");
if let Some(u) = upstream_url {
builder = builder.header("X-Ati-Upstream-Url", u);
}
let req = builder
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.expect("oneshot");
let status = resp.status();
let bytes = resp.into_body().collect().await.expect("body").to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, json)
}
#[tokio::test]
async fn header_without_mcp_url_env_returns_400() {
let app = build_app( false, None);
let (status, body) = call_with_header(app, Some("https://parcha-tools.example.com/mcp")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let err = body.get("error").and_then(|v| v.as_str()).unwrap_or("");
assert!(
err.contains("does not declare mcp_url_env"),
"expected fail-loud message; got: {err}"
);
}
#[tokio::test]
async fn header_with_no_allowlist_returns_403() {
let app = build_app( true, None);
let (status, body) = call_with_header(app, Some("https://parcha-tools.example.com/mcp")).await;
assert_eq!(status, StatusCode::FORBIDDEN);
let err = body.get("error").and_then(|v| v.as_str()).unwrap_or("");
assert!(
err.contains("no upstream URL allowlist"),
"expected fail-closed message; got: {err}"
);
}
#[tokio::test]
async fn header_outside_allowlist_returns_403() {
let app = build_app(
true,
Some("https://parcha-tools-*.example.com/mcp"),
);
let (status, body) = call_with_header(app, Some("https://attacker.com/mcp")).await;
assert_eq!(status, StatusCode::FORBIDDEN);
let err = body.get("error").and_then(|v| v.as_str()).unwrap_or("");
assert!(
err.contains("not in provider 'parcha_tools's allowlist"),
"expected allowlist message; got: {err}"
);
}
#[tokio::test]
async fn glob_star_must_not_cross_path_separator() {
let app = build_app( true, Some("https://parcha-tools-*"));
let (status, _body) =
call_with_header(app, Some("https://parcha-tools-staging.evil.com/mcp")).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"glob * must not match across `/` or `.` boundaries — \
attacker URL got past the allowlist"
);
}
#[tokio::test]
async fn glob_star_must_not_swallow_path_segments() {
let app = build_app(
true,
Some("https://parcha-tools.example.com/*"),
);
let (status, _body) =
call_with_header(app, Some("https://parcha-tools.example.com/mcp/admin")).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"glob * in path must not swallow additional segments"
);
}
#[tokio::test]
async fn header_inside_allowlist_passes_auth_gate() {
let app = build_app(
true,
Some("https://parcha-tools-*.example.com/mcp"),
);
let (status, _body) =
call_with_header(app, Some("https://parcha-tools-staging.example.com/mcp")).await;
assert_ne!(
status,
StatusCode::BAD_REQUEST,
"allowed URL must pass case-2 gate"
);
assert_ne!(
status,
StatusCode::FORBIDDEN,
"allowed URL must pass case-4/5 gate"
);
}
#[tokio::test]
async fn header_absent_falls_back_to_mcp_url() {
let app = build_app( true, None);
let (status, _body) = call_with_header(app, None).await;
assert_ne!(
status,
StatusCode::BAD_REQUEST,
"no-header case must skip the auth gate"
);
assert_ne!(
status,
StatusCode::FORBIDDEN,
"no-header case must skip the allowlist check"
);
}
#[tokio::test]
async fn header_absent_no_mcp_url_env_works_as_today() {
let app = build_app( false, None);
let (status, _body) = call_with_header(app, None).await;
assert_ne!(
status,
StatusCode::BAD_REQUEST,
"legacy path must not be 400'd"
);
assert_ne!(
status,
StatusCode::FORBIDDEN,
"legacy path must not be 403'd"
);
}
#[tokio::test]
async fn dot_boundary_bypass_blocked_single_label_pattern() {
let app = build_app(
true,
Some("https://parcha-tools-*/mcp"),
);
let (status, body) = call_with_header(app, Some("https://parcha-tools-evil.com/mcp")).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"label-count mismatch must 403; otherwise `*` swallows `evil.com` (Greptile P0 on #126)"
);
let err = body.get("error").and_then(|v| v.as_str()).unwrap_or("");
assert!(
err.contains("not in provider"),
"expected allowlist rejection; got: {err}"
);
}
#[tokio::test]
async fn dot_boundary_bypass_blocked_three_label_pattern() {
let app = build_app(
true,
Some("https://parcha-tools-*.grep.ai/mcp"),
);
let (status, _body) =
call_with_header(app, Some("https://parcha-tools-evil.com.grep.ai/mcp")).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"extra DNS label in attacker URL must 403"
);
}
#[tokio::test]
async fn fragment_bypass_blocked() {
let app = build_app(
true,
Some("https://parcha-tools-*.grep.ai/mcp"),
);
let (status, _body) = call_with_header(
app,
Some("https://parcha-tools-evilco.com/mcp#staging.grep.ai/mcp"),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"fragment must not let URL bypass allowlist (Greptile P1 on #125)"
);
}
#[tokio::test]
async fn userinfo_bypass_blocked() {
let app = build_app(
true,
Some("https://parcha-tools-*.grep.ai/mcp"),
);
let (status, _body) = call_with_header(
app,
Some("https://attacker.com@parcha-tools-staging.grep.ai/mcp"),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"userinfo-bearing URLs must be rejected"
);
}
#[tokio::test]
async fn path_must_match_exactly_no_wildcards() {
let app = build_app(
true,
Some("https://parcha-tools.example.com/mcp"),
);
let (status, _body) =
call_with_header(app, Some("https://parcha-tools.example.com/mcp/admin")).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"extra path segments must not match an exact-path entry"
);
}
#[tokio::test]
async fn scheme_mismatch_blocked() {
let app = build_app(
true,
Some("https://parcha-tools.example.com/mcp"),
);
let (status, _body) = call_with_header(app, Some("http://parcha-tools.example.com/mcp")).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"scheme mismatch must 403; downgrade to http is a security regression"
);
}
#[tokio::test]
async fn invalid_url_returns_400() {
let app = build_app(
true,
Some("https://parcha-tools.example.com/mcp"),
);
let (status, body) = call_with_header(app, Some("not a url")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let err = body.get("error").and_then(|v| v.as_str()).unwrap_or("");
assert!(
err.contains("not a valid URL"),
"expected URL-parse error; got: {err}"
);
}
#[tokio::test]
async fn host_comparison_is_case_insensitive() {
let app = build_app(
true,
Some("https://parcha-tools.example.com/mcp"),
);
let (status, _body) = call_with_header(app, Some("https://PARCHA-TOOLS.EXAMPLE.COM/mcp")).await;
assert_ne!(
status,
StatusCode::FORBIDDEN,
"uppercase host must match lowercase allowlist (DNS is case-insensitive)"
);
}
#[tokio::test]
async fn double_star_pattern_rejected_at_load() {
let app = build_app( true, Some("https://**/mcp"));
let (status, body) = call_with_header(app, Some("https://parcha-tools.example.com/mcp")).await;
assert_eq!(status, StatusCode::FORBIDDEN);
let err = body.get("error").and_then(|v| v.as_str()).unwrap_or("");
assert!(
err.contains("no upstream URL allowlist") || err.contains("not in provider"),
"expected fail-closed; got: {err}"
);
}