Skip to content
This repository was archived by the owner on Aug 9, 2026. It is now read-only.

Commit ccc638f

Browse files
feat(metrics): require a bearer token on /metrics when configured (#177)
Add an optional `metrics_token` config (also settable via ROCKET_METRICS_TOKEN). When set, /metrics requires `Authorization: Bearer <token>` (constant-time compared via subtle); when unset it stays open and logs a startup warning, so rolling this out doesn't break existing scrapers. The auth lives in the app, not the ingress, so it travels with cryptify to external hosts that can't be firewalled. /health stays public. Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
1 parent b316445 commit ccc638f

4 files changed

Lines changed: 141 additions & 13 deletions

File tree

conf/config.dev.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,8 @@ usage_db = "/app/data/usage.db"
1515
# pkg_url = "https://pkg.staging.yivi.app"
1616
pkg_url = "http://postguard-pkg:8087"
1717
chunk_size = 5000000
18+
# Leave unset in dev so /metrics is freely scrapable. In prod set this (or the
19+
# ROCKET_METRICS_TOKEN env var) so /metrics requires `Authorization: Bearer <token>`.
20+
# metrics_token = "dev-token"
1821
# When true, finalize logs the email it WOULD have sent and skips SMTP.
1922
# staging_mode = true

conf/config.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@ smtp_port = 1025
1212
# add-in (prod); `localhost:3000` is the Office add-in dev server.
1313
allowed_origins = "^https://(postguard\\.(eu|nl)|addin\\.postguard\\.eu|localhost:3000)$"
1414
pkg_url = "https://pkg.postguard.eu/"
15+
# Bearer token required to scrape /metrics. Prefer injecting it as a secret
16+
# via the ROCKET_METRICS_TOKEN env var rather than committing it here. When
17+
# unset, /metrics is publicly accessible (a startup warning is logged).
18+
# metrics_token = "change-me"

src/config.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub struct RawCryptifyConfig {
1616
chunk_size: Option<u64>,
1717
session_ttl_secs: Option<u64>,
1818
staging_mode: Option<bool>,
19+
metrics_token: Option<String>,
1920
usage_db: Option<String>,
2021
}
2122

@@ -36,6 +37,7 @@ pub struct CryptifyConfig {
3637
chunk_size: u64,
3738
session_ttl_secs: u64,
3839
staging_mode: bool,
40+
metrics_token: Option<String>,
3941
/// Filesystem path to the SQLite database backing the rolling-quota
4042
/// usage state. When set, per-sender usage survives process restarts
4143
/// (the in-memory map in `Store` is only a cache). `None` keeps usage
@@ -63,6 +65,7 @@ impl From<RawCryptifyConfig> for CryptifyConfig {
6365
chunk_size: config.chunk_size.unwrap_or(5_000_000),
6466
session_ttl_secs: config.session_ttl_secs.unwrap_or(3600),
6567
staging_mode: config.staging_mode.unwrap_or(false),
68+
metrics_token: config.metrics_token,
6669
usage_db: config.usage_db,
6770
}
6871
}
@@ -125,6 +128,13 @@ impl CryptifyConfig {
125128
self.staging_mode
126129
}
127130

131+
/// Bearer token required to scrape `/metrics`. `None` leaves the endpoint
132+
/// open (with a startup warning); when set, requests must present
133+
/// `Authorization: Bearer <token>`.
134+
pub fn metrics_token(&self) -> Option<&str> {
135+
self.metrics_token.as_deref()
136+
}
137+
128138
/// Path to the SQLite database backing rolling-quota usage, if
129139
/// configured. `None` means usage is kept in memory only.
130140
pub fn usage_db(&self) -> Option<&str> {
@@ -148,6 +158,7 @@ impl CryptifyConfig {
148158
chunk_size: 5_000_000,
149159
session_ttl_secs: 3600,
150160
staging_mode,
161+
metrics_token: None,
151162
usage_db: None,
152163
}
153164
}

src/main.rs

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,51 @@ fn health() -> &'static str {
137137
"OK"
138138
}
139139

