From d0ca7d74730c20af61faba54078585c622c96634 Mon Sep 17 00:00:00 2001 From: Alberto Vilches Date: Thu, 2 Jul 2026 10:20:31 -0400 Subject: [PATCH 1/4] Default ALLOWED_HOSTS to localhost in production --- docker-compose.yaml | 2 ++ gateway/main/settings.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 4d6cac4fd1..dba9730d20 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -47,6 +47,7 @@ services: user: "root" # root user is needed to write on volumes environment: - DEBUG=0 + - ALLOWED_HOSTS=* - RAY_HOST=http://ray-head:8265 - RAY_CLUSTER_MODE_LOCAL=true - DJANGO_SUPERUSER_USERNAME=admin @@ -76,6 +77,7 @@ services: entrypoint: "./entrypoint-scheduler.sh" environment: - DEBUG=0 + - ALLOWED_HOSTS=* - DATABASE_HOST=postgres - DATABASE_PORT=5432 - DATABASE_NAME=serverlessdb diff --git a/gateway/main/settings.py b/gateway/main/settings.py index 2cade9ed49..9df3422abf 100644 --- a/gateway/main/settings.py +++ b/gateway/main/settings.py @@ -50,7 +50,9 @@ LOG_FORMAT = "json" if os.environ.get("LOG_FORMAT", "simple") == "json" else "simple" # It must be a full url without protocol: mydomain.com -ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",") +# In production (DEBUG off) require an explicit allowlist instead of "*", which +# otherwise enables Host header attacks (cache poisoning, password-reset, etc.). +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*" if DEBUG else "localhost").split(",") # It must be a full url: https://mydomain.com CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "http://localhost").split(",") From 70c4a20374ffabafe3f560e66f9783ea7d9cdfad Mon Sep 17 00:00:00 2001 From: Alberto Vilches Date: Thu, 2 Jul 2026 14:36:26 -0400 Subject: [PATCH 2/4] Default chart allowedHosts to localhost so unset deployments fail closed --- charts/qiskit-serverless/charts/gateway/values.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/charts/qiskit-serverless/charts/gateway/values.yaml b/charts/qiskit-serverless/charts/gateway/values.yaml index 850dfc958d..881d8b5204 100644 --- a/charts/qiskit-serverless/charts/gateway/values.yaml +++ b/charts/qiskit-serverless/charts/gateway/values.yaml @@ -53,7 +53,10 @@ application: runtimeApi: url: "https://quantum.cloud.ibm.com" cacheTtl: "60" - allowedHosts: "*" + # Set this to the host(s) the gateway serves. It defaults to localhost so a + # deployment that forgets to set it fails closed instead of accepting any Host + # header. Set "*" only for local development. + allowedHosts: "localhost" trustedOrigins: "http://localhost" corsOrigins: "http://localhost" logsMaximumSize: "52428800" # 50Mb in bytes From 75b20b340c15bfbd9b3a3595b8dfb5175ee9e491 Mon Sep 17 00:00:00 2001 From: Alberto Vilches Date: Thu, 2 Jul 2026 14:51:45 -0400 Subject: [PATCH 3/4] Fail closed when ALLOWED_HOSTS is unset in production --- .../charts/gateway/values.yaml | 8 ++++---- gateway/main/settings.py | 17 ++++++++++++++--- gateway/tox.ini | 3 +++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/charts/qiskit-serverless/charts/gateway/values.yaml b/charts/qiskit-serverless/charts/gateway/values.yaml index 881d8b5204..061bd26731 100644 --- a/charts/qiskit-serverless/charts/gateway/values.yaml +++ b/charts/qiskit-serverless/charts/gateway/values.yaml @@ -53,10 +53,10 @@ application: runtimeApi: url: "https://quantum.cloud.ibm.com" cacheTtl: "60" - # Set this to the host(s) the gateway serves. It defaults to localhost so a - # deployment that forgets to set it fails closed instead of accepting any Host - # header. Set "*" only for local development. - allowedHosts: "localhost" + # Set this to the host(s) the gateway serves. It is empty by default so a + # deployment that forgets to set it fails closed (the gateway refuses to boot) + # instead of accepting any Host header. Set "*" only for local development. + allowedHosts: "" trustedOrigins: "http://localhost" corsOrigins: "http://localhost" logsMaximumSize: "52428800" # 50Mb in bytes diff --git a/gateway/main/settings.py b/gateway/main/settings.py index 9df3422abf..2b62465ec8 100644 --- a/gateway/main/settings.py +++ b/gateway/main/settings.py @@ -16,6 +16,9 @@ import os.path import sys from pathlib import Path + +from django.core.exceptions import ImproperlyConfigured + from core.utils import sanitize_file_path RELEASE_VERSION = os.environ.get("VERSION", "UNKNOWN") @@ -50,9 +53,17 @@ LOG_FORMAT = "json" if os.environ.get("LOG_FORMAT", "simple") == "json" else "simple" # It must be a full url without protocol: mydomain.com -# In production (DEBUG off) require an explicit allowlist instead of "*", which -# otherwise enables Host header attacks (cache poisoning, password-reset, etc.). -ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*" if DEBUG else "localhost").split(",") +# Accepting any Host ("*") enables Host header attacks (cache poisoning and +# password-reset links pointing at an attacker domain). In production (DEBUG off) +# the process fails closed if ALLOWED_HOSTS is not set, instead of defaulting to +# a value. A hardcoded "*" is only used for local development and tests. +_allowed_hosts = os.environ.get("ALLOWED_HOSTS") +if not _allowed_hosts: + if DEBUG or IS_TEST: + _allowed_hosts = "*" + else: + raise ImproperlyConfigured("ALLOWED_HOSTS environment variable must be set when DEBUG is disabled.") +ALLOWED_HOSTS = _allowed_hosts.split(",") # It must be a full url: https://mydomain.com CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "http://localhost").split(",") diff --git a/gateway/tox.ini b/gateway/tox.ini index 7a5a61f960..0c962f2bd5 100644 --- a/gateway/tox.ini +++ b/gateway/tox.ini @@ -21,6 +21,9 @@ setenv = LANGUAGE=en_US LC_ALL=en_US.utf-8 PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python + # Settings fail closed without ALLOWED_HOSTS when DEBUG is off; lint and import + # checks import the settings module, so give them a local value. + ALLOWED_HOSTS=localhost deps = -rrequirements.txt -rrequirements-dev.txt From 356013d6a488234d37bec66de5858e4c568c4fbf Mon Sep 17 00:00:00 2001 From: Alberto Vilches Date: Thu, 2 Jul 2026 17:15:54 -0400 Subject: [PATCH 4/4] Add ALLOWED_HOSTS settings regression tests --- gateway/tests/main/__init__.py | 0 gateway/tests/main/test_settings.py | 75 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 gateway/tests/main/__init__.py create mode 100644 gateway/tests/main/test_settings.py diff --git a/gateway/tests/main/__init__.py b/gateway/tests/main/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gateway/tests/main/test_settings.py b/gateway/tests/main/test_settings.py new file mode 100644 index 0000000000..a4a02ae57c --- /dev/null +++ b/gateway/tests/main/test_settings.py @@ -0,0 +1,75 @@ +# This code is part of a Qiskit project. +# +# (C) IBM 2026 +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Regression tests for main.settings ALLOWED_HOSTS handling.""" + +import importlib +import sys +from unittest.mock import patch + +import pytest +from django.core.exceptions import ImproperlyConfigured + + +@pytest.fixture(autouse=True) +def restore_main_settings(): + """Reload main.settings with a valid env after each test. + + Reloading the module runs settings.py top to bottom and mutates the + imported module in place, so leave it in a clean, test-friendly state + (pytest is in sys.modules, so IS_TEST is True) and other tests are not + polluted by a half-initialized module. + """ + yield + assert "pytest" in sys.modules + import main.settings + + importlib.reload(main.settings) + + +def test_allowed_hosts_required_when_debug_off(monkeypatch): + """Unset ALLOWED_HOSTS with DEBUG off fails closed in production.""" + monkeypatch.setenv("DEBUG", "0") + monkeypatch.delenv("ALLOWED_HOSTS", raising=False) + + import main.settings + + # Hide pytest from sys.modules only for this reload so IS_TEST is False + # and the production guard is actually exercised. + with patch.dict("sys.modules"): + sys.modules.pop("pytest", None) + with pytest.raises(ImproperlyConfigured): + importlib.reload(main.settings) + + +def test_allowed_hosts_wildcard_when_debug_on(monkeypatch): + """Unset ALLOWED_HOSTS with DEBUG on defaults to the wildcard.""" + monkeypatch.setenv("DEBUG", "1") + monkeypatch.delenv("ALLOWED_HOSTS", raising=False) + + import main.settings + + importlib.reload(main.settings) + + assert main.settings.ALLOWED_HOSTS == ["*"] + + +def test_allowed_hosts_uses_set_value(monkeypatch): + """A set ALLOWED_HOSTS value is used as-is, split on commas.""" + monkeypatch.setenv("DEBUG", "0") + monkeypatch.setenv("ALLOWED_HOSTS", "example.com") + + import main.settings + + importlib.reload(main.settings) + + assert main.settings.ALLOWED_HOSTS == ["example.com"]