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}