From 3fdcf73d114d106aecf1397be342101f9bd2fee4 Mon Sep 17 00:00:00 2001 From: "Michael H." Date: Mon, 31 Aug 2026 15:08:13 +0200 Subject: [PATCH] feat: bring back email domain blacklist --- apps/labrinth/src/env.rs | 4 +- apps/labrinth/src/routes/internal/flows.rs | 55 ++++++++++++++++++---- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index abc3d4f15d..edd4a80610 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -314,7 +314,7 @@ vars! { SENDY_LIST_ID: String = "none"; SENDY_API_KEY: String = "none"; - SKIP_EMAIL_CHECK_DOMAINS: String = ""; + SKIP_EMAIL_CHECK_DOMAINS: StringCsv = StringCsv(vec![]); NEVERBOUNCE_API_KEY: String = ""; NEVERBOUNCE_BASE_URL: String = neverbounce::DEFAULT_API_URL; @@ -323,6 +323,8 @@ vars! { USERCHECK_GATE_ID: String = ""; USERCHECK_BASE_URL: String = crate::util::usercheck::DEFAULT_API_URL; + EMAIL_DOMAIN_BLACKLIST: StringCsv = StringCsv(vec![]); + CLICKHOUSE_REPLICATED: bool = false; CLICKHOUSE_URL: String = "http://localhost:8123"; CLICKHOUSE_USER: String = "default"; diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index fc917a2c47..fd22aaf6d4 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -1901,17 +1901,54 @@ impl From for AccountRegisterFlow { } } -/// Domains listed in `SKIP_EMAIL_CHECK_DOMAINS` bypass every email check. +/// List entries are matched literally, unless they begin with `*.`, in which +/// case they match any subdomain of the remaining suffix. +fn matches_domain_entry(domain: &str, entry: &str) -> bool { + let entry = entry.trim().to_ascii_lowercase(); + + match entry.strip_prefix("*.") { + Some(suffix) => domain + .strip_suffix(suffix) + .is_some_and(|subdomain| subdomain.ends_with('.')), + None => entry == domain, + } +} + +fn domain_of(email: &str) -> Option { + email + .rsplit_once('@') + .map(|(_, domain)| domain.to_ascii_lowercase()) +} + +fn domain_matches_list(domain: &str, list: &[String]) -> bool { + list.iter().any(|entry| matches_domain_entry(domain, entry)) +} + +/// Domains listed in `SKIP_EMAIL_CHECK_DOMAINS` bypass every email check, +/// including the blacklist. fn email_checks_skipped(email: &str) -> bool { - let Some((_, domain)) = email.rsplit_once('@') else { - return false; + domain_of(email).is_some_and(|domain| { + domain_matches_list(&domain, &ENV.SKIP_EMAIL_CHECK_DOMAINS) + }) +} + +fn ensure_email_domain_is_not_blacklisted(email: &str) -> Result<(), ApiError> { + let Some(domain) = domain_of(email) else { + return Ok(()); }; - ENV.SKIP_EMAIL_CHECK_DOMAINS - .split(',') - .map(str::trim) - .filter(|skipped| !skipped.is_empty()) - .any(|skipped| skipped.eq_ignore_ascii_case(domain)) + if !domain_matches_list(&domain, &ENV.EMAIL_DOMAIN_BLACKLIST) { + return Ok(()); + } + + info!( + email.domain = domain.as_str(), + "blacklisted email domain, denying", + ); + + Err(ApiError::Request(eyre!( + "This domain name may not be used ({domain})!" + ))) } /// Runs the UserCheck gate, which covers both password and OAuth signups. @@ -1923,6 +1960,8 @@ async fn ensure_email_passes_gate( return Ok(()); } + ensure_email_domain_is_not_blacklisted(email)?; + let action = check_email_gate(redis, email) .await .wrap_request_err("checking email address")?;