Skip to main content

agentmux_srv/server/
auth.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Origin checks and the auth middlewares.
5//! Split out of server/mod.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.1).
6
7use super::*;
8use agentmux_common::AUTH_KEY_HEADER;
9
10// ---- Origin checks ----
11
12/// A loopback origin ([`is_loopback_origin`]), or one a headless srv was
13/// told to accept with `--allowed-origin` (a reverse proxy's public origin).
14/// Used by CORS and by [`ws_origin_guard`].
15pub(crate) fn is_allowed_origin(origin: &str) -> bool {
16    is_loopback_origin(origin) || crate::headless::is_extra_allowed_origin(origin)
17}
18
19/// `http://127.0.0.1` or `http://localhost`, optionally with a numeric
20/// port: where the desktop frontend is served from (the CEF host's loopback
21/// server, or the Vite dev server). A headless srv behind a reverse proxy can
22/// also accept the proxy's origin; see [`is_allowed_origin`], which CORS and
23/// [`ws_origin_guard`] use.
24pub(crate) fn is_loopback_origin(origin: &str) -> bool {
25    ["http://127.0.0.1", "http://localhost"].iter().any(|host| {
26        origin.strip_prefix(host).is_some_and(|rest| {
27            rest.is_empty()
28                || rest
29                    .strip_prefix(':')
30                    .is_some_and(|port| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()))
31        })
32    })
33}
34
35/// Refuse a WebSocket upgrade whose `Origin` is neither loopback nor allowed
36/// by `--allowed-origin` ([`is_allowed_origin`]).
37///
38/// CORS does not apply to WebSocket upgrades, so without this any web page
39/// holding the auth key could open `/ws` (cross-site WebSocket hijacking).
40/// The key is not sent automatically the way a cookie would be, so this is
41/// defense in depth rather than a fix for a reachable attack. Browsers always
42/// send `Origin` on an upgrade; srv's native clients (launcher, agentmux-mcp)
43/// send none, so a missing header is allowed.
44pub(super) async fn ws_origin_guard(req: Request<Body>, next: Next) -> Response {
45    let origin = req.headers().get(header::ORIGIN).map(|v| v.to_str().unwrap_or(""));
46    match origin {
47        Some(o) if !is_allowed_origin(o) => {
48            tracing::warn!(origin = %o, "refused /ws upgrade from an origin that isn't allowed");
49            (StatusCode::FORBIDDEN, Json(json!({"error": "origin not allowed"}))).into_response()
50        }
51        _ => next.run(req).await,
52    }
53}
54
55// ---- Auth Middleware ----
56
57/// Auth middleware matching Go pkg/authkey/authkey.go:18-42.
58pub(super) async fn auth_middleware(
59    State(state): State<AppState>,
60    mut req: Request<Body>,
61    next: Next,
62) -> Response {
63    if req.method() == Method::OPTIONS {
64        return next.run(req).await;
65    }
66
67    let auth_key = req
68        .headers()
69        .get(AUTH_KEY_HEADER)
70        .and_then(|v| v.to_str().ok())
71        .map(|s| s.to_string());
72
73    // 2026-05-11 audit (C3): the query-string `?authkey=` fallback
74    // bypasses CORS preflight and is preserved in browser history,
75    // navigation `Referer` headers, server access logs, etc. — a CSRF
76    // amplifier whenever the key leaks. It is allowed **only** on the
77    // WebSocket upgrade route (`/ws`), where the browser WS API doesn't
78    // permit custom headers and there is no other practical channel
79    // for the key. Every other route requires the header.
80    let auth_key = auth_key.or_else(|| {
81        if req.uri().path() != "/ws" {
82            return None;
83        }
84        req.uri().query().and_then(|q| {
85            q.split('&')
86                .filter_map(|pair| pair.split_once('='))
87                .find(|(k, _)| *k == "authkey")
88                .map(|(_, v)| v.to_string())
89        })
90    });
91
92    match auth_key {
93        Some(key) if secret_eq(key.as_bytes(), state.auth_key.as_bytes()) => {
94            // Identity M4a: `caller_middleware` derives a `Caller` only for a
95            // request marked as full-key authenticated — never by default.
96            req.extensions_mut().insert(ReactiveAuthVia::FullAuthKey);
97            next.run(req).await
98        }
99        Some(key) => match crate::backend::container_credential::grant_for(&key) {
100            Some(grant) => admit_container_agent(grant, req, next).await,
101            None => unauthorized(),
102        },
103        None => unauthorized(),
104    }
105}
106
107pub(super) fn unauthorized() -> Response {
108    (StatusCode::UNAUTHORIZED, Json(json!({"error": "unauthorized"}))).into_response()
109}
110
111/// A container agent's token: allowed only on
112/// [`crate::backend::container_credential::container_route_allowed`]. There it
113/// stands in for the instance key it replaced (so jekt tiers and identity
114/// behave as for any agent), with the caller pinned to the grant's agent by
115/// `caller_middleware`.
116pub(super) async fn admit_container_agent(
117    grant: crate::backend::container_credential::ContainerGrant,
118    mut req: Request<Body>,
119    next: Next,
120) -> Response {
121    use crate::backend::container_credential::{container_route_allowed, route_needs_identity};
122    let path = req.uri().path().to_string();
123    let refused = if !container_route_allowed(&path) {
124        Some("this route is not available to container agents")
125    } else if route_needs_identity(&path) && grant.agent_token.is_none() {
126        Some("this container agent has no identity token")
127    } else {
128        None
129    };
130    if let Some(reason) = refused {
131        tracing::warn!(block_id = %grant.block_id, %path, reason, "container agent request refused");
132        return (StatusCode::FORBIDDEN, Json(json!({"error": reason}))).into_response();
133    }
134    req.extensions_mut().insert(ReactiveAuthVia::FullAuthKey);
135    req.extensions_mut().insert(grant);
136    next.run(req).await
137}
138
139/// Auth middleware for the two LAN-forwarding-relevant reactive routes
140/// (`/agentmux/reactive/agent`, `/agentmux/reactive/inject`) — accepts
141/// EITHER `state.auth_key` (the normal, full-access case: every other
142/// caller of these two routes, e.g. agentmux-mcp's SendMessage tool for a
143/// plain host-tier inject) OR `state.lan_key` (a LAN peer forwarding a
144/// jekt or looking up which agents this instance hosts).
145///
146/// Deliberately a SEPARATE middleware from `auth_middleware`, applied to a
147/// standalone router merged at the top level rather than nested inside
148/// `authed_routes` — nesting would put `authed_routes`'s own
149/// `route_layer(auth_middleware)` on the outside, rejecting the LAN key
150/// before this middleware ever ran. This is why these two routes live in
151/// their own `lan_forward_routes` router in `router()`, not in
152/// `reactive_routes`.
153///
154/// `state.lan_key` grants access to ONLY the `lan_forward_routes` — never the rest
155/// of `/agentmux/service`, `/agentmux/file`, shell creation, credential/
156/// identity endpoints, etc. See `Config::lan_key`'s doc comment for why
157/// this exists.
158/// Which of the two credentials `lan_or_full_auth_middleware` accepted
159/// authenticated this specific request. Inserted into the request's
160/// extensions so `handle_reactive_inject` can force `delivery_tier = "lan"`
161/// whenever `LanKey` authenticated the request, regardless of what the JSON
162/// body itself claims — closing the bypass where a `lan_key`-only holder
163/// could otherwise just self-label a request "host" to dodge LAN's stricter
164/// verification entirely. See
165/// docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §3 for the full
166/// rationale, including why the reverse (downgrading a "lan" claim seen
167/// under `FullAuthKey`) is deliberately NOT done — reagentx P0 found that
168/// same-host forwarding (Tier 2a/2b in `reactive.rs`) legitimately
169/// re-authenticates an already-LAN-originated jekt with a sibling
170/// instance's own full `auth_key`, so downgrading there silently discarded
171/// an already-detected signature failure's forced-sensitive escalation.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub(crate) enum ReactiveAuthVia {
174    FullAuthKey,
175    LanKey,
176}
177
178pub(super) async fn lan_or_full_auth_middleware(
179    State(state): State<AppState>,
180    mut req: Request<Body>,
181    next: Next,
182) -> Response {
183    if req.method() == Method::OPTIONS {
184        return next.run(req).await;
185    }
186
187    let auth_key = req
188        .headers()
189        .get(AUTH_KEY_HEADER)
190        .and_then(|v| v.to_str().ok());
191
192    match auth_key {
193        Some(key) if secret_eq(key.as_bytes(), state.auth_key.as_bytes()) => {
194            req.extensions_mut().insert(ReactiveAuthVia::FullAuthKey);
195            next.run(req).await
196        }
197        Some(key) if secret_eq(key.as_bytes(), state.lan_key.as_bytes()) => {
198            req.extensions_mut().insert(ReactiveAuthVia::LanKey);
199            next.run(req).await
200        }
201        Some(key) => match crate::backend::container_credential::grant_for(key) {
202            Some(grant) => admit_container_agent(grant, req, next).await,
203            None => unauthorized(),
204        },
205        None => unauthorized(),
206    }
207}