140+
/// Request guard protecting `/metrics`. When `metrics_token` is configured,
141+
/// the endpoint requires `Authorization: Bearer <token>` (constant-time
142+
/// compared); otherwise it stays open (a startup warning is logged). This
143+
/// auth lives in the app rather than the ingress so the protection travels
144+
/// with cryptify to every deployment, including external hosts we can't
145+
/// firewall.
146+
struct MetricsAuth;
147+
148+
#[rocket::async_trait]
149+
impl<'r> FromRequest<'r> for MetricsAuth {
150+
type Error = ();
151+
async fn from_request(request: &'r rocket::Request<'_>) -> rocket::request::Outcome<Self, ()> {
152+
let expected = request
153+
.rocket()
154+
.state::<CryptifyConfig>()
155+
.and_then(CryptifyConfig::metrics_token);
156+
157+
// No token configured → metrics is open (warned about at startup).
158+
let Some(expected) = expected else {
159+
return rocket::request::Outcome::Success(MetricsAuth);
160+
};
161+
162+
let presented = request
163+
.headers()
164+
.get_one("Authorization")
165+
.and_then(|h| {
166+
h.strip_prefix("Bearer ")
167+
.or_else(|| h.strip_prefix("bearer "))
168+
})
169+
.map(str::trim);
170+
171+
match presented {
172+
Some(token) if constant_time_eq(token, expected) => {
173+
rocket::request::Outcome::Success(MetricsAuth)
174+
}
175+
_ => rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, ())),
176+
}
177+
}
178+
}
179+
140180
#[get("/metrics")]
141-
fn metrics_endpoint(metrics: &State<Arc<Metrics>>) -> rocket::response::content::RawText<String> {
181+
fn metrics_endpoint(
182+
_auth: MetricsAuth,
183+
metrics: &State<Arc<Metrics>>,
184+
) -> rocket::response::content::RawText<String> {
142185
rocket::response::content::RawText(metrics.render())
143186
}
144187

@@ -991,11 +1034,11 @@ struct UploadStatusResponse {
9911034
prev_offset: Option<u64>,
9921035
}
9931036

994-
/// Constant-time compare of the recovery token. Hex-encoded equal-length
995-
/// strings, but `subtle::ConstantTimeEq` makes the timing independent of
996-
/// where the bytes start to differ — defeats timing oracles even though
997-
/// 32 bytes of high-entropy random aren't realistically guessable.
998-
fn recovery_tokens_match(presented: &str, expected: &str) -> bool {
1037+
/// Constant-time string equality. `subtle::ConstantTimeEq` makes the timing
1038+
/// independent of where the bytes start to differ — defeats timing oracles
1039+
/// on secret comparisons. Used for the recovery token and the `/metrics`
1040+
/// bearer token. A length difference returns early (lengths aren't secret).
1041+
fn constant_time_eq(presented: &str, expected: &str) -> bool {
9991042
use subtle::ConstantTimeEq;
10001043
if presented.len() != expected.len() {
10011044
return false;
@@ -1021,7 +1064,7 @@ async fn upload_status(
10211064
.ok_or_else(|| Error::upload_session_not_found(uuid, "expired_or_unknown"))?;
10221065
let state = state.lock().await;
10231066

1024-
if !recovery_tokens_match(&recovery_token.0, &state.recovery_token) {
1067+
if !constant_time_eq(&recovery_token.0, &state.recovery_token) {
10251068
// Same body shape as evicted/unknown so the response doesn't leak
10261069
// session existence to a token-guessing attacker.
10271070
return Err(Error::upload_session_not_found(uuid, "expired_or_unknown"));
@@ -1479,6 +1522,13 @@ async fn rocket() -> _ {
14791522
.extract::<CryptifyConfig>()
14801523
.expect("Missing configuration");
14811524

1525+
if config.metrics_token().is_none() {
1526+
log::warn!(
1527+
"metrics_token is not set — /metrics is publicly accessible without authentication. \
1528+
Set `metrics_token` in config (or ROCKET_METRICS_TOKEN) to require a Bearer token."
1529+
);
1530+
}
1531+
14821532
let pkg_params_url = format!(
14831533
"{}/v2/sign/parameters",
14841534
config.pkg_url().trim_end_matches('/')
@@ -1982,16 +2032,16 @@ mod tests {
19822032
}
19832033

19842034
#[rocket::async_test]
1985-
async fn recovery_tokens_match_constant_time_helper() {
2035+
async fn constant_time_eq_helper() {
19862036
// The function under test is the constant-time wrapper itself —
19872037
// we can't observe timing in a unit test, but we can pin the
19882038
// value-equality semantics so a future refactor doesn't silently
19892039
// turn it into `presented == expected`.
1990-
assert!(recovery_tokens_match("abc123", "abc123"));
1991-
assert!(!recovery_tokens_match("abc123", "abc124"));
1992-
assert!(!recovery_tokens_match("abc123", "abc12")); // length mismatch
1993-
assert!(!recovery_tokens_match("", "abc"));
1994-
assert!(recovery_tokens_match("", ""));
2040+
assert!(constant_time_eq("abc123", "abc123"));
2041+
assert!(!constant_time_eq("abc123", "abc124"));
2042+
assert!(!constant_time_eq("abc123", "abc12")); // length mismatch
2043+
assert!(!constant_time_eq("", "abc"));
2044+
assert!(constant_time_eq("", ""));
19952045
}
19962046

19972047
// Browser preflight regression: design AC for #146 explicitly required
@@ -3074,6 +3124,66 @@ mod integration {
30743124
let _ = std::fs::remove_dir_all(dir);
30753125
}
30763126

3127+
// Minimal Rocket exposing only /metrics, with the given config managed so
3128+
// the MetricsAuth guard can read `metrics_token`. Avoids needing a real
3129+
// VerifyingKey / TestSetup.
3130+
fn metrics_only_config(with_token: bool) -> CryptifyConfig {
3131+
let (figment, _dir) = test_figment();
3132+
let figment = if with_token {
3133+
figment.merge(("metrics_token", "s3cret"))
3134+
} else {
3135+
figment
3136+
};
3137+
figment.extract::<CryptifyConfig>().expect("extract config")
3138+
}
3139+
3140+
async fn metrics_only_client(config: CryptifyConfig) -> Client {
3141+
let rocket = rocket::build()
3142+
.mount("/", routes![metrics_endpoint])
3143+
.manage(config)
3144+
.manage(std::sync::Arc::new(Metrics::new()));
3145+
Client::tracked(rocket).await.expect("valid rocket")
3146+
}
3147+
3148+
#[rocket::async_test]
3149+
async fn metrics_requires_bearer_when_token_configured() {
3150+
let client = metrics_only_client(metrics_only_config(true)).await;
3151+
3152+
// No Authorization header → 401.
3153+
assert_eq!(
3154+
client.get("/metrics").dispatch().await.status(),
3155+
Status::Unauthorized
3156+
);
3157+
// Wrong token → 401.
3158+
assert_eq!(
3159+
client
3160+
.get("/metrics")
3161+
.header(Header::new("Authorization", "Bearer wrong"))
3162+
.dispatch()
3163+
.await
3164+
.status(),
3165+
Status::Unauthorized
3166+
);
3167+
// Correct token → 200 with the metrics body.
3168+
let ok = client
3169+
.get("/metrics")
3170+
.header(Header::new("Authorization", "Bearer s3cret"))
3171+
.dispatch()
3172+
.await;
3173+
assert_eq!(ok.status(), Status::Ok);
3174+
assert!(ok
3175+
.into_string()
3176+
.await
3177+
.unwrap_or_default()
3178+
.contains("cryptify_uploads_total"));
3179+
}
3180+
3181+
#[rocket::async_test]
3182+
async fn metrics_open_when_token_unset() {
3183+
let client = metrics_only_client(metrics_only_config(false)).await;
3184+
assert_eq!(client.get("/metrics").dispatch().await.status(), Status::Ok);
3185+
}
3186+
30773187
#[rocket::async_test]
30783188
async fn upload_happy_path_multi_chunk() {
30793189
// Two chunks >1 MiB to exercise the rolling token chain across

0 commit comments

Comments
 (0)