Repository: CTFd/CTFd
Pull request #2735 (2025-04-02 19:56:20)

Feature: Email Blacklisting
This implementation is based off of existing email whitelisting functionality.

I have tested this locally and it works perfectly.

Tests may need modification but they should work for now.


Patch:
diff --git a/CTFd/auth.py b/CTFd/auth.py
index fbb27993..80ef9f52 100644
--- a/CTFd/auth.py
+++ b/CTFd/auth.py
@@ -281,6 +281,8 @@ def register():
             errors.append("Please enter a valid email address")
         if email.check_email_is_whitelisted(email_address) is False:
             errors.append("Your email address is not from an allowed domain")
+        if email.check_email_is_blacklisted(email_address) is True:
+            errors.append("Your email address is not from an allowed domain")
         if names:
             errors.append("That user name is already taken")
         if team_name_email_check is True:
diff --git a/CTFd/forms/config.py b/CTFd/forms/config.py
index 0a84fddb..dc3857fb 100644
--- a/CTFd/forms/config.py
+++ b/CTFd/forms/config.py
@@ -40,6 +40,10 @@ class AccountSettingsForm(BaseForm):
         "Email Domain Allowlist",
         description="Comma-seperated list of allowable email domains which users can register under (e.g. examplectf.com, example.com, *.example.com)",
     )
+    domain_blacklist = StringField(
+        "Email Domain Blocklist",
+        description="Comma-seperated list of disallowed email domains which users cannot register under (e.g. examplectf.com, example.com, *.example.com)",
+    )
     team_creation = SelectField(
         "Team Creation",
         description="Control whether users can create their own teams (Teams mode only)",
diff --git a/CTFd/schemas/users.py b/CTFd/schemas/users.py
index e7a8e310..43b7e711 100644
--- a/CTFd/schemas/users.py
+++ b/CTFd/schemas/users.py
@@ -7,7 +7,7 @@ from CTFd.models import Brackets, UserFieldEntries, UserFields, Users, ma
 from CTFd.schemas.fields import UserFieldEntriesSchema
 from CTFd.utils import get_config, string_types
 from CTFd.utils.crypto import verify_password
-from CTFd.utils.email import check_email_is_whitelisted
+from CTFd.utils.email import check_email_is_blacklisted, check_email_is_whitelisted
 from CTFd.utils.user import get_current_user, is_admin
 from CTFd.utils.validators import validate_country_code, validate_language
 
@@ -155,6 +155,11 @@ class UserSchema(ma.ModelSchema):
                         "Email address is not from an allowed domain",
                         field_names=["email"],
                     )
+                if check_email_is_blacklisted(email) is True:
+                    raise ValidationError(
+                        "Email address is not from an allowed domain",
+                        field_names=["email"],
+                    )
                 if get_config("verify_emails"):
                     current_user.verified = False
 
diff --git a/CTFd/themes/admin/templates/configs/accounts.html b/CTFd/themes/admin/templates/configs/accounts.html
index bffbb4ef..de345bd3 100644
--- a/CTFd/themes/admin/templates/configs/accounts.html
+++ b/CTFd/themes/admin/templates/configs/accounts.html
@@ -28,6 +28,14 @@
 							{{ form.domain_whitelist.description }}
 						</small>
 					</div>
+
+					<div class="form-group">
+						{{ form.domain_blacklist.label }}
+						{{ form.domain_blacklist(class="form-control", value=domain_blacklist or "") }}
+						<small class="form-text text-muted">
+							{{ form.domain_blacklist.description }}
+						</small>
+					</div>
 			
 					<div class="form-group">
 						{{ form.verify_emails.label }}
diff --git a/CTFd/utils/email/__init__.py b/CTFd/utils/email/__init__.py
index b4fac23d..860cc6ec 100644
--- a/CTFd/utils/email/__init__.py
+++ b/CTFd/utils/email/__init__.py
@@ -170,3 +170,31 @@ def check_email_is_whitelisted(email_address):
 
     # whitelist is not specified - allow all emails
     return True
+
+
+def check_email_is_blacklisted(email_address):
+    local_id, _, domain = email_address.partition("@")
+    domain_blacklist = get_config("domain_blacklist")
+
+    if domain_blacklist:
+        domain_blacklist = [d.strip() for d in domain_blacklist.split(",")]
+
+        for disallowed_domain in domain_blacklist:
+            if disallowed_domain.startswith("*."):
+                # domains should never container the "*" char
+                if "*" in domain:
+                    return True
+
+                # Handle wildcard domain case
+                suffix = disallowed_domain[1:]  # Remove the "*" prefix
+                if domain.endswith(suffix):
+                    return True
+
+            elif domain == disallowed_domain:
+                return True
+
+        # blacklist is specified but the email is not blacklisted
+        return False
+
+    # blacklist is not specified - no emails are blacklisted
+    return False


Test patch:
diff --git a/tests/api/v1/test_users.py b/tests/api/v1/test_users.py
index 166d0505..d5b30377 100644
--- a/tests/api/v1/test_users.py
+++ b/tests/api/v1/test_users.py
@@ -568,6 +568,35 @@ def test_api_user_change_email_under_whitelist():
     destroy_ctfd(app)
 
 
