From 0311d814554afa6e529e198341522504763aec27 Mon Sep 17 00:00:00 2001 From: YOMXXX Date: Thu, 21 May 2026 06:30:58 +0800 Subject: [PATCH] fix(security): replace wildcard CORS on core RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replaces wildcard Core RPC CORS behavior with an explicit allowlist for Tauri and loopback origins. - Preserves existing `Vary` response values while appending `Origin`. - Removes unsafe process-global env mutation from CORS tests by injecting env override input directly. - Adds regression coverage for env override exact matching and existing `Vary` preservation. ## Problem - `src/core/jsonrpc.rs` emitted `Access-Control-Allow-Origin: *`, so any browser origin that obtained the bearer token could call the local RPC surface. - The prior fix in #2266 addressed the core issue but still had two review blockers: unsafe env mutation in parallel Rust tests and overwriting existing `Vary` headers. - I could not push directly to #2266's fork branch, so this PR carries the same security fix plus the review follow-ups. ## Solution - Keep `is_origin_allowed(origin)` as the production env-reading entry point. - Add `is_origin_allowed_with_extra(origin, extra_origins)` so tests can exercise override parsing without mutating process-global environment. - Change `with_cors_headers` from `headers.insert(Vary, Origin)` to `headers.append(Vary, Origin)`. - Add focused tests for existing `Vary` preservation and exact-match override behavior. ## Submission Checklist - [x] Tests added or updated (happy path + at least one failure / edge case) per [Testing Strategy](../gitbooks/developing/testing-strategy.md#failure-path-requirement) - [x] **Diff coverage ≥ 80%** — changed lines (Vitest + cargo-llvm-cov merged via `diff-cover`) meet the gate enforced by [`.github/workflows/coverage.yml`](../.github/workflows/coverage.yml). CI coverage gate must confirm this; local focused Rust tests cover the changed paths. - [x] Coverage matrix updated — N/A: security boundary fix covered by focused Rust tests; no feature matrix row added/removed/renamed. - [x] All affected feature IDs from the matrix are listed in the PR description under `## Related` — N/A: no coverage-matrix feature row applies. - [x] No new external network dependencies introduced (mock backend used per [Testing Strategy](../gitbooks/developing/testing-strategy.md#mock-policy)) - [x] Manual smoke checklist updated if this touches release-cut surfaces — N/A: Core RPC header behavior only; no manual release checklist surface changed. - [x] Linked issue closed via `Closes #NNN` in the `## Related` section ## Impact - Security: arbitrary non-allowlisted browser origins no longer receive ACAO for local Core RPC responses. - Compatibility: Tauri webview origins, loopback dev/E2E origins, and non-browser callers remain supported. - Operators can still add exact additional debug origins with `OPENHUMAN_CORE_ALLOWED_ORIGINS`. ## Related - Closes #2262 - Supersedes #2266 because I do not have permission to push review fixes to `leighstillard/fix/cors-allowlist`. - Follow-up PR(s)/TODOs: none. --- ## AI Authored PR Metadata (required for Codex/Linear PRs) ### Linear Issue - Key: N/A - URL: N/A ### Commit & Branch - Branch: `fix/2262-cors-allowlist` - Commit SHA: `9d1341cff29ef1e1b08721885124ce3267f4a99d` ### Validation Run - [x] `pnpm --filter openhuman-app format:check` — N/A: Rust-only Core RPC change. - [x] `pnpm typecheck` — N/A: Rust-only Core RPC change. - [x] Focused tests: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml cors_tests --lib` — 8 passed. - [x] Rust fmt/check (if changed): `cargo fmt --manifest-path Cargo.toml --all`; `GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml --lib`; `git diff --check`. - [x] Tauri fmt/check (if changed): N/A: Tauri shell unchanged. ### Validation Blocked - `command:` N/A - `error:` N/A - `impact:` N/A ### Behavior Changes - Intended behavior change: Core RPC only echoes `Access-Control-Allow-Origin` for allowlisted browser origins instead of wildcard `*`. - User-visible effect: none expected for packaged app, loopback dev, E2E, or non-browser callers. ### Parity Contract - Legacy behavior preserved: Tauri origins, loopback origins, debug env overrides, CORS methods/headers/max-age, and no-Origin non-browser callers remain supported. - Guard/fallback/dispatch parity checks: focused CORS unit tests cover allowed origins, denied origins, no-Origin callers, exact env override matching, and preserved `Vary` values. ### Duplicate / Superseded PR Handling - Duplicate PR(s): #2266 - Canonical PR: this PR if maintainers prefer an immediately updated branch; otherwise #2266 can cherry-pick `9d1341cff29ef1e1b08721885124ce3267f4a99d`. - Resolution (closed/superseded/updated): #2266 remains open; this PR carries the requested review fixes because direct push to the fork branch was denied. ## Summary by CodeRabbit * **Bug Fixes** * Tightened CORS handling to enforce an origin allowlist; only trusted local schemes and loopback addresses are allowed by default, and disallowed origins no longer receive CORS responses. * **Chores** * Added support for configuring extra allowed origins via environment configuration. * **Tests** * Added comprehensive tests for allowlist decisions, header emission (including Vary behavior), and edge cases. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2328?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) Co-authored-by: Leigh Stillard Co-authored-by: 李冠辰 Co-authored-by: Steven Enamakel --- src/core/jsonrpc.rs | 9 +++-- src/core/jsonrpc_cors_tests.rs | 61 ++++++++++++++++++++++------------ 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 4a0da4178..25956784c 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -627,6 +627,11 @@ const ALLOWED_ORIGINS_ENV: &str = "OPENHUMAN_CORE_ALLOWED_ORIGINS"; /// loaded in a CEF child webview) must be refused — the bearer token alone /// is not enough authorization without an origin binding. pub(super) fn is_origin_allowed(origin: &str) -> bool { + let extra_origins = std::env::var(ALLOWED_ORIGINS_ENV).ok(); + is_origin_allowed_with_extra(origin, extra_origins.as_deref()) +} + +pub(super) fn is_origin_allowed_with_extra(origin: &str, extra_origins: Option<&str>) -> bool { // Tauri v2 webview origins. Windows uses an HTTP(S) custom host; macOS // and Linux use the `tauri://` scheme. We accept both for portability. if matches!( @@ -651,7 +656,7 @@ pub(super) fn is_origin_allowed(origin: &str) -> bool { } // Env override: comma-separated exact matches. - if let Ok(extra) = std::env::var(ALLOWED_ORIGINS_ENV) { + if let Some(extra) = extra_origins { for candidate in extra.split(',').map(str::trim).filter(|s| !s.is_empty()) { if candidate == origin { return true; @@ -691,7 +696,7 @@ async fn cors_middleware(req: Request, next: Next) -> Response { /// the calling JS. Non-browser callers (no `Origin` header) are unaffected. pub(super) fn with_cors_headers(mut response: Response, origin: Option<&str>) -> Response { let headers = response.headers_mut(); - headers.insert(header::VARY, HeaderValue::from_static("Origin")); + headers.append(header::VARY, HeaderValue::from_static("Origin")); if let Some(o) = origin { if is_origin_allowed(o) { diff --git a/src/core/jsonrpc_cors_tests.rs b/src/core/jsonrpc_cors_tests.rs index 3454d9bda..d6e838af7 100644 --- a/src/core/jsonrpc_cors_tests.rs +++ b/src/core/jsonrpc_cors_tests.rs @@ -1,9 +1,9 @@ //! Unit tests for the CORS allowlist and header-emission logic in `jsonrpc.rs`. -use axum::http::{header, StatusCode}; +use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; -use super::{is_origin_allowed, with_cors_headers, ALLOWED_ORIGINS_ENV}; +use super::{is_origin_allowed, is_origin_allowed_with_extra, with_cors_headers}; fn ok_response() -> Response { (StatusCode::OK, "").into_response() @@ -78,29 +78,46 @@ fn missing_origin_emits_no_acao_but_sets_vary() { #[test] fn env_override_allows_extra_origins() { - // SAFETY: this test mutates a process-global env var. No other test in - // this crate reads ALLOWED_ORIGINS_ENV, so parallel runs are safe; we - // still restore the previous value on exit to be a good citizen. - let prev = std::env::var(ALLOWED_ORIGINS_ENV).ok(); - unsafe { - std::env::set_var( - ALLOWED_ORIGINS_ENV, - "https://debug.internal, http://harness:9000", - ); - } + let extra_origins = Some("https://debug.internal, http://harness:9000"); - assert!(is_origin_allowed("https://debug.internal")); - assert!(is_origin_allowed("http://harness:9000")); - assert!(!is_origin_allowed( - "https://debug.internal.attacker.example" + assert!(is_origin_allowed_with_extra( + "https://debug.internal", + extra_origins )); + assert!(is_origin_allowed_with_extra( + "http://harness:9000", + extra_origins + )); + assert!(!is_origin_allowed_with_extra( + "https://debug.internal.attacker.example", + extra_origins + )); +} - unsafe { - match prev { - Some(v) => std::env::set_var(ALLOWED_ORIGINS_ENV, v), - None => std::env::remove_var(ALLOWED_ORIGINS_ENV), - } - } +#[test] +fn preserves_existing_vary_values() { + let mut response = ok_response(); + response + .headers_mut() + .insert(header::VARY, HeaderValue::from_static("Accept-Encoding")); + + let r = with_cors_headers(response, None); + let values = r + .headers() + .get_all(header::VARY) + .iter() + .map(|v| v.to_str().unwrap_or("")) + .collect::>(); + + assert_eq!(values, vec!["Accept-Encoding", "Origin"]); +} + +#[test] +fn env_override_does_not_allow_lookalike_suffixes() { + assert!(!is_origin_allowed_with_extra( + "https://debug.internal.attacker.example", + Some("https://debug.internal") + )); } #[test]