diff --git a/AGENTS.md b/AGENTS.md index 9c6f931..c17f060 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ so CI builds what was tested. `POST /refresh`, logout. - `src/manifest.rs`: parsing, matching, the live and embedded manifest. - `src/guard.rs`: `require_auth`, `authenticate`, the `User` extractor. +- `src/console.rs`: `console_router`, the admin dashboard proxy. - `src/cookies.rs`, `src/jwt.rs`, `src/jwks.rs`, `src/service_token.rs`: HS256 cookies and service tokens, RS256 against the API's JWKS. - `src/tests/`: unit tests against a fake auth API. diff --git a/README.md b/README.md index 25e055e..c84aef3 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,24 @@ asks the API for the message and hands it to you: A delivery error answers the request with 502 `delivery_failed`. +### The admin console + +`console_router()` serves the Seamless admin dashboard from your API, proxied from the auth API, so +it shares the origin and cookie scope of the `/auth` routes. It serves `/console`, the path the +dashboard is built against, so merge it (a router nested at `/console` never sees `/console/`): + +```rust +let app = Router::new() + .nest("/auth", auth.router()) + .merge(auth.console_router()); +``` + +It serves `GET` and `HEAD` only and forwards nothing but the method and path, so the browser's +cookies never reach the upstream. It refuses any path that could leave the console (dot segments, +encoded separators), and follows a redirect only while it stays inside the console on the auth API. +When you serve it, add your API's origin to the auth server's `ORIGINS` so passkey ceremonies +started in the console verify. + ## Features | Feature | Default | Purpose | diff --git a/src/adapter.rs b/src/adapter.rs index 85073fc..ab01f36 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -48,6 +48,7 @@ pub(crate) struct Inner { pub manifest: ManifestSource, pub tokens: ServiceTokens, pub refreshes: Arc, + pub console_client: std::sync::OnceLock>, } impl Adapter { @@ -71,6 +72,7 @@ impl Adapter { manifest, tokens, refreshes: Arc::default(), + console_client: std::sync::OnceLock::new(), }), } } diff --git a/src/console.rs b/src/console.rs new file mode 100644 index 0000000..0db9a98 --- /dev/null +++ b/src/console.rs @@ -0,0 +1,182 @@ +use std::time::Duration; + +use axum::Router; +use axum::body::Body; +use axum::extract::Request; +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, LAST_MODIFIED}; +use axum::http::{HeaderMap, Method, StatusCode}; +use axum::response::Response; +use axum::routing::any; +use reqwest::Url; +use reqwest::redirect::Policy; +use serde_json::json; + +use crate::adapter::{Adapter, Inner, json_response}; +use crate::manifest::percent_decode; + +const CONSOLE_BASE_PATH: &str = "/console"; +const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_REDIRECTS: usize = 5; + +/// The auth API's origin and the console's path on it. +#[derive(Clone)] +struct ConsoleOrigin { + base: Url, + prefix: String, +} + +impl ConsoleOrigin { + fn new(auth_server_url: &str) -> Option { + let base = Url::parse(auth_server_url).ok()?; + base.host_str()?; + let prefix = format!("{}{CONSOLE_BASE_PATH}", base.path().trim_end_matches('/')); + Some(ConsoleOrigin { base, prefix }) + } + + /// Holds a URL to the auth API's origin and the console subtree. + fn contains(&self, url: &Url) -> bool { + url.scheme() == self.base.scheme() + && url.host_str() == self.base.host_str() + && url.port_or_known_default() == self.base.port_or_known_default() + && (url.path() == self.prefix || url.path().starts_with(&format!("{}/", self.prefix))) + } + + /// Maps a path below the mount to the console on the auth API, refusing + /// anything that could leave the console subtree: an encoded separator (an + /// upstream that decodes it would read a traversal), and any dot segment, + /// literal or encoded. + fn upstream(&self, subpath: &str, query: Option<&str>) -> Option { + let lower = subpath.to_ascii_lowercase(); + if lower.contains("%2f") || lower.contains("%5c") || subpath.contains('\\') { + return None; + } + for segment in subpath.split('/') { + match percent_decode(segment) { + Some(decoded) if decoded != "." && decoded != ".." => {} + _ => return None, + } + } + + let suffix = match subpath { + "" | "/" => String::new(), + s if s.starts_with('/') => s.to_string(), + s => format!("/{s}"), + }; + let mut url = self.base.clone(); + url.set_path(&format!("{}{suffix}", self.prefix)); + url.set_query(query.filter(|q| !q.is_empty())); + self.contains(&url).then_some(url) + } +} + +impl Adapter { + /// Reverse-proxies the Seamless admin dashboard from the auth API, so it loads + /// from the same origin as the cookie-based auth routes. It serves `/console`, + /// the path the dashboard is built against, so merge it rather than nest it: + /// `Router::new().merge(auth.console_router())`. (A router nested at + /// `/console` never sees `/console/`, where the browser lands.) + /// + /// Nothing from the incoming request is forwarded but the method and the + /// path: the console is public static hosting, and the browser's session + /// cookies have no business at the upstream. + pub fn console_router(&self) -> Router + where + S: Clone + Send + Sync + 'static, + { + let inner = self.inner.clone(); + let handler = move |req: Request| { + let inner = inner.clone(); + async move { inner.serve_console(req).await } + }; + Router::new() + .route(CONSOLE_BASE_PATH, any(handler.clone())) + .route(&format!("{CONSOLE_BASE_PATH}/"), any(handler.clone())) + .route(&format!("{CONSOLE_BASE_PATH}/{{*path}}"), any(handler)) + } +} + +impl Inner { + /// Its own client: the adapter's follows no redirects at all, and the + /// console follows them while they stay inside it. + fn console_client(&self, origin: &ConsoleOrigin) -> Option { + self.console_client + .get_or_init(|| { + let origin = origin.clone(); + reqwest::Client::builder() + .timeout(UPSTREAM_TIMEOUT) + .redirect(Policy::custom(move |attempt| { + if attempt.previous().len() >= MAX_REDIRECTS { + attempt.error("too many redirects") + } else if origin.contains(attempt.url()) { + attempt.follow() + } else { + attempt.error("redirect leaves the console") + } + })) + .build() + .ok() + }) + .clone() + } + + async fn serve_console(&self, req: Request) -> Response { + let method = req.method().clone(); + if method != Method::GET && method != Method::HEAD { + return json_response( + StatusCode::METHOD_NOT_ALLOWED, + &json!({ "error": "Method not allowed" }), + ); + } + + let invalid = || { + json_response( + StatusCode::BAD_REQUEST, + &json!({ "error": "Invalid console path" }), + ) + }; + let Some(origin) = ConsoleOrigin::new(&self.config.auth_server_url) else { + return invalid(); + }; + let subpath = req + .uri() + .path() + .strip_prefix(CONSOLE_BASE_PATH) + .unwrap_or_default(); + let Some(upstream) = origin.upstream(subpath, req.uri().query()) else { + return invalid(); + }; + let Some(client) = self.console_client(&origin) else { + return json_response( + StatusCode::BAD_GATEWAY, + &json!({ "error": "Console upstream unreachable" }), + ); + }; + + let res = match client.request(method.clone(), upstream).send().await { + Ok(res) => res, + Err(_) => { + return json_response( + StatusCode::BAD_GATEWAY, + &json!({ "error": "Console upstream unreachable" }), + ); + } + }; + + let mut headers = HeaderMap::new(); + for name in [CONTENT_TYPE, CACHE_CONTROL, ETAG, LAST_MODIFIED] { + if let Some(value) = res.headers().get(&name) { + headers.insert(name, value.clone()); + } + } + let status = res.status(); + let body = if method == Method::HEAD { + Body::empty() + } else { + Body::new(reqwest::Body::from(res)) + }; + let mut response = Response::new(body); + *response.status_mut() = status; + *response.headers_mut() = headers; + response + } +} diff --git a/src/lib.rs b/src/lib.rs index 026d058..2911290 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ mod adapter; mod client_ip; +mod console; mod cookies; mod delivery; mod guard; diff --git a/src/manifest.rs b/src/manifest.rs index b9576a5..9d52e33 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -207,7 +207,7 @@ fn segments(path: &str) -> Vec<&str> { /// Decodes a path segment. Refuses a malformed escape or a result that is not /// UTF-8, as Go's `url.PathUnescape` does. -fn percent_decode(segment: &str) -> Option { +pub(crate) fn percent_decode(segment: &str) -> Option { let bytes = segment.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; diff --git a/src/tests/console.rs b/src/tests/console.rs new file mode 100644 index 0000000..06ea566 --- /dev/null +++ b/src/tests/console.rs @@ -0,0 +1,170 @@ +use axum::body::Body; +use axum::http::HeaderValue; +use axum::response::Response; +use serde_json::json; + +use super::*; + +fn console_app(adapter: &Adapter) -> Router { + Router::new().merge(adapter.console_router()) +} + +fn html(body: &'static str) -> Handler { + Arc::new(move |_| { + let mut res = Response::new(Body::from(body)); + res.headers_mut() + .insert("content-type", HeaderValue::from_static("text/html")); + res + }) +} + +fn redirect(to: &'static str) -> Handler { + Arc::new(move |_| { + let mut res = Response::new(Body::empty()); + *res.status_mut() = axum::http::StatusCode::FOUND; + res.headers_mut() + .insert("location", HeaderValue::from_static(to)); + res + }) +} + +#[tokio::test] +async fn the_console_is_proxied_from_the_auth_api() { + let api = FakeApi::start().await; + api.on( + "GET", + "/console/assets/app.js", + Arc::new(|_| { + let mut res = Response::new(Body::from("console();")); + let headers = res.headers_mut(); + headers.insert("content-type", HeaderValue::from_static("text/javascript")); + headers.insert("cache-control", HeaderValue::from_static("max-age=60")); + headers.insert("set-cookie", HeaderValue::from_static("upstream=1")); + res + }), + ); + let adapter = api.adapter(|b| b); + let cookie = signed_cookie("seamless-access", json!({ "sub": "u1", "token": "t" })); + + let r = call("GET", "/console/assets/app.js?v=2") + .cookie(&cookie) + .header("authorization", "Bearer t") + .through(console_app(&adapter)) + .await; + + assert_eq!(r.status, 200); + assert_eq!(r.text, "console();"); + assert_eq!(r.headers["content-type"], "text/javascript"); + assert_eq!(r.headers["cache-control"], "max-age=60"); + assert!( + r.headers.get("set-cookie").is_none(), + "an upstream Set-Cookie reached the browser" + ); + let recorded = &api.calls_to("GET", "/console/assets/app.js")[0]; + assert_eq!(recorded.query.as_deref(), Some("v=2")); + assert_eq!(recorded.header("cookie"), ""); + assert_eq!(recorded.header("authorization"), ""); +} + +#[tokio::test] +async fn the_console_root_and_head() { + let api = FakeApi::start().await; + api.on("GET", "/console", html("")); + api.on("HEAD", "/console", html("")); + let adapter = api.adapter(|b| b); + + let r = call("GET", "/console/") + .through(console_app(&adapter)) + .await; + assert_eq!((r.status, r.text.as_str()), (200, "")); + let r = call("HEAD", "/console/") + .through(console_app(&adapter)) + .await; + assert_eq!((r.status, r.text.as_str()), (200, "")); +} + +#[tokio::test] +async fn the_console_refuses_other_methods() { + let api = FakeApi::start().await; + let adapter = api.adapter(|b| b); + for method in ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"] { + let r = call(method, "/console/x") + .through(console_app(&adapter)) + .await; + assert_eq!(r.status, 405, "{method}"); + } +} + +#[tokio::test] +async fn the_console_never_leaves_its_subtree() { + let api = FakeApi::start().await; + let adapter = api.adapter(|b| b); + for target in [ + "/console/../admin/users", + "/console/%2e%2e/admin/users", + "/console/assets/%2E%2E/%2e%2e/jwks.json", + "/console/..%2fadmin", + "/console/..%2Fadmin", + "/console/..%5cadmin", + "/console/a/./b", + "/console/%zz", + ] { + let r = call("GET", target).through(console_app(&adapter)).await; + assert_eq!(r.status, 400, "{target}"); + } + assert!(api.calls_to("GET", "/admin/users").is_empty()); + assert!(api.calls_to("GET", "/.well-known/jwks.json").is_empty()); +} + +#[tokio::test] +async fn the_console_follows_redirects_only_within_itself() { + let api = FakeApi::start().await; + api.on("GET", "/console/old", redirect("/console/new")); + api.on("GET", "/console/new", html("moved")); + api.on("GET", "/console/escape", redirect("/.well-known/jwks.json")); + let adapter = api.adapter(|b| b); + + let r = call("GET", "/console/old") + .through(console_app(&adapter)) + .await; + assert_eq!((r.status, r.text.as_str()), (200, "moved")); + let r = call("GET", "/console/escape") + .through(console_app(&adapter)) + .await; + assert_eq!(r.status, 502); + assert!(api.calls_to("GET", "/.well-known/jwks.json").is_empty()); +} + +#[tokio::test] +async fn the_console_under_an_auth_server_path() { + let api = FakeApi::start().await; + api.on("GET", "/base/console/x", html("ok")); + let adapter = Adapter::builder(format!("{}/base", api.url)) + .cookie_secret(TEST_SECRET) + .service_secret(TEST_SERVICE) + .jwks_kid("test-main") + .disable_manifest_fetch(true) + .build() + .unwrap(); + + let r = call("GET", "/console/x") + .through(console_app(&adapter)) + .await; + assert_eq!((r.status, r.text.as_str()), (200, "ok")); +} + +#[tokio::test] +async fn the_console_reports_an_unreachable_upstream() { + let adapter = Adapter::builder("http://127.0.0.1:9") + .cookie_secret(TEST_SECRET) + .service_secret(TEST_SERVICE) + .jwks_kid("test-main") + .disable_manifest_fetch(true) + .build() + .unwrap(); + let r = call("GET", "/console/x") + .through(console_app(&adapter)) + .await; + assert_eq!(r.status, 502); + assert!(r.text.contains("unreachable")); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index c7621b5..523d7c6 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -2,6 +2,7 @@ mod adapter; mod client_ip; +mod console; mod guard; mod manifest;