diff --git a/crates/imcp2-local/src/login/routes.rs b/crates/imcp2-local/src/login/routes.rs index 88381df..78582a3 100644 --- a/crates/imcp2-local/src/login/routes.rs +++ b/crates/imcp2-local/src/login/routes.rs @@ -4,6 +4,7 @@ //! MCP tool surface never rides HTTP. use axum::extract::State; +use axum::http::{HeaderName, HeaderValue}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; @@ -18,9 +19,12 @@ use super::{Grant, LoginDriver, CONTACT_EMAIL}; /// only `Host` header the listener accepts. pub(super) fn login_router(driver: LoginDriver, callback_url: String, authority: String) -> Router { Router::new() - .route("/callback", get(callback_page)) - .route("/redeem", post(redeem)) - .route(AUTH_CALLBACKS_WELL_KNOWN, get(auth_callbacks)) + .route("/callback", get(callback_page).options(preflight)) + .route("/redeem", post(redeem).options(preflight)) + .route( + AUTH_CALLBACKS_WELL_KNOWN, + get(auth_callbacks).options(preflight), + ) // Anti-DNS-rebinding, per the design's loopback hardening (the local // analogue of the hosted server's Host allow-list): a page on an // attacker's hostname that rebinds to 127.0.0.1 reaches this port @@ -111,6 +115,50 @@ async fn auth_callbacks(State(ctx): State) -> Response { resp } +/// The header Chrome's Private Network Access check requires on a preflight +/// response before it will let a PUBLIC page touch a private (loopback) one. +const ALLOW_PRIVATE_NETWORK: &str = "access-control-allow-private-network"; + +/// CORS preflight for every route on this listener. +/// +/// II's frontend runs on the public `https://id.ai` origin and fetches the +/// #4091 allow-list from `http://127.0.0.1:` — a **private-network +/// request** in Chrome's Private Network Access rules. Those force an +/// `OPTIONS` preflight carrying `Access-Control-Request-Private-Network: +/// true` even though the GET is otherwise "simple", and block the fetch +/// unless the preflight answers `Access-Control-Allow-Private-Network: true`. +/// Without this the allow-list is unreachable *from a browser* — `curl` and +/// the e2e harness's HTTP client never preflight, which is exactly what made +/// the gap easy to miss — so II cannot validate the callback and rejects +/// every connect as "missing information". The same applies to the tab +/// navigation into `/callback` where Chrome enforces PNA for navigations, +/// hence the preflight on all three routes rather than the allow-list alone. +async fn preflight() -> Response { + let mut resp = axum::http::StatusCode::NO_CONTENT.into_response(); + let h = resp.headers_mut(); + h.insert( + axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + h.insert( + axum::http::header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, POST, OPTIONS"), + ); + h.insert( + axum::http::header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("*"), + ); + h.insert( + HeaderName::from_static(ALLOW_PRIVATE_NETWORK), + HeaderValue::from_static("true"), + ); + h.insert( + axum::http::header::ACCESS_CONTROL_MAX_AGE, + HeaderValue::from_static("600"), + ); + resp +} + fn redeem_err(msg: &str) -> Response { ( axum::http::StatusCode::BAD_REQUEST, diff --git a/crates/imcp2-local/src/login/tests.rs b/crates/imcp2-local/src/login/tests.rs index 7e01f2e..3243162 100644 --- a/crates/imcp2-local/src/login/tests.rs +++ b/crates/imcp2-local/src/login/tests.rs @@ -277,6 +277,64 @@ async fn a_pending_refresh_outranks_the_live_grant_in_status() { // rejected before routing — a rebinding page fetches this port with the // attacker's hostname in `Host`, which is exactly what must bounce. The // true authority (what every handed-out URL carries) passes untouched. +// II's frontend is a PUBLIC https origin fetching this listener on loopback, +// which Chrome treats as a private-network request: it preflights with +// `Access-Control-Request-Private-Network: true` and blocks the fetch unless +// the answer carries `Access-Control-Allow-Private-Network: true`. A 405 here +// (routes registered for GET/POST only) makes the #4091 allow-list unreadable +// from the browser and every connect fails as "missing information" — while +// curl and the e2e harness, which never preflight, see a healthy 200. Hence a +// RAW preflight, the only shape that reproduces it. +#[tokio::test] +async fn the_listener_answers_the_private_network_preflight() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (driver, _slot) = test_driver(); + driver.begin(false).await.expect("begin"); + let (_, callback_url) = driver.pending_handshake().await.expect("pending"); + let authority = callback_url + .strip_prefix("http://") + .and_then(|s| s.strip_suffix("/callback")) + .expect("authority"); + let port: u16 = authority.split(':').nth(1).unwrap().parse().unwrap(); + + for path in ["/.well-known/ii-auth-callbacks", "/callback"] { + let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("connect"); + stream + .write_all( + format!( + "OPTIONS {path} HTTP/1.1\r\nHost: {authority}\r\n\ + Origin: https://id.ai\r\nAccess-Control-Request-Method: GET\r\n\ + Access-Control-Request-Private-Network: true\r\n\ + Connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await + .expect("write"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .await + .expect("read response"); + let lowered = response.to_lowercase(); + assert!( + response.contains("204") || response.contains("200"), + "{path} preflight must succeed, got: {response}" + ); + assert!( + lowered.contains("access-control-allow-private-network: true"), + "{path} preflight must grant private-network access, got: {response}" + ); + assert!( + lowered.contains("access-control-allow-origin: *"), + "{path} preflight must allow II's origin, got: {response}" + ); + } +} + #[tokio::test] async fn the_listener_rejects_foreign_host_headers() { use tokio::io::{AsyncReadExt, AsyncWriteExt};