Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 16 additions & 0 deletions Cargo.lock

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

7 changes: 7 additions & 0 deletions crates/fleetd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,17 @@ async-trait = { workspace = true }
axum = { workspace = true }
dotenvy = { workspace = true }
rusqlite = { workspace = true }
# CORS. The one crate this adds to the graph (`tower` 0.5.3 is already resolved via
# axum). Hand-rolling the headers is where `Vary: Origin` cache-poisoning bugs live,
# and preflight is fiddly enough to be worth a maintained implementation.
tower-http = { version = "0.6", features = ["cors"] }

[dev-dependencies]
tokio = { version = "1", features = ["test-util"] }
# Real WebSocket client for the /stream integration test (same versions axum
# already resolves, so no new crates enter the lock graph).
tokio-tungstenite = "0.24"
futures-util = "0.3"
# `ServiceExt::oneshot` for router-level request tests. Already in the lock graph
# via axum, so this adds no new crate.
tower = { version = "0.5", features = ["util"] }
142 changes: 141 additions & 1 deletion crates/fleetd/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ use axum::{
ws::{Message, WebSocket, WebSocketUpgrade},
Path, Query, State,
},
http::StatusCode,
http::{header, HeaderValue, Method, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use tower_http::cors::{AllowOrigin, CorsLayer};
use fleet_core::{Command, Event, GateConfig, Phase, Tier};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
Expand Down Expand Up @@ -138,6 +139,53 @@ impl Default for AppState {
}
}

/// Origins permitted to call the daemon from a browser context.
///
/// **Deliberately an allowlist, never `Any`.** fleetd binds loopback but exposes
/// command endpoints (`POST /missions`, `POST /units/:id/commands`); with a
/// permissive origin, any page the operator happened to visit could drive the
/// daemon, because "localhost" is reachable from every website in the browser.
/// The bind address is not a security boundary against a browser.
///
/// Defaults cover the Tauri webview (whose origin differs by platform) and the
/// Vite dev server. `FLEETD_ALLOWED_ORIGINS` overrides with a comma-separated
/// list, for a cockpit served from somewhere else.
fn allowed_origins() -> Vec<HeaderValue> {
const DEFAULTS: &[&str] = &[
"tauri://localhost", // Tauri v2, macOS/Linux
"http://tauri.localhost", // Tauri v2, Windows
"http://localhost:5173", // Vite dev
"http://127.0.0.1:5173",
];

match std::env::var("FLEETD_ALLOWED_ORIGINS") {
Ok(raw) if !raw.trim().is_empty() => raw
.split(',')
.map(str::trim)
.filter(|o| !o.is_empty())
.filter_map(|o| HeaderValue::from_str(o).ok())
.collect(),
_ => DEFAULTS
.iter()
.filter_map(|o| HeaderValue::from_str(o).ok())
.collect(),
}
}

/// D-3. Without this every browser `fetch` from the cockpit fails and the FLEET
/// ops grid is empty — `store.reconnect()` calls `listUnits()` over HTTP before
/// opening any stream, so a missing CORS layer blocks discovery entirely rather
/// than degrading it.
///
/// Credentials are NOT allowed: the daemon has no cookie or Authorization auth,
/// so enabling them would only widen what a permitted origin can do.
fn cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_origin(AllowOrigin::list(allowed_origins()))
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([header::CONTENT_TYPE])
}

/// Build the router.
pub fn router(state: AppState) -> Router {
Router::new()
Expand All @@ -151,6 +199,7 @@ pub fn router(state: AppState) -> Router {
.route("/swarms", post(create_swarm).get(list_swarms))
.route("/swarms/:id", get(get_swarm))
.with_state(state)
.layer(cors_layer())
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -2223,4 +2272,95 @@ mod tests {
"nothing launched"
);
}

// ── D-3 · CORS ──────────────────────────────────────────────────────────
// Without these headers the cockpit cannot reach the daemon at all: the FLEET
// ops grid calls `listUnits()` over HTTP before opening any stream, so a
// missing layer blocks discovery rather than degrading it. The negative case
// matters as much as the positive one — fleetd takes commands on loopback,
// and loopback is reachable from every website in the browser.

use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt; // `oneshot`

const ALLOWED: &str = "http://localhost:5173";
const HOSTILE: &str = "https://evil.example";

#[tokio::test]
async fn preflight_on_a_command_route_is_answered_not_405() {
// The measured D-3 symptom was `OPTIONS /missions` → 405.
let res = router(AppState::default())
.oneshot(
Request::builder()
.method(Method::OPTIONS)
.uri("/missions")
.header("origin", ALLOWED)
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert!(
res.status().is_success(),
"preflight must be answered, got {}",
res.status()
);
assert_eq!(
res.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.map(|v| v.to_str().unwrap()),
Some(ALLOWED),
);
let methods = res
.headers()
.get(header::ACCESS_CONTROL_ALLOW_METHODS)
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
assert!(methods.contains("POST"), "POST must be allowed, got {methods:?}");
}

#[tokio::test]
async fn an_allowed_origin_gets_the_header_on_a_real_response() {
let res = router(AppState::default())
.oneshot(
Request::builder()
.uri("/health")
.header("origin", ALLOWED)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(
res.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.map(|v| v.to_str().unwrap()),
Some(ALLOWED),
);
}

#[tokio::test]
async fn an_unlisted_origin_gets_no_allow_origin_header() {
// The check that makes the allowlist mean something. If this ever passes a
// header back, any page the operator visits can POST /missions.
let res = router(AppState::default())
.oneshot(
Request::builder()
.uri("/health")
.header("origin", HOSTILE)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert!(
res.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).is_none(),
"an unlisted origin must not be granted access",
);
}
}
Loading