Skip to content

Commit a6f52f8

Browse files
committed
add authentication for the metrics endpoint
1 parent 38d3650 commit a6f52f8

File tree

6 files changed

+114
-9
lines changed

6 files changed

+114
-9
lines changed

src/config.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub struct Config {
2121
pub allowed_origins: Vec<String>,
2222
pub downloads_persist_interval_ms: usize,
2323
pub ownership_invitations_expiration_days: u64,
24+
pub metrics_authorization_token: Option<String>,
2425
}
2526

2627
impl Default for Config {
@@ -47,8 +48,10 @@ impl Default for Config {
4748
/// - `DATABASE_URL`: The URL of the postgres database to use.
4849
/// - `READ_ONLY_REPLICA_URL`: The URL of an optional postgres read-only replica database.
4950
/// - `BLOCKED_TRAFFIC`: A list of headers and environment variables to use for blocking
50-
///. traffic. See the `block_traffic` module for more documentation.
51+
/// traffic. See the `block_traffic` module for more documentation.
5152
/// - `DOWNLOADS_PERSIST_INTERVAL_MS`: how frequent to persist download counts (in ms).
53+
/// - `METRICS_AUTHORIZATION_TOKEN`: authorization token needed to query metrics. If missing,
54+
/// querying metrics will be completely disabled.
5255
fn default() -> Config {
5356
let api_protocol = String::from("https");
5457
let mirror = if dotenv::var("MIRROR").is_ok() {
@@ -156,6 +159,7 @@ impl Default for Config {
156159
})
157160
.unwrap_or(60_000), // 1 minute
158161
ownership_invitations_expiration_days: 30,
162+
metrics_authorization_token: dotenv::var("METRICS_AUTHORIZATION_TOKEN").ok(),
159163
}
160164
}
161165
}

src/controllers/metrics.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
use crate::controllers::frontend_prelude::*;
2-
use crate::util::errors::not_found;
2+
use crate::util::errors::{forbidden, not_found, MetricsDisabled};
33
use conduit::{Body, Response};
44
use prometheus::{Encoder, TextEncoder};
55

