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
91 changes: 37 additions & 54 deletions crates/tracedecay-dashboard-api/src/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@
//!
//! Loopback is reachable by every local account, so binding to 127.0.0.1 is
//! not an access boundary. Each dashboard listener mints a random token that
//! only its launch URL carries. A browser trades that URL once for an
//! `HttpOnly`, `SameSite=Strict` cookie; programmatic clients send the token as
//! the Basic-auth password.
//! only its launch URL carries.
//!
//! The token is never stored in a cookie: browsers scope cookies by host, not
//! port, so a cookie would ride along to every other service on the same
//! loopback host. Instead the launch URL is traded for a redirect that moves
//! the token into the URL fragment (never sent on the wire, never in a
//! `Referer`); the page script keeps it in origin-scoped storage and sends it
//! as the Basic-auth password on each request, as programmatic clients do.
//! The embedded page shell carries no secret, so it is served without a
//! credential; every `/api` route needs one.

use std::fmt;
use std::net::SocketAddr;
use std::sync::Arc;

use axum::http::{HeaderMap, HeaderValue, Method, Uri, header};
use axum::http::{HeaderMap, Method, Uri, header};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;

Expand Down Expand Up @@ -42,7 +49,8 @@ impl DashboardAccessToken {
})
}

/// The URL a person opens; the listener trades it for a session cookie.
/// The URL a person opens; the listener trades it for a redirect that
/// hands the token to the page through the URL fragment.
pub fn launch_url(&self, addr: SocketAddr) -> String {
format!("http://{addr}/?{LAUNCH_QUERY_KEY}={}", self.token)
}
Expand All @@ -61,56 +69,44 @@ impl fmt::Debug for DashboardAccessToken {

pub(crate) enum DashboardCredential {
Admitted,
/// A valid launch URL: answer with a redirect that drops the token from
/// the address bar and sets the session cookie.
/// A valid launch URL on the page shell: answer with a redirect that drops
/// the token from the query and delivers it in the fragment.
Launch {
location: String,
set_cookie: HeaderValue,
},
Missing,
}

pub(crate) fn classify_credential(
access: &DashboardAccessToken,
port: u16,
method: &Method,
uri: &Uri,
headers: &HeaderMap,
) -> DashboardCredential {
let cookie_name = session_cookie_name(port);
// A launch URL is exchanged even when a session already exists, so the
// token never stays in the address bar or history.
if let Some(launch) = launch_exchange(access, &cookie_name, method, uri) {
return launch;
if is_page_shell(method, uri) {
// A launch URL is exchanged even when the page already holds the
// token, so the token never stays in the query or history.
return launch_exchange(access, uri).unwrap_or(DashboardCredential::Admitted);
}
let basic = headers.get(header::AUTHORIZATION).is_some_and(|value| {
constant_time_eq(value.as_bytes(), access.basic_authorization.as_bytes())
});
let cookie = headers
.get_all(header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(';'))
.filter_map(|pair| pair.trim().split_once('='))
.any(|(name, value)| {
name == cookie_name && constant_time_eq(value.as_bytes(), access.token.as_bytes())
});
if basic || cookie {
if basic {
DashboardCredential::Admitted
} else {
DashboardCredential::Missing
}
}

fn launch_exchange(
access: &DashboardAccessToken,
cookie_name: &str,
method: &Method,
uri: &Uri,
) -> Option<DashboardCredential> {
if !matches!(*method, Method::GET | Method::HEAD) {
return None;
}
/// The embedded single-page app (index, `/static/**`, client-side routes) is
/// public code the browser must fetch before it can hold a token. Everything
/// under `/api` is data and stays credentialed.
fn is_page_shell(method: &Method, uri: &Uri) -> bool {
let path = uri.path();
matches!(*method, Method::GET | Method::HEAD) && path != "/api" && !path.starts_with("/api/")
Comment on lines +105 to +106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Non-API routes bypass dashboard authentication

When a listener mounts a GET route outside /api, is_page_shell admits it without a token. The previous admission check protected every non-launch route, so callers can read data exposed there anonymously.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

fn launch_exchange(access: &DashboardAccessToken, uri: &Uri) -> Option<DashboardCredential> {
let query = uri.query()?;
let mut launch = false;
let remaining: Vec<&str> = query
Expand All @@ -126,30 +122,17 @@ fn launch_exchange(
if !launch {
return None;
}
let path = uri.path();
let location = if remaining.is_empty() {
uri.path().to_owned()
format!("{path}#{LAUNCH_QUERY_KEY}={}", access.token)
} else {
format!("{}?{}", uri.path(), remaining.join("&"))
format!(
"{path}?{}#{LAUNCH_QUERY_KEY}={}",
remaining.join("&"),
access.token
)
};
let set_cookie = HeaderValue::from_str(&format!(
"{cookie_name}={}; Path=/; HttpOnly; SameSite=Strict",
access.token
))
.ok()?;
Some(DashboardCredential::Launch {
location,
set_cookie,
})
}

/// Browsers scope cookies by host, not port, so the name carries the port to
/// keep concurrent dashboards on one loopback host from overwriting each
/// other's session.
// ponytail: any listener on another 127.0.0.1 port that this browser visits
// also receives the cookie; closing that needs per-request header tokens in
// the SPA (EventSource cannot send headers), so the cookie stays until then.
fn session_cookie_name(port: u16) -> String {
format!("tracedecay_dashboard_{port}")
Some(DashboardCredential::Launch { location })
}

fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
Expand Down
72 changes: 44 additions & 28 deletions crates/tracedecay-dashboard-api/src/authority_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ async fn admitted_automation_route_carries_the_backend_sized_deadline() {
}

mod automatic_fact_receipts_routes;
mod credential_isolation;

/// The loopback authority every admitted-router fixture binds. Requests
/// must carry it as their `Host` header for the admission layer to admit
Expand Down Expand Up @@ -182,7 +183,9 @@ fn admitted_request(uri: impl AsRef<str>) -> Request<Body> {

/// Other local accounts reach the same loopback port, so the Host and
/// Origin checks alone must not admit a request: only the listener's
/// token does, as a Basic password, a session cookie, or a launch URL.
/// token does, as a Basic password or a launch URL. The page shell is the one
/// public surface: it carries no secret and a browser cannot attach headers to
/// a navigation or `<script>` fetch.
#[tokio::test]
async fn loopback_requests_need_the_listener_access_token() {
async fn probe() -> StatusCode {
Expand Down Expand Up @@ -244,62 +247,75 @@ async fn loopback_requests_need_the_listener_access_token() {
.unwrap(),
)
.await;
assert_eq!(wrong_launch.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
wrong_launch.status(),
StatusCode::NO_CONTENT,
"a forged launch URL is just the public shell, never a redirect that carries a token"
);
assert!(!wrong_launch.headers().contains_key(header::LOCATION));
let posted_launch = send(
request(Method::POST, launch_path)
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(posted_launch.status(), StatusCode::UNAUTHORIZED);
let api_launch = send(
request(
Method::GET,
&format!("/api/automation/scheduler/status?{}", &launch_path[2..]),
)
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(
api_launch.status(),
StatusCode::UNAUTHORIZED,
"the launch token in an API query authenticates nothing"
);
let token = launch_path.strip_prefix("/?token=").expect("launch token");
let launch = send(
request(Method::GET, &format!("{launch_path}&view=brain"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(launch.status(), StatusCode::SEE_OTHER);
assert_eq!(launch.headers()[header::LOCATION], "/?view=brain");
let set_cookie = launch.headers()[header::SET_COOKIE]
.to_str()
.expect("cookie text");
let token = launch_path.strip_prefix("/?token=").expect("launch token");
assert_eq!(
set_cookie,
format!("tracedecay_dashboard_{port}={token}; Path=/; HttpOnly; SameSite=Strict")
launch.headers()[header::LOCATION],
format!("/?view=brain#token={token}").as_str()
);
assert!(
!launch.headers().contains_key(header::SET_COOKIE),
"a cookie would be replayed to every other loopback port"
);
assert_eq!(launch.headers()[header::CACHE_CONTROL], "no-store");
assert_eq!(launch.headers()[header::REFERRER_POLICY], "no-referrer");

let session = send(
request(Method::POST, "/api/automation/scheduler/pause")
.header(
header::COOKIE,
format!("theme=dark; tracedecay_dashboard_{port}={token}"),
)
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(session.status(), StatusCode::NO_CONTENT);
let relaunch = send(
request(Method::GET, launch_path)
.header(
header::COOKIE,
format!("tracedecay_dashboard_{port}={token}"),
)
.header(header::AUTHORIZATION, access.basic_authorization())
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(relaunch.status(), StatusCode::SEE_OTHER);
assert_eq!(relaunch.headers()[header::LOCATION], "/");
let other_port_cookie = send(
assert_eq!(
relaunch.headers()[header::LOCATION],
format!("/#token={token}").as_str()
);
let session_cookie = send(
request(Method::POST, "/api/automation/scheduler/pause")
.header(header::COOKIE, format!("tracedecay_dashboard_1={token}"))
.header(
header::COOKIE,
format!("tracedecay_dashboard_{port}={token}"),
)
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(other_port_cookie.status(), StatusCode::UNAUTHORIZED);
assert_eq!(session_cookie.status(), StatusCode::UNAUTHORIZED);
}

/// The router as production serves it.
Expand Down
Loading
Loading