Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/rucelium-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ serde = { workspace = true }
serde_json = { workspace = true }

axum = "0.7"
# CORS (dev-only, v0.1 posture): lets a same-machine dashboard served from a
# different origin/port (e.g. RuView's ui/) read this gateway's JSON. See
# `api::router`'s doc comment for scope/limits — matches this crate's
# existing "admin endpoints unauthenticated, bind to localhost" posture
# rather than adding a new one.
tower-http = { version = "0.6", features = ["cors"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal", "sync"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }

Expand Down
20 changes: 19 additions & 1 deletion crates/rucelium-gateway/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ use crate::federation::{accept_artifact, ArtifactEffect, ArtifactRejection};
use crate::state::{now_ns, GatewayState};
use crate::transport::FederationArtifact;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::http::{Method, StatusCode};
use axum::routing::{get, post};
use axum::{Json, Router};
use tower_http::cors::{Any, CorsLayer};
use rucelium_core::EventKind;
use rucelium_federation::{project_sample, SensorThingsBundle};
use rucelium_policy::ControlError;
Expand Down Expand Up @@ -60,6 +61,22 @@ struct WindowParam {
window_s: Option<u64>,
}

/// CORS for `router`'s response, kept as its own function so the policy is
/// named and testable in isolation: read-only (`GET`), any origin, no
/// credentials — lets a same-machine dashboard served from a different
/// origin/port (e.g. RuView's `ui/`, on a different HTTP port) read this
/// gateway's JSON without a proxy. This adds no new exposure beyond what
/// already exists: every route here is unauthenticated in v0.1 regardless of
/// the requester's origin (module docs), so a browser's same-origin policy
/// was never the thing standing between this data and an arbitrary reader —
/// it only ever stopped an arbitrary *page* from reading it in an arbitrary
/// *browser tab*. Revisit when the admin endpoints gain real authentication.
fn cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_methods([Method::GET])
.allow_origin(Any)
}

/// Build the gateway's axum router (see module docs for the endpoint list
/// and the v0.1 admin-endpoint security posture).
pub fn router(state: GatewayState) -> Router {
Expand All @@ -79,6 +96,7 @@ pub fn router(state: GatewayState) -> Router {
.route("/api/admin/revoke/:node_id", post(admin_revoke))
.route("/api/admin/command", post(admin_command))
.with_state(state)
.layer(cors_layer())
}

/// `GET /health` — liveness.
Expand Down
94 changes: 94 additions & 0 deletions crates/rucelium-gateway/tests/cors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//! CORS regression guard for the read-only dashboard use case (`api::cors_layer`
//! doc comment): a same-machine dashboard on a different origin/port must be
//! able to read this gateway's JSON without a proxy, and the policy must stay
//! GET-only.

use rucelium_gateway::{spawn_gateway_with_state, GatewayConfig, GatewayState};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

fn temp_dir(tag: &str) -> PathBuf {
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_nanos();
std::env::temp_dir().join(format!("rucelium-gw-cors-{tag}-{}-{t}", std::process::id()))
}

#[tokio::test(flavor = "multi_thread")]
async fn get_responses_carry_a_permissive_cors_header() {
let dir = temp_dir("get");
let cfg = GatewayConfig {
udp_port: 0,
http_port: 0,
data_dir: dir.clone(),
..GatewayConfig::default()
};
let state = GatewayState::open(&cfg).expect("open state");
let handle = spawn_gateway_with_state(state, cfg)
.await
.expect("spawn gateway");
let base = format!("http://127.0.0.1:{}", handle.http_port);

let client = reqwest::Client::new();
let resp = client
.get(format!("{base}/api/stats"))
.header("Origin", "http://127.0.0.1:8080") // a plausible RuView `ui/` origin
.send()
.await
.expect("GET /api/stats");

assert!(resp.status().is_success());
let allow_origin = resp
.headers()
.get("access-control-allow-origin")
.expect("CORS header must be present on a GET response")
.to_str()
.unwrap();
assert_eq!(
allow_origin, "*",
"any-origin GET access is the documented policy (api::cors_layer)"
);

std::fs::remove_dir_all(&dir).ok();
}

#[tokio::test(flavor = "multi_thread")]
async fn preflight_for_a_write_route_does_not_allow_post() {
let dir = temp_dir("post");
let cfg = GatewayConfig {
udp_port: 0,
http_port: 0,
data_dir: dir.clone(),
..GatewayConfig::default()
};
let state = GatewayState::open(&cfg).expect("open state");
let handle = spawn_gateway_with_state(state, cfg)
.await
.expect("spawn gateway");
let base = format!("http://127.0.0.1:{}", handle.http_port);

let client = reqwest::Client::new();
// A browser preflight (OPTIONS) for a cross-origin POST to an admin
// route must NOT come back allowing POST — the CORS policy is
// deliberately GET-only (dashboard reads, not cross-origin writes).
let resp = client
.request(reqwest::Method::OPTIONS, format!("{base}/api/admin/command"))
.header("Origin", "http://127.0.0.1:8080")
.header("Access-Control-Request-Method", "POST")
.send()
.await
.expect("OPTIONS preflight");

let allow_methods = resp
.headers()
.get("access-control-allow-methods")
.map(|v| v.to_str().unwrap_or("").to_string())
.unwrap_or_default();
assert!(
!allow_methods.contains("POST"),
"CORS policy must stay GET-only, got allow-methods={allow_methods:?}"
);

std::fs::remove_dir_all(&dir).ok();
}