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}