victauri_core/
middleware.rs1use std::sync::Arc;
7
8use axum::extract::Request;
9use axum::http::StatusCode;
10use axum::middleware::Next;
11use axum::response::Response;
12
13use crate::security::{self, RateLimiter, constant_time_eq, is_allowed_origin, is_localhost_host};
14
15const BEARER_PREFIX_LEN: usize = "Bearer ".len();
16
17#[derive(Clone)]
20pub struct AuthState {
21 pub token: Option<String>,
23}
24
25pub async fn require_auth(
35 axum::extract::State(auth): axum::extract::State<Arc<AuthState>>,
36 request: Request,
37 next: Next,
38) -> Result<Response, StatusCode> {
39 let Some(expected) = &auth.token else {
40 return Ok(next.run(request).await);
41 };
42
43 if bearer_matches(request.headers(), expected) {
44 Ok(next.run(request).await)
45 } else {
46 tracing::warn!("Victauri: rejected request — invalid or missing auth token");
47 Err(StatusCode::UNAUTHORIZED)
48 }
49}
50
51#[must_use]
54pub fn bearer_matches(headers: &axum::http::HeaderMap, expected: &str) -> bool {
55 headers
56 .get("authorization")
57 .and_then(|v| v.to_str().ok())
58 .filter(|v| {
59 v.get(..BEARER_PREFIX_LEN)
60 .is_some_and(|scheme| scheme.eq_ignore_ascii_case("bearer "))
61 })
62 .is_some_and(|v| constant_time_eq(&v.as_bytes()[BEARER_PREFIX_LEN..], expected.as_bytes()))
63}
64
65#[must_use]
68pub fn default_rate_limiter() -> Arc<RateLimiter> {
69 Arc::new(RateLimiter::new(security::DEFAULT_RATE_LIMIT))
70}
71
72pub async fn rate_limit(
80 axum::extract::State(limiter): axum::extract::State<Arc<RateLimiter>>,
81 request: Request,
82 next: Next,
83) -> Result<
84 Response,
85 (
86 StatusCode,
87 [(axum::http::HeaderName, axum::http::HeaderValue); 1],
88 ),
89> {
90 if limiter.try_acquire() {
91 Ok(next.run(request).await)
92 } else {
93 Err((
94 StatusCode::TOO_MANY_REQUESTS,
95 [(
96 axum::http::header::RETRY_AFTER,
97 axum::http::HeaderValue::from_static("1"),
98 )],
99 ))
100 }
101}
102
103pub async fn dns_rebinding_guard(request: Request, next: Next) -> Result<Response, StatusCode> {
112 let host = request
113 .headers()
114 .get("host")
115 .and_then(|v| v.to_str().ok())
116 .unwrap_or("");
117 if !is_localhost_host(host) {
118 tracing::warn!("DNS rebinding attempt blocked: Host={host}");
119 return Err(StatusCode::FORBIDDEN);
120 }
121 Ok(next.run(request).await)
122}
123
124pub async fn origin_guard(request: Request, next: Next) -> Result<Response, StatusCode> {
136 if let Some(site) = request.headers().get("sec-fetch-site")
137 && site.as_bytes() != b"none"
138 {
139 tracing::warn!("Browser-initiated request blocked: Sec-Fetch-Site={site:?}");
140 return Err(StatusCode::FORBIDDEN);
141 }
142 if let Some(origin) = request.headers().get("origin") {
143 match origin.to_str() {
146 Ok(origin) if is_allowed_origin(origin) => {}
147 _ => {
148 tracing::warn!("Cross-origin request blocked: Origin={origin:?}");
149 return Err(StatusCode::FORBIDDEN);
150 }
151 }
152 }
153 Ok(next.run(request).await)
154}
155
156pub async fn security_headers(request: Request, next: Next) -> Response {
159 let mut response = next.run(request).await;
160 let headers = response.headers_mut();
161 headers.insert(
162 axum::http::header::X_CONTENT_TYPE_OPTIONS,
163 axum::http::HeaderValue::from_static("nosniff"),
164 );
165 headers.insert(
166 axum::http::header::CACHE_CONTROL,
167 axum::http::HeaderValue::from_static("no-store"),
168 );
169 headers.insert(
170 axum::http::header::HeaderName::from_static("x-frame-options"),
171 axum::http::HeaderValue::from_static("DENY"),
172 );
173 headers.insert(
176 axum::http::header::HeaderName::from_static("content-security-policy"),
177 axum::http::HeaderValue::from_static("default-src 'none'"),
178 );
179 response
180}