+def test_api_user_change_email_under_blacklist():
+    """Test that users can not change emails to ones in the blacklist"""
+    app = create_ctfd()
+    with app.app_context():
+        register_user(app)
+        set_config(
+            "domain_blacklist", "blacklisted.com, blacklisted.org, blacklisted.net"
+        )
+        with login_as_user(app) as client:
+            r = client.patch(
+                "/api/v1/users/me",
+                json={"email": "new_email@blacklisted.com", "confirm": "password"},
+            )
+            assert r.status_code == 400
+            resp = r.get_json()
+            assert resp["errors"]["email"]
+            assert resp["success"] is False
+
+            r = client.patch(
+                "/api/v1/users/me",
+                json={"email": "new_email@test.com", "confirm": "password"},
+            )
+            assert r.status_code == 200
+            resp = r.get_json()
+            assert resp["data"]["email"] == "new_email@test.com"
+            assert resp["success"] is True
+    destroy_ctfd(app)
+
+
 def test_api_user_get_me_solves_not_logged_in():
     """Can a user get /api/v1/users/me/solves if not logged in"""
     app = create_ctfd()
diff --git a/tests/users/test_auth.py b/tests/users/test_auth.py
index 42c52ee1..817be7a0 100644
--- a/tests/users/test_auth.py
+++ b/tests/users/test_auth.py
@@ -107,6 +107,29 @@ def test_register_whitelisted_email():
     destroy_ctfd(app)
 
 
+def test_register_blacklisted_email():
+    """A user shouldn't be able to register with an email that is on the blacklist"""
+    app = create_ctfd()
+    with app.app_context():
+        set_config(
+            "domain_blacklist", "blacklisted.com, blacklisted.org, blacklisted.net"
+        )
+        register_user(
+            app, name="blacklisted", email="user@blacklisted.com", raise_for_error=False
+        )
+        assert Users.query.count() == 1
+
+        register_user(app, name="user1", email="user@yep.com")
+        assert Users.query.count() == 2
+
+        register_user(app, name="user2", email="user@yay.org")
+        assert Users.query.count() == 3
+
+        register_user(app, name="user3", email="user@yipee.net")
+        assert Users.query.count() == 4
+    destroy_ctfd(app)
+
+
 def test_user_bad_login():
     """A user should not be able to login with an incorrect password"""
     app = create_ctfd()
diff --git a/tests/utils/test_email.py b/tests/utils/test_email.py
index 7944502d..bbdc4168 100644
--- a/tests/utils/test_email.py
+++ b/tests/utils/test_email.py
@@ -7,6 +7,7 @@ from CTFd.models import Users
 from CTFd.utils import get_config, set_config
 from CTFd.utils.crypto import verify_password
 from CTFd.utils.email import (
+    check_email_is_blacklisted,
     check_email_is_whitelisted,
     forgot_password,
     sendmail,
@@ -324,6 +325,85 @@ def test_email_whitelist():
     destroy_ctfd(app)
 
 
+def test_email_blacklist():
+    app = create_ctfd()
+    with app.app_context():
+        set_config("domain_blacklist", "example.com")
+        test_cases_specific_domain = [
+            ("john.doe@example.com", True),
+            ("john.doe@ext.example.com", False),
+            ("john.doe@example.io", False),
+            ("john.doe@example.co", False),
+            ("john.doe@ample.com", False),
+            ("john.doe@exexample.com", False),
+        ]
+
+        for case in test_cases_specific_domain:
+            email, expected = case
+            assert check_email_is_blacklisted(email) is expected
+
+        set_config("domain_blacklist", "*.example.com")
+        test_cases_wildcard_domain = [
+            ("john.doe@ext.example.com", True),
+            ("john.doe@.example.com", True),
+            ("john.doe@example.com", False),
+            ("john.doe@example.io", False),
+            ("john.doe@example.co", False),
+            ("john.doe@ample.com", False),
+            ("john.doe@exexample.com", False),
+            ("john.doe@*example.com", True),
+            ("john.doe@*.example.com", True),
+        ]
+
+        for case in test_cases_wildcard_domain:
+            email, expected = case
+            assert check_email_is_blacklisted(email) is expected
+
+        set_config("domain_blacklist", "example.com, *.example.com")
+        test_cases_combined_domain = [
+            ("john.doe@example.com", True),
+            ("john.doe@ext.example.com", True),
+            ("john.doe@.example.com", True),
+            ("john.doe@example.io", False),
+            ("john.doe@example.co", False),
+            ("john.doe@gmail.com", False),
+            ("john.doe@ample.com", False),
+            ("john.doe@exexample.com", False),
+            ("john.doe@*example.com", True),
+            ("john.doe@*.example.com", True),
+        ]
+
+        for case in test_cases_combined_domain:
+            email, expected = case
+            assert check_email_is_blacklisted(email) is expected
+
+        set_config("domain_blacklist", "example.com, uni.acme.com, *.edu, *.edu.de")
+        test_cases_multiple_combined_domains = [
+            ("john.doe@example.com", True),
+            ("john.doe@uni.acme.com", True),
+            ("john.doe@uni.edu", True),
+            ("john.doe@cs.uni.edu", True),
+            ("john.doe@mail.cs.uni.edu", True),
+            ("john.doe@uni.edu.de", True),
+            ("john.doe@cs.uni.edu.de", True),
+            ("john.doe@mail.cs.uni.edu.de", True),
+            ("john.doe@gmail.com", False),
+            ("john.doe@ample.com", False),
+            ("john.doe@example1.com", False),
+            ("john.doe@1example.com", False),
+            ("john.doe@ext.example.com", False),
+            ("john.doe@cs.acme.com", False),
+            ("john.doe@edu.com", False),
+            ("john.doe@mail.uni.acme.com", False),
+            ("john.doe@edu", False),
+        ]
+
+        for case in test_cases_multiple_combined_domains:
+            email, expected = case
+            assert check_email_is_blacklisted(email) is expected
+    destroy_ctfd(app)
+
+
 def test_confirm_links_single_use():
     """Test that confirm links are single use"""
     app = create_ctfd()