Repository: CTFd/CTFd
Pull request #2709 (2025-02-18 16:52:10)

Backport TRUSTED_HOSTS from Flask 3.1
* Backport the `TRUSTED_HOSTS` config setting from Flask 3.1


Patch:
diff --git a/CTFd/__init__.py b/CTFd/__init__.py
index f2fa95e5..d8bebf9a 100644
--- a/CTFd/__init__.py
+++ b/CTFd/__init__.py
@@ -13,6 +13,7 @@ from jinja2 import FileSystemLoader
 from jinja2.sandbox import SandboxedEnvironment
 from werkzeug.middleware.proxy_fix import ProxyFix
 from werkzeug.utils import safe_join
+from werkzeug.wsgi import get_host
 
 import CTFd.utils.config
 from CTFd import utils
@@ -65,6 +66,17 @@ class CTFdFlask(Flask):
         """Overridden jinja environment constructor"""
         return super(CTFdFlask, self).create_jinja_environment()
 
+    def create_url_adapter(self, request):
+        # TODO: Backport of TRUSTED_HOSTS behavior from Flask. Remove when possible.
+        # https://github.com/pallets/flask/pull/5637
+        if request is not None:
+            if (trusted_hosts := self.config.get("TRUSTED_HOSTS")) is not None:
+                request.trusted_hosts = trusted_hosts
+
+            # Check trusted_hosts here until bind_to_environ does.
+            request.host = get_host(request.environ, request.trusted_hosts)
+        return super(CTFdFlask, self).create_url_adapter(request)
+
 
 class SandboxedBaseEnvironment(SandboxedEnvironment):
     """SandboxEnvironment that mimics the Flask BaseEnvironment"""
diff --git a/CTFd/config.ini b/CTFd/config.ini
index a1510dd1..a50e651d 100644
--- a/CTFd/config.ini
+++ b/CTFd/config.ini
@@ -97,6 +97,17 @@ REDIS_PORT =
 REDIS_DB =
 
 [security]
+# TRUSTED_HOSTS
+# Comma seperated string containing valid host names for CTFd to respond to. If not specified, 
+# CTFd will respond to requests for any host unless otherwise restricted in an upstream proxy.
+# Each value is either an exact match, or can start with a dot . to match any subdomain.
+#
+# It is recommended that most users set this to the server name that they expect to be using
+# 
+# Example: example.com,ctfd.io,.ctfd.io
+# See https://flask.palletsprojects.com/en/stable/config/#TRUSTED_HOSTS
+TRUSTED_HOSTS =
+
 # SESSION_COOKIE_HTTPONLY
 # Controls if cookies should be set with the HttpOnly flag. Defaults to True.
 SESSION_COOKIE_HTTPONLY = true
diff --git a/CTFd/config.py b/CTFd/config.py
index a0b4e539..4e37a75e 100644
--- a/CTFd/config.py
+++ b/CTFd/config.py
@@ -144,6 +144,12 @@ class ServerConfig(object):
     CROSS_ORIGIN_OPENER_POLICY: str = empty_str_cast(config_ini["security"].get("CROSS_ORIGIN_OPENER_POLICY")) \
         or "same-origin-allow-popups"
 
+    TRUSTED_HOSTS: list[str] | None = None
+    if config_ini["security"].get("TRUSTED_HOSTS"):
+        TRUSTED_HOSTS = [
+            h.strip() for h in empty_str_cast(config_ini["security"].get("TRUSTED_HOSTS")).split(",")
+        ]
+
     """
     TRUSTED_PROXIES:
     Defines a set of regular expressions used for finding a user's IP address if the CTFd instance


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index 34add48b..931075e8 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,6 +1,8 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*-
 
+from werkzeug.exceptions import SecurityError
+
 from CTFd.config import TestingConfig
 from tests.helpers import create_ctfd, destroy_ctfd, login_as_user, register_user
 
@@ -58,3 +60,29 @@ def test_server_sent_events_config():
         r = client.get("/events")
         assert r.status_code == 204
     destroy_ctfd(app)
+
+
+def test_trusted_hosts_config():
+    """Test that TRUSTED_HOSTS configuration behaves properly"""
+
+    class TrustedHostsConfig(TestingConfig):
+        SERVER_NAME = "example.com"
+        TRUSTED_HOSTS = ["example.com"]
+
+    app = create_ctfd(config=TrustedHostsConfig)
+    with app.app_context():
+        register_user(app)
+        client = login_as_user(app)
+        r = client.get("/", headers={"Host": "example.com"})
+        assert r.status_code == 200
+
+        # TODO: We need to allow either a 500 or a 400 because Flask-RestX
+        # seems to be overriding Flask's error handler
+        try:
+            r = client.get("/", headers={"Host": "evil.com"})
+        except SecurityError:
+            pass
+        else:
+            if r.status_code != 400:
+                raise SecurityError("Responded to untrusted request")
+    destroy_ctfd(app)