Skip to main content

datapress_core/
admin.rs

1//! Admin endpoint authentication.
2//!
3//! Reads the expected token from the `ADMIN_TOKEN` environment variable at
4//! startup, **or** from a value supplied directly via [`init`]. If neither is
5//! set, all admin endpoints refuse every request — they are effectively
6//! disabled. This is the secure default: you must explicitly opt in.
7//!
8//! Clients authenticate by sending `X-Admin-Token: <value>`. The comparison
9//! is constant-time to avoid leaking the token via timing side channels.
10
11use std::sync::OnceLock;
12
13use actix_web::HttpRequest;
14
15use crate::errors::AppError;
16
17static EXPECTED: OnceLock<Option<String>> = OnceLock::new();
18
19fn expected() -> Option<&'static str> {
20    EXPECTED
21        .get_or_init(|| std::env::var("ADMIN_TOKEN").ok().filter(|s| !s.is_empty()))
22        .as_deref()
23}
24
25/// Seed the admin token before the server starts.
26///
27/// Must be called **before the first HTTP request** (i.e. before
28/// [`crate::server::serve`] returns a bound socket). If the `OnceLock` has
29/// already been initialised (because another call or the `ADMIN_TOKEN` env var
30/// was read first), this is a no-op and the original value wins.
31///
32/// Passing `None` or an empty string leaves admin endpoints disabled.
33pub fn init(token: Option<&str>) {
34    let _ = EXPECTED.set(token.filter(|s| !s.is_empty()).map(str::to_owned));
35}
36
37/// Verify the request carries a valid admin token.
38///
39/// Returns `Err(AppError::Forbidden)` when the token is missing, malformed,
40/// or does not match. Returns `Err(AppError::Forbidden)` (not 500) when the
41/// server has no `ADMIN_TOKEN` configured at all — admin endpoints stay
42/// disabled by default.
43pub fn require_admin(req: &HttpRequest) -> Result<(), AppError> {
44    let expected = expected().ok_or_else(|| {
45        AppError::Forbidden(
46            "admin endpoints are disabled (set ADMIN_TOKEN env var to enable)".into(),
47        )
48    })?;
49
50    let presented = req
51        .headers()
52        .get("X-Admin-Token")
53        .and_then(|v| v.to_str().ok())
54        .unwrap_or("");
55
56    if constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
57        Ok(())
58    } else {
59        Err(AppError::Forbidden(
60            "invalid or missing X-Admin-Token".into(),
61        ))
62    }
63}
64
65/// Constant-time byte comparison. Returns false immediately when lengths
66/// differ (length itself isn't secret); otherwise XORs every byte so the
67/// runtime doesn't depend on where the first difference occurs.
68fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
69    if a.len() != b.len() {
70        return false;
71    }
72    let mut diff: u8 = 0;
73    for (x, y) in a.iter().zip(b.iter()) {
74        diff |= x ^ y;
75    }
76    diff == 0
77}
78
79#[cfg(test)]
80mod tests {
81    use super::constant_time_eq;
82
83    #[test]
84    fn ct_eq_equal() {
85        assert!(constant_time_eq(b"hunter2", b"hunter2"));
86        assert!(constant_time_eq(b"", b""));
87    }
88
89    #[test]
90    fn ct_eq_different_content() {
91        assert!(!constant_time_eq(b"hunter2", b"hunter3"));
92    }
93
94    #[test]
95    fn ct_eq_different_length() {
96        assert!(!constant_time_eq(b"abc", b"abcd"));
97        assert!(!constant_time_eq(b"abcd", b"abc"));
98    }
99}