From 0685d5530f849fec6724fec37ba0d0c676a6a04e Mon Sep 17 00:00:00 2001 From: andrew Date: Sun, 10 May 2026 16:22:39 +0300 Subject: [PATCH] omnigraph-server: optional CORS layer for browser-based UIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Off by default — production deployments behind a same-origin reverse proxy need no configuration. When OMNIGRAPH_SERVER_CORS_ORIGIN is set (comma-separated origins), attach a tower_http::cors::CorsLayer permitting GET/POST/DELETE plus Authorization and Content-Type request headers. Empty/unset variable preserves prior no-CORS behavior. Tests: cors_default_off_does_not_emit_allow_origin_header, cors_env_origin_emits_allow_origin_header. Companion to omnigraph-ui's web demo (Vite dev origin http://127.0.0.1:5173 hitting the API at 127.0.0.1:8080). Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 2 +- crates/omnigraph-server/src/lib.rs | 38 ++++++++++++- crates/omnigraph-server/tests/server.rs | 74 +++++++++++++++++++++++++ docs/user/server.md | 15 ++++- 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 761f29b..a7d7848 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ serde_yaml = "0.9" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tower = "0.5" -tower-http = { version = "0.6", features = ["trace"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } color-eyre = "0.6" tempfile = "3" ahash = "0.8" diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index 5b63eb0..3a3d650 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -49,6 +49,7 @@ use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; use tokio::net::TcpListener; use tokio::sync::mpsc; +use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::trace::TraceLayer; use tracing::{error, info}; use tracing_subscriber::EnvFilter; @@ -550,15 +551,48 @@ pub fn build_app(state: AppState) -> Router { require_bearer_auth, )); - Router::new() + let mut router = Router::new() .route("/healthz", get(server_health)) .route("/openapi.json", get(server_openapi)) .merge(protected) .layer(DefaultBodyLimit::max(DEFAULT_REQUEST_BODY_LIMIT_BYTES)) .layer(TraceLayer::new_for_http()) - .with_state(state) + .with_state(state); + + if let Some(cors) = build_cors_layer() { + router = router.layer(cors); + } + + router } +/// Build a CORS layer if `OMNIGRAPH_SERVER_CORS_ORIGIN` is set. The value is a +/// comma-separated list of origins. Default off so production deployments are +/// unchanged. +fn build_cors_layer() -> Option { + let raw = std::env::var(CORS_ORIGIN_ENV).ok()?; + let origins: Vec<_> = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse::().ok()) + .collect(); + if origins.is_empty() { + return None; + } + let layer = CorsLayer::new() + .allow_origin(AllowOrigin::list(origins)) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::DELETE, + ]) + .allow_headers([AUTHORIZATION, CONTENT_TYPE]); + Some(layer) +} + +pub const CORS_ORIGIN_ENV: &str = "OMNIGRAPH_SERVER_CORS_ORIGIN"; + pub async fn serve(config: ServerConfig) -> Result<()> { let token_source = resolve_token_source().await?; info!(source = token_source.name(), "loaded bearer token source"); diff --git a/crates/omnigraph-server/tests/server.rs b/crates/omnigraph-server/tests/server.rs index 03f4aa7..732d8e7 100644 --- a/crates/omnigraph-server/tests/server.rs +++ b/crates/omnigraph-server/tests/server.rs @@ -3478,3 +3478,77 @@ async fn oversized_request_body_returns_payload_too_large() { assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); } + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn cors_default_off_does_not_emit_allow_origin_header() { + // Make sure the env is unset when build_app runs. + // SAFETY: tests are gated by #[serial], so no concurrent env reads. + unsafe { + env::remove_var("OMNIGRAPH_SERVER_CORS_ORIGIN"); + } + + let (_temp, app) = app_for_loaded_repo().await; + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/healthz") + .method(Method::GET) + .header("Origin", "http://localhost:5173") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + response.headers().get("access-control-allow-origin").is_none(), + "default-off CORS should not advertise allow-origin", + ); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn cors_env_origin_emits_allow_origin_header() { + // SAFETY: tests are gated by #[serial], so no concurrent env reads. + unsafe { + env::set_var( + "OMNIGRAPH_SERVER_CORS_ORIGIN", + "http://localhost:5173,https://app.example.com", + ); + } + + // Build the app *after* the env var is set so build_cors_layer reads it. + let (_temp, app) = app_for_loaded_repo().await; + + // Reset the env immediately to keep other tests clean. + unsafe { + env::remove_var("OMNIGRAPH_SERVER_CORS_ORIGIN"); + } + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/healthz") + .method(Method::GET) + .header("Origin", "http://localhost:5173") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let allow_origin = response + .headers() + .get("access-control-allow-origin") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + assert_eq!( + allow_origin, "http://localhost:5173", + "CORS layer should echo the matching origin", + ); +} diff --git a/docs/user/server.md b/docs/user/server.md index 6904e99..afd31a2 100644 --- a/docs/user/server.md +++ b/docs/user/server.md @@ -90,9 +90,22 @@ See [deployment.md](deployment.md) for token-source operational details. - Startup logs: token source name, repo URI, bind address - Graceful SIGINT shutdown +## CORS + +Off by default — production deployments behind a same-origin reverse proxy need no +configuration. To enable cross-origin requests (e.g. from a browser-based UI on a +different host/port during development), set: + +- `OMNIGRAPH_SERVER_CORS_ORIGIN` — comma-separated list of allowed origins. + Example: `OMNIGRAPH_SERVER_CORS_ORIGIN=http://localhost:5173,https://app.example.com`. + +When set, the server attaches a `tower_http::cors::CorsLayer` permitting `GET`, `POST`, +`DELETE`, plus the `Authorization` and `Content-Type` request headers. Requests from +origins not in the list receive no CORS allow-origin response and the browser blocks +them. Empty/unset variable → no layer → no CORS headers (default behaviour preserved). + ## Not implemented (by design or "TBD") -- CORS — not configured; add `tower_http::cors` if needed. - Rate limiting — per-actor admission control gates `/change`, `/ingest`, `/branches/{create,delete,merge}`, `/schema/apply` (see "Per-actor admission control" above). No global rate limiter is configured;