From a9130759ec574c168deb617dbe74696e9e23e075 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Fri, 12 Sep 2025 17:04:55 +0200 Subject: [PATCH 1/2] Add `DISABLE_TOKEN_CREATION` flag Setting this environment variable will cause crates.io to reject new API token creation requests with the error message set inside the environment variable. This can be useful to e.g. temporarily pause API token creation during ongoing phishing campaigns. --- src/config/server.rs | 8 ++++++++ src/controllers/token.rs | 15 +++++++++++++-- src/tests/routes/me/tokens/create.rs | 18 ++++++++++++++++++ src/tests/util/test_app.rs | 1 + 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/config/server.rs b/src/config/server.rs index 9a3b507b6b5..2914b2c4c9c 100644 --- a/src/config/server.rs +++ b/src/config/server.rs @@ -93,6 +93,10 @@ pub struct Server { /// The expected audience claim (`aud`) for the Trusted Publishing /// token exchange. pub trustpub_audience: String, + + /// Disables API token creation when set to any non-empty value. + /// The value is used as the error message returned to users. + pub disable_token_creation: Option, } impl Server { @@ -128,6 +132,8 @@ impl Server { /// endpoint even with a healthy database pool. /// - `BLOCKED_ROUTES`: A comma separated list of HTTP route patterns that are manually blocked /// by an operator (e.g. `/crates/{crate_id}/{version}/download`). + /// - `DISABLE_TOKEN_CREATION`: If set to any non-empty value, disables API token creation + /// and uses the value as the error message returned to users. /// /// # Panics /// @@ -195,6 +201,7 @@ impl Server { let domain_name = dotenvy::var("DOMAIN_NAME").unwrap_or_else(|_| "crates.io".into()); let trustpub_audience = var("TRUSTPUB_AUDIENCE")?.unwrap_or_else(|| domain_name.clone()); + let disable_token_creation = var("DISABLE_TOKEN_CREATION")?.filter(|s| !s.is_empty()); Ok(Server { db: DatabasePools::full_from_environment(&base)?, @@ -245,6 +252,7 @@ impl Server { html_render_cache_max_capacity: var_parsed("HTML_RENDER_CACHE_CAP")?.unwrap_or(1024), content_security_policy: Some(content_security_policy.parse()?), trustpub_audience, + disable_token_creation, }) } } diff --git a/src/controllers/token.rs b/src/controllers/token.rs index 5d29ed98c90..b3ef84f2f98 100644 --- a/src/controllers/token.rs +++ b/src/controllers/token.rs @@ -7,7 +7,7 @@ use anyhow::Context; use crate::app::AppState; use crate::auth::AuthCheck; use crate::models::token::{CrateScope, EndpointScope}; -use crate::util::errors::{AppResult, bad_request}; +use crate::util::errors::{AppResult, bad_request, custom}; use crate::util::token::PlainToken; use axum::Json; use axum::extract::{Path, Query}; @@ -25,7 +25,7 @@ use http::request::Parts; use minijinja::context; use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; -use tracing::error; +use tracing::{error, warn}; #[derive(Deserialize)] pub struct GetParams { @@ -127,6 +127,17 @@ pub async fn create_api_token( let user = auth.user(); + // Check if token creation is disabled + if let Some(disable_message) = &app.config.disable_token_creation { + warn!( + "Blocked token creation for user `{}` (id: {}) due to disabled flag (token name: `{}`)", + user.gh_login, user.id, new.api_token.name + ); + + let message = disable_message.clone(); + return Err(custom(StatusCode::SERVICE_UNAVAILABLE, message)); + } + let max_token_per_user = 500; let count: i64 = ApiToken::belonging_to(user) .count() diff --git a/src/tests/routes/me/tokens/create.rs b/src/tests/routes/me/tokens/create.rs index 03504a3e9d1..312595b9724 100644 --- a/src/tests/routes/me/tokens/create.rs +++ b/src/tests/routes/me/tokens/create.rs @@ -279,3 +279,21 @@ async fn create_token_with_expiry_date() { assert_snapshot!(app.emails_snapshot().await); } + +#[tokio::test(flavor = "multi_thread")] +async fn create_token_disabled() { + const ERROR_MESSAGE: &str = + "Token creation is temporarily disabled due to an ongoing phishing campaign"; + + let (app, _, user) = TestApp::init() + .with_config(|config| { + config.disable_token_creation = Some(ERROR_MESSAGE.to_string()); + }) + .with_user() + .await; + + let response = user.put::<()>("/api/v1/me/tokens", NEW_BAR).await; + assert_snapshot!(response.status(), @"503 Service Unavailable"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Token creation is temporarily disabled due to an ongoing phishing campaign"}]}"#); + assert!(app.emails().await.is_empty()); +} diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index f3da1ac4131..4ef98d4c0c8 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -526,6 +526,7 @@ fn simple_config() -> config::Server { html_render_cache_max_capacity: 1024, content_security_policy: None, trustpub_audience: AUDIENCE.to_string(), + disable_token_creation: None, } } From 276366a4c9918d9a00ce2875f30122cb99d1e803 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Fri, 12 Sep 2025 17:29:23 +0200 Subject: [PATCH 2/2] controllers/token: Add client IP address and headers to the log message --- src/controllers/token.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/controllers/token.rs b/src/controllers/token.rs index b3ef84f2f98..cc4a49414e1 100644 --- a/src/controllers/token.rs +++ b/src/controllers/token.rs @@ -6,6 +6,7 @@ use anyhow::Context; use crate::app::AppState; use crate::auth::AuthCheck; +use crate::middleware::real_ip::RealIp; use crate::models::token::{CrateScope, EndpointScope}; use crate::util::errors::{AppResult, bad_request, custom}; use crate::util::token::PlainToken; @@ -20,8 +21,8 @@ use diesel::dsl::{IntervalDsl, now}; use diesel::prelude::*; use diesel::sql_types::Timestamptz; use diesel_async::RunQueryDsl; -use http::StatusCode; use http::request::Parts; +use http::{StatusCode, header}; use minijinja::context; use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; @@ -129,7 +130,16 @@ pub async fn create_api_token( // Check if token creation is disabled if let Some(disable_message) = &app.config.disable_token_creation { + let client_ip = parts.extensions.get::().map(|ip| ip.to_string()); + let client_ip = client_ip.as_deref().unwrap_or("unknown"); + + let mut headers = parts.headers.clone(); + headers.remove(header::AUTHORIZATION); + headers.remove(header::COOKIE); + warn!( + network.client.ip = client_ip, + http.headers = ?headers, "Blocked token creation for user `{}` (id: {}) due to disabled flag (token name: `{}`)", user.gh_login, user.id, new.api_token.name );