fix: reject unauthenticated loopback requests

This commit is contained in:
whit3rabbit
2026-09-12 08:58:06 -05:00
parent ebf5455b10
commit 201d7ea5cd
12 changed files with 39 additions and 124 deletions
+4 -6
View File
@@ -13,12 +13,10 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follo
## [0.16.0] - 2026-07-16
### Changed
- Auth default is now **loopback-open** instead of reject-all. With no `PROXY_API_KEYS`,
no `PROXY_OPEN_RELAY`, no virtual keys and no OIDC, the proxy accepts unauthenticated
requests from localhost only; LAN/remote peers still get `401`. Decision uses the real
TCP peer (`ConnectInfo`), not the spoofable `X-Forwarded-For`. Set `PROXY_API_KEYS` when
running behind a reverse proxy. `GET /admin/api/status` now reports `auth_mode`
(`keys` / `open_relay` / `loopback_only`) and `proxy_key_count`.
- Auth default is **reject-all**. With no `PROXY_API_KEYS`, no `PROXY_OPEN_RELAY`, no
virtual keys, and no OIDC, every proxy request returns `401`, including from localhost.
Set `PROXY_API_KEYS` to allow authenticated access. `GET /admin/api/status` reports `auth_mode`
(`keys` / `open_relay` / `auth_required`) and `proxy_key_count`.
- Proxy start-up now pre-checks the listen port and fails fast with a hint when it is
already in use; the `wait_for_port` readiness timeout rose from 10s to 30s.
+1 -1
View File
@@ -104,7 +104,7 @@ Five-crate Cargo workspace: `providers` (metadata catalog), `client` (Anthropic
- **CSRF tokens are one-time-use.** Fetch a fresh token from `GET /admin/csrf-token` before each admin POST/PUT/DELETE. The SPA does this automatically; scripts must too.
- **Admin UI defaults on for bare invocation.** Running `anyllm-proxy` with **no args** starts proxy + admin UI and auto-opens the default browser to the admin page (the zero-arg desktop default). Passing `--webui`/`--admin` (or `WEBUI=1`/`ADMIN=1` env) forces it on alongside other args but does **not** open a browser. Passing any other arg (e.g. `--env-file`, `--redact-secrets`) keeps the proxy CLI-only. `DISABLE_ADMIN=1` force-disables it in all cases. Gate + zero-arg logic live in `main_helpers/bootstrap.rs` (`admin_enabled`/`admin_requested`/`is_default_launch`); browser open in `main_helpers/browser.rs`.
- **Virtual key OnceLock in tests.** `set_virtual_keys` uses a global `OnceLock<DashMap>`. Integration tests in `crates/proxy/tests/virtual_keys.rs` use a shared `OnceLock` to avoid conflicts.
- **Auth defaults to loopback-open.** With no `PROXY_API_KEYS`, no `PROXY_OPEN_RELAY`, no virtual keys and no OIDC, requests from a loopback TCP peer are accepted; LAN/remote peers get 401. Gate: `no_auth_configured() && peer_is_loopback()` at the top of `validate_auth` (`server/middleware/auth.rs`), using `ConnectInfo` (the proxy is now served with `into_make_service_with_connect_info`), NOT `X-Forwarded-For`. `effective_auth_mode()` (`keys`/`open_relay`/`loopback_only`) is surfaced via `GET /admin/api/status` and the admin UI banner.
- **Auth defaults to reject-all.** Without `PROXY_API_KEYS`, `PROXY_OPEN_RELAY=true`, virtual keys, or OIDC, every request gets 401, including from localhost. `effective_auth_mode()` (`keys`/`open_relay`/`auth_required`) is surfaced via `GET /admin/api/status` and the admin UI banner.
- **Admin rate limiter resets on restart.** 10 RPM per source IP, in-memory sliding window. `set_admin_rpm()` overrides for tests.
- **Docker admin needs `ADMIN_BIND=0.0.0.0`.** Default binds to 127.0.0.1 which is unreachable from outside the container.
- **PLAN.md references in source comments are stale.** Some files reference line ranges in a removed PLAN.md.
+1 -1
View File
@@ -33,7 +33,7 @@ cargo test --test live_api -- --ignored --test-threads=1 # needs real key
- **Admin UI defaults on for bare invocation.** Bare `anyllm-proxy` (no args) starts proxy + admin UI and auto-opens the browser (zero-arg default). `--webui`/`--admin` or `WEBUI=1`/`ADMIN=1` force it on with other args (no browser). Any other arg keeps it CLI-only. `DISABLE_ADMIN=1` force-disables. Single gate: `main_helpers::bootstrap::admin_enabled` (used by `main.rs` and `init_admin`); browser open only on `is_default_launch` via `main_helpers::browser::open`.
- **Runtime smoke-test in isolation.** Use a fresh `ANYLLM_HOME=$(mktemp -d)` + non-default `LISTEN_PORT`/`ADMIN_PORT`: the real `~/.anyllm` DB's persisted admin config overrides can hang `--webui` *before* the servers bind (stalls right after "applied config overrides from database"), and port 3000 is often held by other dev servers (giving false `200`s from something that isn't the proxy). `--redact-secrets`/`REDACT_SECRETS` also adds multi-second startup — allow more time or omit for quick checks.
- **`main_helpers` (bin-only) tests can't use `ENV_TEST_LOCK`.** It's `pub(crate)` in the lib crate, unreachable from the bin crate. For bin-only code that reads env, split the pure logic (e.g. `admin_requested(args)`) from the env read (`admin_enabled`) and unit-test the pure part with no env mutation, instead of trying to serialize on the lock.
- **Auth defaults to loopback-open.** No `PROXY_API_KEYS`, no `PROXY_OPEN_RELAY`, no virtual keys, no OIDC => loopback peers accepted, LAN/remote get 401. Gate is `no_auth_configured() && peer_is_loopback()` at the top of `validate_auth` (`middleware/auth.rs`); proxy is served with `into_make_service_with_connect_info` so `ConnectInfo` is present. `effective_auth_mode()` feeds `GET /admin/api/status` (`auth_mode`) and the admin UI banner.
- **Auth defaults to reject-all.** Without `PROXY_API_KEYS`, `PROXY_OPEN_RELAY=true`, virtual keys, or OIDC, every request gets 401, including from localhost. `effective_auth_mode()` feeds `GET /admin/api/status` (`auth_mode`) and the admin UI banner.
- **CSRF tokens are one-time-use.** Fetch a fresh one from `GET /admin/csrf-token` before each admin POST/PUT/DELETE. Scripts must too.
- **Live admin-endpoint smoke:** run with `ADMIN_TOKEN=<32+ chars> ... --webui` (admin on :3001). GET needs `Authorization: Bearer $ADMIN_TOKEN`. POST/PUT/DELETE ALSO need CSRF: `GET /admin/csrf-token` with a cookie jar (`curl -c jar`), then resend with `-b jar` + `X-CSRF-Token: <token>` (header must equal the cookie). Missing/mismatched CSRF returns 403 before your handler runs.
- **`main_helpers` is bin-only** (declared in `main.rs`, NOT `lib.rs`). Library code (anything reached via `crate::` at runtime, e.g. `optimizer.rs`) cannot use `crate::main_helpers::bootstrap::*` — it won't compile. The data-dir/home helpers live in `crate::config::helpers::{resolve_data_dir, home_dir}`; use those from lib code.
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -8,10 +8,10 @@ export interface ProxyStatus {
proxy_running: boolean
/**
* Effective proxy auth posture. "keys": a key is required. "open_relay": any
* key accepted on all interfaces. "loopback_only": no auth, localhost open and
* LAN rejected (the default). Drives the top-of-app warning banner.
* key accepted on all interfaces. "auth_required": no authentication source is
* configured, so all requests are rejected. Drives the top-of-app warning banner.
*/
auth_mode: 'keys' | 'open_relay' | 'loopback_only'
auth_mode: 'keys' | 'open_relay' | 'auth_required'
/** Number of distinct static PROXY_API_KEYS entries. */
proxy_key_count: number
}
@@ -14,7 +14,7 @@ const box = (accent: string): CSSProperties => ({
/**
* Site-wide warning banners rendered above every tab. Reflects live state
* (cleared automatically when fixed): proxy auth is open (open_relay) or unset
* (loopback_only). useStatus is React Query-cached, so no extra fetch.
* (auth_required). useStatus is React Query-cached, so no extra fetch.
*
* Deliberately does NOT warn on an empty Models tab: that lists only
* model-router deployments (virtual model aliases), which are optional. A
@@ -35,13 +35,11 @@ export default function AppBanner() {
<span className="mono">PROXY_API_KEYS</span> to require a key.
</div>,
)
} else if (status?.auth_mode === 'loopback_only') {
} else if (status?.auth_mode === 'auth_required') {
banners.push(
<div key="auth" style={box('var(--warn)')}>
<strong>No API key set.</strong> The proxy is open on localhost only;
LAN/remote requests are rejected. Set{' '}
<span className="mono">PROXY_API_KEYS</span> to require a key for remote
access.
<strong>No API key set.</strong> The proxy rejects all requests. Set{' '}
<span className="mono">PROXY_API_KEYS</span> to allow authenticated access.
</div>,
)
}
+2 -2
View File
@@ -11,8 +11,8 @@ pub struct ProxyStatus {
/// Whether the proxy's own port accepts a TCP connection right now.
pub proxy_running: bool,
/// Effective proxy auth posture: `"keys"` (enforced), `"open_relay"` (any
/// key accepted on all interfaces), or `"loopback_only"` (no auth; localhost
/// open, LAN rejected, the default). Drives the admin UI warning banner.
/// key accepted on all interfaces), or `"auth_required"` (no authentication
/// source configured; all requests rejected). Drives the admin UI warning banner.
pub auth_mode: crate::server::middleware::EffectiveAuthMode,
/// Number of distinct static `PROXY_API_KEYS` entries (deduplicated).
pub proxy_key_count: usize,
@@ -336,8 +336,7 @@ pub async fn async_main(args: Vec<String>, data_dir: PathBuf) {
let (shutdown_tx, mut shutdown_rx1) = tokio::sync::watch::channel(false);
let proxy_handle = tokio::spawn(async move {
// connect-info supplies the TCP peer SocketAddr to request extensions;
// auth's loopback-open default and the IP allowlist read it.
// ConnectInfo supplies the TCP peer SocketAddr for the IP allowlist.
axum::serve(
proxy_listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
+19 -69
View File
@@ -74,7 +74,7 @@ pub struct VirtualKeyContext {
pub(crate) period_reset: Option<String>,
}
/// Which of `validate_auth`'s four success paths authenticated this request.
/// Which of `validate_auth`'s success paths authenticated this request.
/// Inserted into request extensions at every success branch so a handler can
/// tell what kind of credential got it in -- used by `ANTHROPIC_FORWARD_CLIENT_AUTH`
/// to decide whether it's safe to forward that same credential upstream as the
@@ -93,10 +93,6 @@ pub enum ClientAuthPath {
VirtualKey,
/// `PROXY_OPEN_RELAY=true`: any non-empty credential accepted.
OpenRelay,
/// Loopback-open default: no proxy auth configured at all, request came
/// from a loopback peer. Not a real credential, so never forwarded upstream
/// (`client_auth_forwardable` returns false for it).
LoopbackOpen,
}
/// Controls which authentication paths are active.
@@ -155,7 +151,7 @@ static ALLOWED_KEY_HASHES: LazyLock<Vec<[u8; 32]>> = LazyLock::new(|| {
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// Posture logging (open-relay warn / loopback-open warn) is emitted once at
// Posture logging is emitted once at
// startup by `log_effective_auth_posture`, AFTER virtual keys and OIDC are
// registered, so it reflects the true posture instead of the partial
// static-key/open-relay state visible at this LazyLock's init time.
@@ -202,10 +198,7 @@ fn has_virtual_keys() -> bool {
VIRTUAL_KEYS.get().map(|m| !m.is_empty()).unwrap_or(false)
}
/// Whether the proxy has NO auth configured at all: no static keys, no open
/// relay, no virtual keys, no OIDC. In this state the proxy falls back to the
/// loopback-open default (see [`validate_auth`]): localhost is accepted without
/// a credential, LAN/remote peers are still rejected with 401.
/// Whether the proxy has no usable authentication source configured.
fn no_auth_configured() -> bool {
ALLOWED_KEY_HASHES.is_empty()
&& !*OPEN_RELAY
@@ -225,21 +218,21 @@ pub enum EffectiveAuthMode {
OpenRelay,
/// At least one static key, virtual key, or OIDC configured (auth enforced).
Keys,
/// Nothing configured: localhost open, LAN rejected (the default).
LoopbackOnly,
/// Nothing configured: every request is rejected.
AuthRequired,
}
/// The effective auth posture for this process:
/// - [`EffectiveAuthMode::OpenRelay`] when `PROXY_OPEN_RELAY=true` (any non-empty
/// key accepted on all interfaces).
/// - [`EffectiveAuthMode::LoopbackOnly`] when nothing is configured (localhost
/// open, LAN rejected, the default).
/// - [`EffectiveAuthMode::AuthRequired`] when nothing is configured (all
/// requests are rejected, the default).
/// - [`EffectiveAuthMode::Keys`] otherwise (auth enforced).
pub fn effective_auth_mode() -> EffectiveAuthMode {
if open_relay_active() {
EffectiveAuthMode::OpenRelay
} else if no_auth_configured() {
EffectiveAuthMode::LoopbackOnly
EffectiveAuthMode::AuthRequired
} else {
EffectiveAuthMode::Keys
}
@@ -249,18 +242,18 @@ pub fn effective_auth_mode() -> EffectiveAuthMode {
/// from `async_main` after virtual keys and OIDC are registered, so the message
/// is accurate even for an OIDC-only or virtual-key-only deployment (unlike the
/// old boot-time warn, which fired from the static-key `LazyLock` before those
/// sources existed and could mislabel such setups as "loopback-only").
/// sources existed and could mislabel such setups as unconfigured).
pub fn log_effective_auth_posture() {
match effective_auth_mode() {
EffectiveAuthMode::OpenRelay => tracing::warn!(
"PROXY_OPEN_RELAY=true: proxy accepts ANY non-empty key on all \
interfaces. Set PROXY_API_KEYS to restrict access."
),
EffectiveAuthMode::LoopbackOnly => tracing::warn!(
EffectiveAuthMode::AuthRequired => tracing::warn!(
"No PROXY_API_KEYS, virtual keys, OIDC, or PROXY_OPEN_RELAY set: \
accepting unauthenticated requests from localhost only; LAN/remote \
peers get 401. Set PROXY_API_KEYS to require a key, or \
PROXY_OPEN_RELAY=true to accept any key on all interfaces."
rejecting all proxy requests. Set PROXY_API_KEYS to allow \
authenticated access, or PROXY_OPEN_RELAY=true to accept any key \
on all interfaces."
),
EffectiveAuthMode::Keys => {
tracing::info!("proxy auth enforced via keys / virtual keys / OIDC")
@@ -268,35 +261,6 @@ pub fn log_effective_auth_posture() {
}
}
/// Whether the request's TCP peer is a loopback address. Reads `ConnectInfo`
/// (the real connection peer), NOT `X-Forwarded-For`, which is client-spoofable.
/// Fails closed: if `ConnectInfo` is absent (proxy not served with connect
/// info), returns false so the loopback-open default never accidentally opens.
fn peer_is_loopback(request: &Request<Body>) -> bool {
request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| is_loopback_ip(ci.0.ip()))
.unwrap_or(false)
}
/// Loopback test that also accepts IPv4-mapped IPv6 (`::ffff:127.0.0.1`):
/// dual-stack listeners present IPv4 loopback peers as mapped v6, which std's
/// `Ipv6Addr::is_loopback()` (only `::1`) would wrongly classify as remote and
/// reject with 401. Never widens the surface beyond a genuine loopback peer.
fn is_loopback_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => v4.is_loopback(),
std::net::IpAddr::V6(v6) => {
v6.is_loopback()
|| v6
.to_ipv4_mapped()
.map(|v4| v4.is_loopback())
.unwrap_or(false)
}
}
}
/// True when forwarding the client's own credential upstream
/// (`ANTHROPIC_FORWARD_CLIENT_AUTH`) could let different callers each
/// redirect the real Anthropic credential: 2+ distinct static keys with no
@@ -310,7 +274,8 @@ pub fn forward_client_auth_misconfigured(key_count: usize, open_relay: bool) ->
/// Validate that the request carries a valid API key.
/// If `PROXY_API_KEYS` is set, the caller's key must be in the allowlist.
/// Otherwise, any non-empty key is accepted (backward-compatible open mode).
/// `PROXY_OPEN_RELAY=true` explicitly accepts any non-empty key; otherwise,
/// requests are rejected when no authentication source is configured.
///
/// Anthropic: <https://docs.anthropic.com/en/api/messages>
pub async fn validate_auth(
@@ -318,20 +283,6 @@ pub async fn validate_auth(
mut request: Request<Body>,
next: Next,
) -> Result<Response, Response> {
// Loopback-open default: when NO proxy auth is configured (no static keys,
// no open relay, no virtual keys, no OIDC), accept requests whose TCP peer
// is loopback so `localhost` works out of the box. LAN/remote peers fall
// through to the checks below and get 401. Runs before credential parsing so
// a header-less localhost call succeeds.
// ponytail: trusts the TCP peer. Behind a reverse proxy on localhost every
// request looks loopback -> effectively open; set PROXY_API_KEYS then.
if no_auth_configured() && peer_is_loopback(&request) {
request
.extensions_mut()
.insert(ClientAuthPath::LoopbackOpen);
return Ok(next.run(request).await);
}
// Accept x-api-key (Anthropic), x-goog-api-key (Gemini CLI), or Authorization: Bearer.
let api_key = headers
.get("x-api-key")
@@ -597,12 +548,11 @@ pub async fn validate_auth(
return Ok(next.run(request).await);
}
// No match found: reject. `no_auth_configured()` here means the peer is
// non-loopback (loopback short-circuits at the top), so explain the
// localhost-only default rather than a generic "not configured".
// No match found: reject. When no auth source is configured, fail closed
// rather than exposing a browser-reachable local proxy.
let message = if no_auth_configured() {
"This proxy accepts unauthenticated requests from localhost only. \
Set PROXY_API_KEYS to allow authenticated remote access."
"Authentication is not configured. Set PROXY_API_KEYS to allow \
authenticated access."
} else {
"Invalid API key."
};
@@ -71,30 +71,3 @@ fn forward_client_auth_allows_exactly_one_key() {
fn forward_client_auth_allows_zero_keys() {
assert!(!forward_client_auth_misconfigured(0, false));
}
#[test]
fn peer_is_loopback_reads_connect_info() {
use axum::extract::ConnectInfo;
use std::net::SocketAddr;
let loopback = |addr: &str| {
let mut req = axum::http::Request::new(axum::body::Body::empty());
req.extensions_mut()
.insert(ConnectInfo(addr.parse::<SocketAddr>().unwrap()));
peer_is_loopback(&req)
};
assert!(loopback("127.0.0.1:5000"));
assert!(loopback("[::1]:5000"));
// IPv4-mapped IPv6: dual-stack listeners present IPv4 loopback peers as
// `::ffff:127.0.0.1`, which std `Ipv6Addr::is_loopback()` (only `::1`)
// would reject. Must still count as loopback.
assert!(loopback("[::ffff:127.0.0.1]:5000"));
// ... but a mapped non-loopback IPv4 must not.
assert!(!loopback("[::ffff:192.168.1.5]:5000"));
assert!(!loopback("192.168.1.5:5000"));
assert!(!loopback("10.0.0.3:5000"));
// No ConnectInfo present -> fail closed (never auto-open).
let bare = axum::http::Request::new(axum::body::Body::empty());
assert!(!peer_is_loopback(&bare));
}
@@ -161,9 +161,6 @@ mod tests {
assert!(client_auth_forwardable(Some(ClientAuthPath::OpenRelay)));
assert!(!client_auth_forwardable(Some(ClientAuthPath::VirtualKey)));
assert!(!client_auth_forwardable(Some(ClientAuthPath::OidcJwt)));
// Loopback-open is not a real credential, so it must never be forwarded
// upstream (locks the security-relevant default against future regressions).
assert!(!client_auth_forwardable(Some(ClientAuthPath::LoopbackOpen)));
assert!(!client_auth_forwardable(None));
}
+3 -3
View File
@@ -55,11 +55,11 @@ Every proxy API endpoint except `/health` requires authentication.
Unauthenticated requests return `401 Unauthorized` with an Anthropic-shaped error body.
### Loopback-open default
### Authentication default
When **no** proxy auth is configured (no `PROXY_API_KEYS`, no `PROXY_OPEN_RELAY=true`, no virtual keys, no OIDC), the proxy accepts unauthenticated requests **from loopback (localhost) peers only**; LAN/remote peers still get `401`. This makes local dev work out of the box while keeping the port closed to the network. The decision uses the real TCP peer address (`ConnectInfo`), not the client-spoofable `X-Forwarded-For`.
When **no** proxy auth is configured (no `PROXY_API_KEYS`, no `PROXY_OPEN_RELAY=true`, no virtual keys, no OIDC), the proxy rejects every request with `401`, including requests from localhost. Set `PROXY_API_KEYS` to allow authenticated access, or set `PROXY_OPEN_RELAY=true` only for explicitly open local development.
Caveat: behind a reverse proxy running on localhost, every request appears to come from loopback, so the proxy is effectively open. Set `PROXY_API_KEYS` in that topology. The effective posture is reported as `auth_mode` (`keys` / `open_relay` / `loopback_only`) by `GET /admin/api/status` and surfaced as a warning banner in the admin UI.
The effective posture is reported as `auth_mode` (`keys` / `open_relay` / `auth_required`) by `GET /admin/api/status` and surfaced as a warning banner in the admin UI.
### IP allowlist