Skip to main content

reinhardt_admin/server/
origin_guard.rs

1//! Admin Origin Guard Middleware
2//!
3//! Restricts admin server function access to same-origin requests,
4//! providing an additional security layer beyond authentication.
5//!
6//! # How It Works
7//!
8//! On state-changing requests (POST, PUT, PATCH, DELETE), the middleware
9//! validates the `Origin` or `Referer` header against the `Host` header
10//! to confirm that the request originates from the same domain.
11//!
12//! # Security Model
13//!
14//! This middleware works in concert with HTTP-Only cookie authentication
15//! (`SameSite=Strict`). Together they form a multi-layer defense:
16//!
17//! 1. **`SameSite=Strict` cookie**: browsers never attach the auth cookie
18//!    to cross-origin requests, so external origins fail authentication.
19//! 2. **Origin guard (this middleware)**: rejects cross-origin requests
20//!    early, before they reach the authentication layer. Also defends
21//!    against non-browser clients that forge cookies but cannot forge
22//!    matching `Origin`/`Host` headers in a browser context.
23//! 3. **CSRF token validation**: existing double-submit cookie pattern
24//!    provides an independent CSRF defense for mutation endpoints.
25//!
26//! # Skipped Methods
27//!
28//! GET, HEAD, and OPTIONS requests are exempt because they serve the SPA
29//! shell, static assets, and CORS preflight responses respectively.
30
31use async_trait::async_trait;
32use reinhardt_http::{Handler, Middleware, Request, Response, Result};
33use std::sync::Arc;
34
35/// Middleware that restricts admin server function access to same-origin
36/// requests by validating `Origin`/`Referer` against the `Host` header.
37///
38/// # Example
39///
40/// ```ignore
41/// use reinhardt_admin::server::origin_guard::AdminOriginGuardMiddleware;
42/// use reinhardt_urls::routers::ServerRouter;
43///
44/// let router = ServerRouter::new()
45///     .with_namespace("admin")
46///     .with_middleware(AdminOriginGuardMiddleware);
47/// ```
48pub struct AdminOriginGuardMiddleware;
49
50#[async_trait]
51impl Middleware for AdminOriginGuardMiddleware {
52	async fn process(&self, request: Request, next: Arc<dyn Handler>) -> Result<Response> {
53		// Skip safe methods (SPA HTML, static assets, CORS preflight)
54		if is_safe_method(&request.method) {
55			return next.handle(request).await;
56		}
57
58		// Validate same-origin via Origin or Referer header
59		if !is_same_origin(&request.headers) {
60			tracing::warn!(
61				method = %request.method,
62				uri = %request.uri,
63				"Admin origin guard: cross-origin or missing origin"
64			);
65			return Ok(Response::new(hyper::StatusCode::FORBIDDEN)
66				.with_header("Content-Type", "application/json")
67				.with_body(
68					r#"{"error":"Forbidden: cross-origin admin requests are not allowed"}"#,
69				));
70		}
71
72		next.handle(request).await
73	}
74}
75
76/// Returns true for HTTP methods that don't require origin validation.
77fn is_safe_method(method: &hyper::Method) -> bool {
78	matches!(
79		*method,
80		hyper::Method::GET | hyper::Method::HEAD | hyper::Method::OPTIONS
81	)
82}
83
84/// Validates that the request originates from the same origin by comparing
85/// the `Origin` (or `Referer`) header against the `Host` header.
86///
87/// Returns `true` if:
88/// - The `Origin` header's host matches the `Host` header, OR
89/// - The `Referer` header's host matches the `Host` header.
90///
91/// Returns `false` if:
92/// - Neither `Origin` nor `Referer` is present (rejects non-browser
93///   clients that don't supply origin information), OR
94/// - The origin doesn't match the host.
95fn is_same_origin(headers: &hyper::HeaderMap) -> bool {
96	let host = match headers
97		.get(hyper::header::HOST)
98		.and_then(|v| v.to_str().ok())
99	{
100		Some(h) => h,
101		// No Host header — reject for safety
102		None => return false,
103	};
104
105	// Try Origin header first (most reliable, sent by browsers on POST)
106	if let Some(origin) = headers
107		.get(hyper::header::ORIGIN)
108		.and_then(|v| v.to_str().ok())
109	{
110		return origin_matches_host(origin, host);
111	}
112
113	// Fall back to Referer header
114	if let Some(referer) = headers
115		.get(hyper::header::REFERER)
116		.and_then(|v| v.to_str().ok())
117	{
118		return referer_matches_host(referer, host);
119	}
120
121	// Neither Origin nor Referer present — reject.
122	// The WASM SPA always sends Origin on POST requests.
123	// Non-browser clients must provide Origin or Referer.
124	false
125}
126
127/// Checks if an Origin header value (e.g., `"https://example.com"`) matches the Host.
128fn origin_matches_host(origin: &str, host: &str) -> bool {
129	// Origin format: "scheme://host[:port]"
130	let origin_host = origin.split("://").nth(1).unwrap_or(origin);
131	let origin_host = origin_host.trim_end_matches('/');
132	origin_host == host
133}
134
135/// Checks if a Referer header value matches the Host.
136fn referer_matches_host(referer: &str, host: &str) -> bool {
137	// Referer format: "scheme://host[:port]/path"
138	let after_scheme = referer.split("://").nth(1).unwrap_or(referer);
139	let referer_host = after_scheme.split('/').next().unwrap_or(after_scheme);
140	referer_host == host
141}
142
143#[cfg(all(test, server))]
144mod tests {
145	use super::*;
146	use bytes::Bytes;
147	use hyper::{HeaderMap, Method, StatusCode, Version};
148
149	struct PassthroughHandler;
150
151	#[async_trait]
152	impl Handler for PassthroughHandler {
153		async fn handle(&self, _request: Request) -> Result<Response> {
154			Ok(Response::new(StatusCode::OK).with_body("ok"))
155		}
156	}
157
158	fn make_request(method: Method, headers: HeaderMap) -> Request {
159		Request::builder()
160			.method(method)
161			.uri("/api/server_fn/get_list")
162			.version(Version::HTTP_11)
163			.headers(headers)
164			.body(Bytes::new())
165			.build()
166			.unwrap()
167	}
168
169	#[tokio::test]
170	async fn test_get_request_passes_through() {
171		let mw = AdminOriginGuardMiddleware;
172		let next = Arc::new(PassthroughHandler);
173		let req = make_request(Method::GET, HeaderMap::new());
174		let resp = mw.process(req, next).await.unwrap();
175		assert_eq!(resp.status, StatusCode::OK);
176	}
177
178	#[tokio::test]
179	async fn test_post_without_origin_returns_403() {
180		let mw = AdminOriginGuardMiddleware;
181		let next = Arc::new(PassthroughHandler);
182		let mut headers = HeaderMap::new();
183		headers.insert(hyper::header::HOST, "localhost:8000".parse().unwrap());
184		let req = make_request(Method::POST, headers);
185		let resp = mw.process(req, next).await.unwrap();
186		assert_eq!(resp.status, StatusCode::FORBIDDEN);
187	}
188
189	#[tokio::test]
190	async fn test_post_without_host_returns_403() {
191		let mw = AdminOriginGuardMiddleware;
192		let next = Arc::new(PassthroughHandler);
193		let mut headers = HeaderMap::new();
194		headers.insert(
195			hyper::header::ORIGIN,
196			"http://localhost:8000".parse().unwrap(),
197		);
198		let req = make_request(Method::POST, headers);
199		let resp = mw.process(req, next).await.unwrap();
200		assert_eq!(resp.status, StatusCode::FORBIDDEN);
201	}
202
203	#[tokio::test]
204	async fn test_post_same_origin_passes() {
205		let mw = AdminOriginGuardMiddleware;
206		let next = Arc::new(PassthroughHandler);
207		let mut headers = HeaderMap::new();
208		headers.insert(hyper::header::HOST, "localhost:8000".parse().unwrap());
209		headers.insert(
210			hyper::header::ORIGIN,
211			"http://localhost:8000".parse().unwrap(),
212		);
213		let req = make_request(Method::POST, headers);
214		let resp = mw.process(req, next).await.unwrap();
215		assert_eq!(resp.status, StatusCode::OK);
216	}
217
218	#[tokio::test]
219	async fn test_post_different_origin_returns_403() {
220		let mw = AdminOriginGuardMiddleware;
221		let next = Arc::new(PassthroughHandler);
222		let mut headers = HeaderMap::new();
223		headers.insert(hyper::header::HOST, "localhost:8000".parse().unwrap());
224		headers.insert(hyper::header::ORIGIN, "http://evil.com".parse().unwrap());
225		let req = make_request(Method::POST, headers);
226		let resp = mw.process(req, next).await.unwrap();
227		assert_eq!(resp.status, StatusCode::FORBIDDEN);
228	}
229
230	#[tokio::test]
231	async fn test_post_referer_same_origin_passes() {
232		let mw = AdminOriginGuardMiddleware;
233		let next = Arc::new(PassthroughHandler);
234		let mut headers = HeaderMap::new();
235		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
236		headers.insert(
237			hyper::header::REFERER,
238			"https://example.com/admin/".parse().unwrap(),
239		);
240		let req = make_request(Method::POST, headers);
241		let resp = mw.process(req, next).await.unwrap();
242		assert_eq!(resp.status, StatusCode::OK);
243	}
244
245	#[tokio::test]
246	async fn test_post_referer_different_origin_returns_403() {
247		let mw = AdminOriginGuardMiddleware;
248		let next = Arc::new(PassthroughHandler);
249		let mut headers = HeaderMap::new();
250		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
251		headers.insert(
252			hyper::header::REFERER,
253			"https://evil.com/admin/".parse().unwrap(),
254		);
255		let req = make_request(Method::POST, headers);
256		let resp = mw.process(req, next).await.unwrap();
257		assert_eq!(resp.status, StatusCode::FORBIDDEN);
258	}
259
260	#[tokio::test]
261	async fn test_options_request_passes_through() {
262		let mw = AdminOriginGuardMiddleware;
263		let next = Arc::new(PassthroughHandler);
264		let req = make_request(Method::OPTIONS, HeaderMap::new());
265		let resp = mw.process(req, next).await.unwrap();
266		assert_eq!(resp.status, StatusCode::OK);
267	}
268
269	#[test]
270	fn test_origin_matches_host() {
271		assert!(origin_matches_host(
272			"http://localhost:8000",
273			"localhost:8000"
274		));
275		assert!(origin_matches_host("https://example.com", "example.com"));
276		assert!(!origin_matches_host("http://evil.com", "example.com"));
277		assert!(!origin_matches_host(
278			"http://localhost:9000",
279			"localhost:8000"
280		));
281	}
282
283	#[test]
284	fn test_referer_matches_host() {
285		assert!(referer_matches_host(
286			"http://localhost:8000/admin/",
287			"localhost:8000"
288		));
289		assert!(referer_matches_host(
290			"https://example.com/admin/model/",
291			"example.com"
292		));
293		assert!(!referer_matches_host(
294			"http://evil.com/admin/",
295			"example.com"
296		));
297	}
298}