Repository: CTFd/CTFd
Pull request #2375 (2023-07-26 11:30:51)

Add email whitelist wildcard
This allows to specify domains with a wildcard like `*.edu` for the domain whitelist.

Note that this is not a regex match, it doesn't evaluate the expression to support inner wildcards, e.g. it's not possible do do something like `cs.*.edu` - however that's intentional to keep this simple.

MilyMilo: I think this is as good as it's going to get. 
I don't think it's possible to avoid `.example.com` with this design, but I filtered out `*.example.com`.

Patch:
diff --git a/CTFd/forms/config.py b/CTFd/forms/config.py
index 3a051670..ade6944c 100644
--- a/CTFd/forms/config.py
+++ b/CTFd/forms/config.py
@@ -31,7 +31,7 @@ class ResetInstanceForm(BaseForm):
 class AccountSettingsForm(BaseForm):
     domain_whitelist = StringField(
         "Account Email Whitelist",
-        description="Comma-seperated email domains which users can register under (e.g. ctfd.io, gmail.com, yahoo.com)",
+        description="Comma-seperated email domains which users can register under (e.g. ctfd.io, example.com, *.example.com)",
     )
     team_creation = SelectField(
         "Team Creation",
diff --git a/CTFd/utils/email/__init__.py b/CTFd/utils/email/__init__.py
index 9a2fba21..08d7abf7 100644
--- a/CTFd/utils/email/__init__.py
+++ b/CTFd/utils/email/__init__.py
@@ -137,8 +137,26 @@ def user_created_notification(addr, name, password):
 def check_email_is_whitelisted(email_address):
     local_id, _, domain = email_address.partition("@")
     domain_whitelist = get_config("domain_whitelist")
+
     if domain_whitelist:
         domain_whitelist = [d.strip() for d in domain_whitelist.split(",")]
-        if domain not in domain_whitelist:
-            return False
+
+        for allowed_domain in domain_whitelist:
+            if allowed_domain.startswith("*."):
+                # domains should never container the "*" char
+                if "*" in domain:
+                    return False
+
+                # Handle wildcard domain case
+                suffix = allowed_domain[1:]  # Remove the "*" prefix
+                if domain.endswith(suffix):
+                    return True
+
+            elif domain == allowed_domain:
+                return True
+
+        # whitelist is specified but the email doesn't match any domains
+        return False
+
+    # whitelist is not specified - allow all emails
     return True


Test patch:
diff --git a/tests/utils/test_email.py b/tests/utils/test_email.py
index 327c0ee5..4036da86 100644
--- a/tests/utils/test_email.py
+++ b/tests/utils/test_email.py
@@ -6,6 +6,7 @@ from freezegun import freeze_time
 
 from CTFd.utils import get_config, set_config
 from CTFd.utils.email import (
+    check_email_is_whitelisted,
     sendmail,
     successful_registration_notification,
     verify_email_address,
@@ -239,3 +240,82 @@ def test_successful_registration_email(mock_smtp):
             email_msg
         )
     destroy_ctfd(app)
+
+
+def test_email_whitelist():
+    app = create_ctfd()
+    with app.app_context():
+        set_config("domain_whitelist", "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_whitelisted(email) is expected
+
+        set_config("domain_whitelist", "*.example.com")
+        test_cases_wildcard_domain = [
+            ("john.doe@ext.example.com", True),
+            ("john.doe@.example.com", True),  # this is expected behaviour
+            ("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", False),
+            ("john.doe@*.example.com", False),
+        ]
+
+        for case in test_cases_wildcard_domain:
+            email, expected = case
+            assert check_email_is_whitelisted(email) is expected
+
+        set_config("domain_whitelist", "example.com, *.example.com")
+        test_cases_combined_domain = [
+            ("john.doe@example.com", True),
+            ("john.doe@ext.example.com", True),
+            ("john.doe@.example.com", True),  # this is expected behaviour
+            ("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", False),
+            ("john.doe@*.example.com", False),
+        ]
+
+        for case in test_cases_combined_domain:
+            email, expected = case
+            assert check_email_is_whitelisted(email) is expected
+
+        set_config("domain_whitelist", "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_whitelisted(email) is expected