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. If the variable is unset or empty, all admin endpoints refuse
5//! every request — they are effectively disabled. This is the secure default:
6//! you must explicitly opt in by setting `ADMIN_TOKEN` to a non-empty value.
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/// Verify the request carries a valid admin token.
26///
27/// Returns `Err(AppError::Forbidden)` when the token is missing, malformed,
28/// or does not match. Returns `Err(AppError::Forbidden)` (not 500) when the
29/// server has no `ADMIN_TOKEN` configured at all — admin endpoints stay
30/// disabled by default.
31pub fn require_admin(req: &HttpRequest) -> Result<(), AppError> {
32    let expected = expected().ok_or_else(|| {
33        AppError::Forbidden(
34            "admin endpoints are disabled (set ADMIN_TOKEN env var to enable)".into(),
35        )
36    })?;
37
38    let presented = req
39        .headers()
40        .get("X-Admin-Token")
41        .and_then(|v| v.to_str().ok())
42        .unwrap_or("");
43
44    if constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
45        Ok(())
46    } else {
47        Err(AppError::Forbidden(
48            "invalid or missing X-Admin-Token".into(),
49        ))
50    }
51}
52
53/// Constant-time byte comparison. Returns false immediately when lengths
54/// differ (length itself isn't secret); otherwise XORs every byte so the
55/// runtime doesn't depend on where the first difference occurs.
56fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
57    if a.len() != b.len() {
58        return false;
59    }
60    let mut diff: u8 = 0;
61    for (x, y) in a.iter().zip(b.iter()) {
62        diff |= x ^ y;
63    }
64    diff == 0
65}
66
67#[cfg(test)]
68mod tests {
69    use super::constant_time_eq;
70
71    #[test]
72    fn ct_eq_equal() {
73        assert!(constant_time_eq(b"hunter2", b"hunter2"));
74        assert!(constant_time_eq(b"", b""));
75    }
76
77    #[test]
78    fn ct_eq_different_content() {
79        assert!(!constant_time_eq(b"hunter2", b"hunter3"));
80    }
81
82    #[test]
83    fn ct_eq_different_length() {
84        assert!(!constant_time_eq(b"abc", b"abcd"));
85        assert!(!constant_time_eq(b"abcd", b"abc"));
86    }
87}