Skip to content
Closed
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
54 changes: 51 additions & 3 deletions crates/imcp2-local/src/login/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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
Expand Down Expand Up @@ -111,6 +115,50 @@ async fn auth_callbacks(State(ctx): State<RouteCtx>) -> 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:<port>` — 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,
Expand Down
58 changes: 58 additions & 0 deletions crates/imcp2-local/src/login/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Loading