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..cc4a49414e1 100644 --- a/src/controllers/token.rs +++ b/src/controllers/token.rs @@ -6,8 +6,9 @@ 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}; +use crate::util::errors::{AppResult, bad_request, custom}; use crate::util::token::PlainToken; use axum::Json; use axum::extract::{Path, Query}; @@ -20,12 +21,12 @@ 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}; -use tracing::error; +use tracing::{error, warn}; #[derive(Deserialize)] pub struct GetParams { @@ -127,6 +128,26 @@ 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 { + 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 + ); + + 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, } }