66
/// Handles the `GET /api/private/metrics/:kind` endpoint.
77
pub fn prometheus(req: &mut dyn RequestExt) -> EndpointResult {
88
let app = req.app();
99

10+
if let Some(expected_token) = &app.config.metrics_authorization_token {
11+
let provided_token = req
12+
.headers()
13+
.get(header::AUTHORIZATION)
14+
.and_then(|value| value.to_str().ok())
15+
.and_then(|value| value.strip_prefix("Bearer "));
16+
17+
if provided_token != Some(expected_token.as_str()) {
18+
return Err(forbidden());
19+
}
20+
} else {
21+
// To avoid accidentally leaking metrics if the environment variable is not set, prevent
22+
// access to any metrics endpoint if the authorization token is not configured.
23+
return Err(Box::new(MetricsDisabled));
24+
}
25+
1026
let metrics = match req.params()["kind"].as_str() {
1127
"service" => app.service_metrics.gather(&*req.db_read_only()?)?,
1228
"instance" => app.instance_metrics.gather(app)?,

src/tests/metrics.rs

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,85 @@
1+
use crate::util::{MockAnonymousUser, Response};
2+
use crate::{RequestHelper, TestApp};
13
use conduit::StatusCode;
2-
use crate::{TestApp, RequestHelper};
34

45
#[test]
56
fn metrics_endpoint_works() {
6-
let (_, anon) = TestApp::init().empty();
7+
let (_, anon) = TestApp::init()
8+
.with_config(|config| config.metrics_authorization_token = Some("foobar".into()))
9+
.empty();
710

8-
let resp = anon.get::<()>("/api/private/metrics/service");
11+
let resp = request_metrics(&anon, "service", Some("foobar"));
912
assert_eq!(StatusCode::OK, resp.status());
1013

11-
let resp = anon.get::<()>("/api/private/metrics/instance");
14+
let resp = request_metrics(&anon, "instance", Some("foobar"));
1215
assert_eq!(StatusCode::OK, resp.status());
1316

14-
let resp = anon.get::<()>("/api/private/metrics/missing");
17+
let resp = request_metrics(&anon, "missing", Some("foobar"));
1518
assert_eq!(StatusCode::NOT_FOUND, resp.status());
1619
}
20+
21+
#[test]
22+
fn metrics_endpoint_wrong_auth() {
23+
let (_, anon) = TestApp::init()
24+
.with_config(|config| config.metrics_authorization_token = Some("secret".into()))
25+
.empty();
26+
27+
// Wrong secret
28+
29+
let resp = request_metrics(&anon, "service", Some("foobar"));
30+
assert_eq!(StatusCode::FORBIDDEN, resp.status());
31+
32+
let resp = request_metrics(&anon, "instance", Some("foobar"));
33+
assert_eq!(StatusCode::FORBIDDEN, resp.status());
34+
35+
let resp = request_metrics(&anon, "missing", Some("foobar"));
36+
assert_eq!(StatusCode::FORBIDDEN, resp.status());
37+
38+
// No secret
39+
40+
let resp = request_metrics(&anon, "service", None);
41+
assert_eq!(StatusCode::FORBIDDEN, resp.status());
42+
43+
let resp = request_metrics(&anon, "instance", None);
44+
assert_eq!(StatusCode::FORBIDDEN, resp.status());
45+
46+
let resp = request_metrics(&anon, "missing", None);
47+
assert_eq!(StatusCode::FORBIDDEN, resp.status());
48+
}
49+
50+
#[test]
51+
fn metrics_endpoint_auth_disabled() {
52+
let (_, anon) = TestApp::init()
53+
.with_config(|config| config.metrics_authorization_token = None)
54+
.empty();
55+
56+
// Wrong secret
57+
58+
let resp = request_metrics(&anon, "service", Some("foobar"));
59+
assert_eq!(StatusCode::NOT_FOUND, resp.status());
60+
61+
let resp = request_metrics(&anon, "instance", Some("foobar"));
62+
assert_eq!(StatusCode::NOT_FOUND, resp.status());
63+
64+
let resp = request_metrics(&anon, "missing", Some("foobar"));
65+
assert_eq!(StatusCode::NOT_FOUND, resp.status());
66+
67+
// No secret
68+
69+
let resp = request_metrics(&anon, "service", None);
70+
assert_eq!(StatusCode::NOT_FOUND, resp.status());
71+
72+
let resp = request_metrics(&anon, "instance", None);
73+
assert_eq!(StatusCode::NOT_FOUND, resp.status());
74+
75+
let resp = request_metrics(&anon, "missing", None);
76+
assert_eq!(StatusCode::NOT_FOUND, resp.status());
77+
}
78+
79+
fn request_metrics(anon: &MockAnonymousUser, kind: &str, token: Option<&str>) -> Response<()> {
80+
let mut req = anon.get_request(&format!("/api/private/metrics/{}", kind));
81+
if let Some(token) = token {
82+
req.header("Authorization", &format!("Bearer {}", token));
83+
}
84+
anon.run(req)
85+
}

src/tests/util/test_app.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ fn simple_config() -> Config {
317317
allowed_origins: Vec::new(),
318318
downloads_persist_interval_ms: 1000,
319319
ownership_invitations_expiration_days: 30,
320+
metrics_authorization_token: None,
320321
}
321322
}
322323

src/util/errors.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ mod json;
2626

2727
pub use json::TOKEN_FORMAT_ERROR;
2828
pub(crate) use json::{
29-
InsecurelyGeneratedTokenRevoked, NotFound, OwnershipInvitationExpired, ReadOnlyMode,
30-
TooManyRequests,
29+
InsecurelyGeneratedTokenRevoked, MetricsDisabled, NotFound, OwnershipInvitationExpired,
30+
ReadOnlyMode, TooManyRequests,
3131
};
3232

3333
/// Returns an error with status 200 and the provided description as JSON

src/util/errors/json.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,18 @@ impl fmt::Display for OwnershipInvitationExpired {
251251
)
252252
}
253253
}
254+
255+
#[derive(Debug)]
256+
pub(crate) struct MetricsDisabled;
257+
258+
impl AppError for MetricsDisabled {
259+
fn response(&self) -> Option<AppResponse> {
260+
Some(json_error(&self.to_string(), StatusCode::NOT_FOUND))
261+
}
262+
}
263+
264+
impl fmt::Display for MetricsDisabled {
265+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266+
f.write_str("Metrics are disabled on this crates.io instance")
267+
}
268+
}

0 commit comments

Comments
 (0)