Skip to main content

rullst_core/security/
machine.rs

1//! Exact machine routes authenticated before their cookie-CSRF exemption.
2
3use axum::{
4    extract::Request,
5    http::{Method, StatusCode, header},
6};
7use std::sync::Arc;
8use subtle::ConstantTimeEq;
9
10/// Authentication category used by an explicitly mounted machine endpoint.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum MachineAuthentication {
14    /// Explicit authorization header compared against the configured credential.
15    Bearer,
16    /// Provider-specific cryptographic signature and freshness verification.
17    SignedWebhook,
18    /// Identity established by the trusted TLS transport.
19    MutualTls,
20}
21
22/// Redacted configuration/authentication failure.
23#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum MachineEndpointError {
26    /// Unsupported method, wildcard or noncanonical route.
27    #[error("machine endpoint requires an exact canonical path and a write method")]
28    InvalidRoute,
29    /// Missing, weak or structurally unsafe credential.
30    #[error("machine bearer credential must contain 32–200 non-whitespace ASCII bytes")]
31    InvalidCredential,
32    /// Ambiguous or oversized endpoint registry.
33    #[error("machine endpoint policy exceeds 32 routes or contains a duplicate")]
34    InvalidPolicy,
35    /// Request proof was not accepted by the verifier.
36    #[error("machine request authentication failed")]
37    Unauthorized,
38}
39
40/// Host-owned cryptographic verifier for signed webhooks or transport-authenticated
41/// mTLS identities. Implementations must fail closed, bind the exact request,
42/// and enforce provider freshness/replay policy where applicable. Never accept
43/// a browser-supplied header as proof of a client certificate.
44///
45/// The policy supplies a buffered body of at most 1 MiB. Return the authenticated
46/// request with that body preserved for the handler.
47#[async_trait::async_trait]
48pub trait MachineRequestVerifier: Send + Sync {
49    /// Authenticates and returns the same method, URI and readable body.
50    async fn verify(&self, request: Request) -> Result<Request, MachineEndpointError>;
51}
52
53enum Credential {
54    Bearer([u8; 32]),
55    Verified(Arc<dyn MachineRequestVerifier>),
56}
57
58/// One exact method/path pair and its mandatory authentication mechanism.
59pub struct MachineEndpoint {
60    method: Method,
61    path: String,
62    kind: MachineAuthentication,
63    credential: Credential,
64}
65
66impl MachineEndpoint {
67    /// Returns the configured authentication category, without credentials.
68    pub fn authentication(&self) -> MachineAuthentication {
69        self.kind
70    }
71
72    /// Registers a strong bearer credential. Only its SHA-256 digest is retained.
73    pub fn bearer(
74        method: Method,
75        path: impl Into<String>,
76        token: impl Into<String>,
77    ) -> Result<Self, MachineEndpointError> {
78        let token = token.into();
79        if !(32..=200).contains(&token.len()) || !token.bytes().all(|b| b.is_ascii_graphic()) {
80            return Err(MachineEndpointError::InvalidCredential);
81        }
82        Self::new(
83            method,
84            path.into(),
85            MachineAuthentication::Bearer,
86            Credential::Bearer(digest(token.as_bytes())),
87        )
88    }
89
90    /// Registers an exact signed-webhook route with a mandatory verifier.
91    pub fn signed_webhook(
92        method: Method,
93        path: impl Into<String>,
94        verifier: impl MachineRequestVerifier + 'static,
95    ) -> Result<Self, MachineEndpointError> {
96        Self::new(
97            method,
98            path.into(),
99            MachineAuthentication::SignedWebhook,
100            Credential::Verified(Arc::new(verifier)),
101        )
102    }
103
104    /// Registers an exact route with a trusted transport-identity verifier.
105    pub fn mutual_tls(
106        method: Method,
107        path: impl Into<String>,
108        verifier: impl MachineRequestVerifier + 'static,
109    ) -> Result<Self, MachineEndpointError> {
110        Self::new(
111            method,
112            path.into(),
113            MachineAuthentication::MutualTls,
114            Credential::Verified(Arc::new(verifier)),
115        )
116    }
117
118    fn new(
119        method: Method,
120        path: String,
121        kind: MachineAuthentication,
122        credential: Credential,
123    ) -> Result<Self, MachineEndpointError> {
124        if !matches!(
125            method,
126            Method::POST | Method::PUT | Method::PATCH | Method::DELETE
127        ) || !path.starts_with('/')
128            || path.len() > 512
129            || path.contains("//")
130            || path.split('/').any(|part| matches!(part, "." | ".."))
131            || !path
132                .bytes()
133                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.'))
134        {
135            return Err(MachineEndpointError::InvalidRoute);
136        }
137        Ok(Self {
138            method,
139            path,
140            kind,
141            credential,
142        })
143    }
144}
145
146fn digest(value: &[u8]) -> [u8; 32] {
147    let hash = ring::digest::digest(&ring::digest::SHA256, value);
148    let mut result = [0; 32];
149    result.copy_from_slice(hash.as_ref());
150    result
151}
152
153/// Immutable bounded policy installed through `Server::with_machine_endpoints`
154/// or `apply_security_baseline_with_machine_endpoints`.
155#[derive(Clone)]
156pub struct MachineEndpointPolicy(Arc<[MachineEndpoint]>);
157
158impl std::fmt::Debug for MachineEndpointPolicy {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.debug_struct("MachineEndpointPolicy")
161            .field("routes", &self.0.len())
162            .finish()
163    }
164}
165
166#[derive(Clone)]
167struct MachineAuthenticated {
168    method: Method,
169    path: String,
170    policy: MachineEndpointPolicy,
171}
172
173impl MachineEndpointPolicy {
174    /// Rejects duplicate method/path pairs and registries exceeding 32 entries.
175    pub fn new(endpoints: Vec<MachineEndpoint>) -> Result<Self, MachineEndpointError> {
176        if endpoints.len() > 32
177            || endpoints.iter().enumerate().any(|(i, endpoint)| {
178                endpoints[..i]
179                    .iter()
180                    .any(|prior| prior.method == endpoint.method && prior.path == endpoint.path)
181            })
182        {
183            return Err(MachineEndpointError::InvalidPolicy);
184        }
185        Ok(Self(endpoints.into()))
186    }
187
188    pub(crate) fn matches(&self, request: &Request) -> bool {
189        self.0
190            .iter()
191            .any(|e| e.method == request.method() && e.path == request.uri().path())
192    }
193
194    pub(crate) async fn authenticate(&self, mut request: Request) -> Result<Request, StatusCode> {
195        if request
196            .extensions()
197            .get::<MachineAuthenticated>()
198            .is_some_and(|proof| {
199                proof.method == request.method()
200                    && proof.path == request.uri().path()
201                    && Arc::ptr_eq(&proof.policy.0, &self.0)
202            })
203        {
204            return Ok(request);
205        }
206        let endpoint = self
207            .0
208            .iter()
209            .find(|e| e.method == request.method() && e.path == request.uri().path())
210            .ok_or(StatusCode::UNAUTHORIZED)?;
211        // A machine exception must never become an ambient-cookie browser path.
212        if request.headers().contains_key(header::COOKIE)
213            || request.headers().contains_key(header::ORIGIN)
214            || request.headers().contains_key("sec-fetch-site")
215        {
216            return Err(StatusCode::UNAUTHORIZED);
217        }
218        if let Credential::Bearer(expected) = &endpoint.credential {
219            let mut values = request.headers().get_all(header::AUTHORIZATION).iter();
220            let token = values
221                .next()
222                .and_then(|v| v.to_str().ok())
223                .and_then(|v| v.strip_prefix("Bearer "))
224                .ok_or(StatusCode::UNAUTHORIZED)?;
225            if values.next().is_some()
226                || !(32..=200).contains(&token.len())
227                || !bool::from(digest(token.as_bytes()).ct_eq(expected))
228            {
229                return Err(StatusCode::UNAUTHORIZED);
230            }
231        }
232        let method = request.method().clone();
233        let uri = request.uri().clone();
234        let (parts, body) = request.into_parts();
235        let bytes = axum::body::to_bytes(body, 1024 * 1024)
236            .await
237            .map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?;
238        request = Request::from_parts(parts, axum::body::Body::from(bytes));
239        if let Credential::Verified(verifier) = &endpoint.credential {
240            request = verifier
241                .verify(request)
242                .await
243                .map_err(|_| StatusCode::UNAUTHORIZED)?;
244            if request.method() != method || request.uri() != &uri {
245                return Err(StatusCode::UNAUTHORIZED);
246            }
247        }
248        request.extensions_mut().insert(MachineAuthenticated {
249            method,
250            path: uri.path().to_owned(),
251            policy: self.clone(),
252        });
253        Ok(request)
254    }
255}
256
257pub(crate) async fn authenticate_machine_request(
258    request: Request,
259    next: axum::middleware::Next,
260) -> axum::response::Response {
261    use axum::response::IntoResponse;
262    if let Some(policy) = request.extensions().get::<MachineEndpointPolicy>().cloned()
263        && policy.matches(&request)
264    {
265        return match policy.authenticate(request).await {
266            Ok(request) => next.run(request).await,
267            Err(status) => (status, "Machine request rejected").into_response(),
268        };
269    }
270    next.run(request).await
271}