Files
rightnow-ai--openfang/crates/openfang-api/src/middleware.rs
T
wehub-resource-sync d93385b373
CI / Check / macos-latest (push) Waiting to run
CI / Check / ubuntu-latest (push) Waiting to run
CI / Check / windows-latest (push) Waiting to run
CI / Test / macos-latest (push) Waiting to run
CI / Test / ubuntu-latest (push) Waiting to run
CI / Test / windows-latest (push) Waiting to run
CI / Clippy (push) Waiting to run
CI / Format (push) Waiting to run
CI / Security Audit (push) Waiting to run
CI / Secrets Scan (push) Waiting to run
CI / Install Script Smoke Test (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:43:09 +08:00

400 lines
14 KiB
Rust

//! Production middleware for the OpenFang API server.
//!
//! Provides:
//! - Request ID generation and propagation
//! - Per-endpoint structured request logging
//! - In-memory rate limiting (per IP)
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use axum::middleware::Next;
use std::time::Instant;
use tracing::info;
/// Request ID header name (standard).
pub const REQUEST_ID_HEADER: &str = "x-request-id";
/// Middleware: inject a unique request ID and log the request/response.
pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Body> {
let request_id = uuid::Uuid::new_v4().to_string();
let method = request.method().clone();
let uri = request.uri().path().to_string();
let start = Instant::now();
let mut response = next.run(request).await;
let elapsed = start.elapsed();
let status = response.status().as_u16();
info!(
request_id = %request_id,
method = %method,
path = %uri,
status = status,
latency_ms = elapsed.as_millis() as u64,
"API request"
);
// Inject the request ID into the response
if let Ok(header_val) = request_id.parse() {
response.headers_mut().insert(REQUEST_ID_HEADER, header_val);
}
response
}
/// Authentication state passed to the auth middleware.
#[derive(Clone)]
pub struct AuthState {
pub api_key: String,
pub auth_enabled: bool,
pub session_secret: String,
/// Set from `OPENFANG_ALLOW_NO_AUTH=1` to permit running without an api_key
/// on a non-loopback bind. Off by default so empty keys fail closed.
pub allow_no_auth: bool,
}
/// Bearer token authentication middleware.
///
/// When `api_key` is non-empty (after trimming), requests to non-public
/// endpoints must include `Authorization: Bearer <api_key>`.
///
/// When `api_key` is empty (no key configured) the server defaults to
/// fail-closed for any request that does NOT originate from loopback.
/// Loopback traffic (127.0.0.1 / ::1) is always allowed through with no
/// key so single-user local setups keep zero-config UX. To explicitly
/// run a no-auth server on a LAN/WAN address, set
/// `OPENFANG_ALLOW_NO_AUTH=1`; this opts out of fail-closed and is
/// reported loudly at startup.
///
/// When dashboard auth is enabled, session cookies are also accepted.
pub async fn auth(
axum::extract::State(auth_state): axum::extract::State<AuthState>,
request: Request<Body>,
next: Next,
) -> Response<Body> {
// SECURITY: Capture method early for method-aware public endpoint checks.
let method = request.method().clone();
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false); // SECURITY: default-deny; unknown origin is NOT loopback
// Shutdown is loopback-only (CLI on same machine). Skip token auth only
// when the request is from loopback.
let path = request.uri().path();
if path == "/api/shutdown" && is_loopback {
return next.run(request).await;
}
// Public endpoints that don't require auth (dashboard needs these).
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
// SECURITY: Public endpoints are GET-only unless explicitly noted.
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
// unauthenticated writes (cron job creation, skill install, etc.).
let is_get = method == axum::http::Method::GET;
let is_public = path == "/"
|| path == "/logo.png"
|| path == "/favicon.ico"
|| (path == "/.well-known/agent.json" && is_get)
|| (path.starts_with("/a2a/") && is_get)
|| path == "/api/health"
|| path == "/api/health/detail"
|| path == "/api/status"
|| path == "/api/version"
|| (path == "/api/agents" && is_get)
|| (path == "/api/profiles" && is_get)
|| (path == "/api/config" && is_get)
|| (path == "/api/config/schema" && is_get)
|| (path.starts_with("/api/uploads/") && is_get)
// Dashboard read endpoints — allow unauthenticated so the SPA can
// render before the user enters their API key.
|| (path == "/api/models" && is_get)
|| (path == "/api/models/aliases" && is_get)
|| (path == "/api/providers" && is_get)
|| (path == "/api/budget" && is_get)
|| (path == "/api/budget/agents" && is_get)
|| (path.starts_with("/api/budget/agents/") && is_get)
|| (path == "/api/network/status" && is_get)
|| (path == "/api/a2a/agents" && is_get)
|| (path == "/api/approvals" && is_get)
|| (path.starts_with("/api/approvals/") && is_get)
|| (path == "/api/channels" && is_get)
|| (path == "/api/hands" && is_get)
|| (path == "/api/hands/active" && is_get)
|| (path.starts_with("/api/hands/") && is_get)
|| (path == "/api/skills" && is_get)
|| (path.starts_with("/api/skills/") && path.ends_with("/config") && is_get)
|| (path == "/api/sessions" && is_get)
|| (path == "/api/integrations" && is_get)
|| (path == "/api/integrations/available" && is_get)
|| (path == "/api/integrations/health" && is_get)
|| (path == "/api/workflows" && is_get)
|| path == "/api/logs/stream" // SSE stream, read-only
|| (path.starts_with("/api/cron/") && is_get)
|| path.starts_with("/api/providers/github-copilot/oauth/")
|| path == "/api/auth/login"
|| path == "/api/auth/logout"
|| (path == "/api/auth/check" && is_get);
if is_public {
return next.run(request).await;
}
// If no API key configured and no dashboard login is active, fail closed
// for anything that did not come from loopback. Opting out of this
// behavior requires setting `OPENFANG_ALLOW_NO_AUTH=1`, which is logged
// loudly at startup.
//
// See issue #1034 (B1/B2): empty api_key previously bypassed auth for
// all origins, exposing agent config, channel tokens, and LLM keys on
// any LAN-reachable bind.
let api_key_trimmed = auth_state.api_key.trim().to_string();
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
if is_loopback || auth_state.allow_no_auth {
return next.run(request).await;
}
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("www-authenticate", "Bearer")
.body(Body::from(
serde_json::json!({
"error": "API key required for non-loopback requests. Set OPENFANG_API_KEY or bind to 127.0.0.1."
})
.to_string(),
))
.unwrap_or_default();
}
let api_key = api_key_trimmed.as_str();
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
let bearer_token = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
let api_token = bearer_token.or_else(|| {
request
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
});
// SECURITY: Use constant-time comparison to prevent timing attacks.
let header_auth = api_token.map(|token| {
use subtle::ConstantTimeEq;
if token.len() != api_key.len() {
return false;
}
token.as_bytes().ct_eq(api_key.as_bytes()).into()
});
// Also check ?token= query parameter (for EventSource/SSE clients that
// cannot set custom headers, same approach as WebSocket auth).
let query_token_decoded = request
.uri()
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(crate::percent_decode);
// SECURITY: Use constant-time comparison to prevent timing attacks.
let query_auth = query_token_decoded.as_deref().map(|token| {
use subtle::ConstantTimeEq;
if token.len() != api_key.len() {
return false;
}
token.as_bytes().ct_eq(api_key.as_bytes()).into()
});
// Accept if either auth method matches
if header_auth == Some(true) || query_auth == Some(true) {
return next.run(request).await;
}
// Check session cookie (dashboard login sessions)
if auth_state.auth_enabled {
if let Some(token) = crate::session_auth::extract_session_cookie(request.headers()) {
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
.is_some()
{
return next.run(request).await;
}
}
}
// Determine error message: was a credential provided but wrong, or missing entirely?
let credential_provided = header_auth.is_some() || query_auth.is_some();
let error_msg = if credential_provided {
"Invalid API key"
} else {
"Missing Authorization: Bearer <api_key> header"
};
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("www-authenticate", "Bearer")
.body(Body::from(
serde_json::json!({"error": error_msg}).to_string(),
))
.unwrap_or_default()
}
/// Security headers middleware — applied to ALL API responses.
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert("x-content-type-options", "nosniff".parse().unwrap());
headers.insert("x-frame-options", "DENY".parse().unwrap());
headers.insert("x-xss-protection", "1; mode=block".parse().unwrap());
// The dashboard handler (webchat_page) sets its own nonce-based CSP.
// For all other responses (API endpoints), apply a strict default.
if !headers.contains_key("content-security-policy") {
headers.insert(
"content-security-policy",
"default-src 'none'; frame-ancestors 'none'"
.parse()
.unwrap(),
);
}
headers.insert(
"referrer-policy",
"strict-origin-when-cross-origin".parse().unwrap(),
);
headers.insert(
"cache-control",
"no-store, no-cache, must-revalidate".parse().unwrap(),
);
headers.insert(
"strict-transport-security",
"max-age=63072000; includeSubDomains".parse().unwrap(),
);
response
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Method, Request};
use axum::routing::get;
use axum::Router;
use std::net::SocketAddr;
use tower::ServiceExt;
#[test]
fn test_request_id_header_constant() {
assert_eq!(REQUEST_ID_HEADER, "x-request-id");
}
fn auth_state_empty() -> AuthState {
AuthState {
api_key: String::new(),
auth_enabled: false,
session_secret: String::new(),
allow_no_auth: false,
}
}
fn auth_state_with_key(key: &str) -> AuthState {
AuthState {
api_key: key.to_string(),
auth_enabled: false,
session_secret: key.to_string(),
allow_no_auth: false,
}
}
async fn ok_handler() -> &'static str {
"ok"
}
fn router(state: AuthState) -> Router {
Router::new()
.route("/api/agents/1", get(ok_handler))
.route_layer(axum::middleware::from_fn_with_state(state, auth))
}
fn req_from(ip: &str) -> Request<Body> {
let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
let mut req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(addr));
req
}
#[tokio::test]
async fn empty_key_allows_loopback() {
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn empty_key_blocks_lan_origin() {
// Issue #1034 B1: previously 192.168/10/... could hit every non-public
// endpoint when api_key was unset. Must now be 401.
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("192.168.1.50")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_blocks_public_origin() {
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("203.0.113.5")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_blocks_unknown_connect_info() {
// Paranoia: if ConnectInfo is missing for any reason, we must fail
// closed, not open.
let app = router(auth_state_empty());
let req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_with_allow_no_auth_opens_everything() {
let mut s = auth_state_empty();
s.allow_no_auth = true;
let app = router(s);
let resp = app.oneshot(req_from("10.0.0.9")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn configured_key_rejects_missing_token_from_loopback() {
let app = router(auth_state_with_key("secret"));
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn configured_key_accepts_bearer() {
let app = router(auth_state_with_key("secret"));
let addr: SocketAddr = "127.0.0.1:40000".parse().unwrap();
let mut req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.header("authorization", "Bearer secret")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(addr));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
}