diff mbox series

[layerindex-web,1/2] dockersetup: trust the site's own origin for CSRF

Message ID 20261025020646.89376-1-tim.orling@konsulko.com
State New
Headers show
Series [layerindex-web,1/2] dockersetup: trust the site's own origin for CSRF | expand

Commit Message

Tim Orling Oct. 25, 2026, 2:06 a.m. UTC
A default dockersetup.py install serves the site on https://localhost:8081,
but CSRF_TRUSTED_ORIGINS only allowed https://localhost, so logging in
failed with "Origin checking failed" (403). Let CSRF_TRUSTED_ORIGINS be
set from the environment, and have dockersetup.py write the URL the site
is served on, including any non-standard port, to .env.local.app.

[YOCTO #15329]

AI-Generated: Claude Opus 5.5
Signed-off-by: Tim Orling <tim.orling@konsulko.com>
---
 .env.dev.app              |  1 +
 docker/settings.py        |  4 +++-
 dockersetup.py            | 30 ++++++++++++++++--------------
 tests/test_dockersetup.py | 19 +++++++++++++++++++
 4 files changed, 39 insertions(+), 15 deletions(-)
diff mbox series

Patch

diff --git a/.env.dev.app b/.env.dev.app
index 9f8fb5a..a268cdc 100644
--- a/.env.dev.app
+++ b/.env.dev.app
@@ -11,4 +11,5 @@  RABBITMQ_DEFAULT_PASS=guest
 #EMAIL_PASSWORD=<set this here if needed>
 #EMAIL_USE_SSL=<set this here if needed>
 #EMAIL_USE_TLS=<set this here if needed>
+#CSRF_TRUSTED_ORIGINS=<set this if the site is not served on https://HOSTNAME>
 DEBUG=1
diff --git a/docker/settings.py b/docker/settings.py
index e18a9c2..a610052 100644
--- a/docker/settings.py
+++ b/docker/settings.py
@@ -314,7 +314,9 @@  TOOLS_LOG_DIR = ""
 
 USE_X_FORWARDED_HOST = True
 ALLOWED_HOSTS = [os.getenv('HOSTNAME', 'layers.test')]
-CSRF_TRUSTED_ORIGINS = ['https://' + os.getenv('HOSTNAME', 'layers.test')]
+# Comma-separated; set it when the site is not served on https://HOSTNAME,
+# e.g. on a non-standard port
+CSRF_TRUSTED_ORIGINS = (os.getenv('CSRF_TRUSTED_ORIGINS') or 'https://' + os.getenv('HOSTNAME', 'layers.test')).split(',')
 SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
 SECURE_BROWSER_XSS_FILTER = True
 SECURE_CONTENT_TYPE_NOSNIFF = True
diff --git a/dockersetup.py b/dockersetup.py
index eb02f33..406bdf9 100755
--- a/dockersetup.py
+++ b/dockersetup.py
@@ -582,7 +582,7 @@  def write_env_file(app_dir):
 # Write the generated secrets and email settings to .env.local.<service>,
 # starting from the tracked .env.dev.<service> defaults. These files hold
 # secrets and are ignored by git.
-def write_service_env_files(dbpassword, dbapassword, secretkey, rmqpassword, email_host, email_port, email_user, email_password, email_ssl, email_tls):
+def write_service_env_files(dbpassword, dbapassword, secretkey, rmqpassword, email_host, email_port, email_user, email_password, email_ssl, email_tls, site_origin=None):
     values = {
         'SECRET_KEY': secretkey,
         'DATABASE_USER': 'layers',
@@ -596,6 +596,7 @@  def write_service_env_files(dbpassword, dbapassword, secretkey, rmqpassword, ema
         'EMAIL_PASSWORD': email_password,
         'EMAIL_USE_SSL': str(email_ssl) if email_ssl else None,
         'EMAIL_USE_TLS': str(email_tls) if email_tls else None,
+        'CSRF_TRUSTED_ORIGINS': site_origin,
         'DEBUG': '0',
     }
     for service in ('app', 'celery', 'db', 'rabbit'):
@@ -613,6 +614,17 @@  def write_service_env_files(dbpassword, dbapassword, secretkey, rmqpassword, ema
             f.write(''.join(newlines))
 
 
+# Return the protocol and host[:port] that the site is served on
+def site_protocol_host(hostname, http_port, https_port, no_https):
+    if https_port and not no_https:
+        protocol, port, defport = 'https', https_port, '443'
+    else:
+        protocol, port, defport = 'http', http_port, '80'
+    if port == defport:
+        return protocol, hostname
+    return protocol, '%s:%s' % (hostname, port)
+
+
 def check_connectivity(app_dir='/opt'):
     return_code = subprocess.call(compose('run', '--rm', 'layersapp', app_dir + '/connectivity_check.sh'), shell=False)
     if return_code != 0:
@@ -813,7 +825,8 @@  provide information as needed.
         edit_dockerfile(args.http_proxy, args.https_proxy, args.no_proxy)
         write_env_file(args.app_dir)
 
-        write_service_env_files(dbpassword, dbapassword, secretkey, rmqpassword, email_host, email_port, args.email_user, args.email_password, args.email_ssl, args.email_tls)
+        protocol, host = site_protocol_host(args.hostname, http_port, https_port, args.no_https)
+        write_service_env_files(dbpassword, dbapassword, secretkey, rmqpassword, email_host, email_port, args.email_user, args.email_password, args.email_ssl, args.email_tls, '%s://%s' % (protocol, host))
         edit_dockercompose(args.hostname, args.portmapping, args.letsencrypt, args.app_dir)
 
         edit_dockerfile_web(args.hostname, args.no_https)
@@ -940,18 +953,7 @@  provide information as needed.
         print("Collecting static files failed")
         sys.exit(1)
 
-    if https_port and not args.no_https:
-        protocol = 'https'
-        port = https_port
-        defport = '443'
-    else:
-        protocol = 'http'
-        port = http_port
-        defport = '80'
-    if port == defport:
-        host = args.hostname
-    else:
-        host = '%s:%s' % (args.hostname, port)
+    protocol, host = site_protocol_host(args.hostname, http_port, https_port, args.no_https)
 
     if not args.update:
         if not args.databasefile:
diff --git a/tests/test_dockersetup.py b/tests/test_dockersetup.py
index 7958946..0109c80 100644
--- a/tests/test_dockersetup.py
+++ b/tests/test_dockersetup.py
@@ -77,10 +77,29 @@  def test_env_files_are_private_and_leave_unset_email_commented(workdir):
     app = read_env(workdir / '.env.local.app')
     assert 'EMAIL_HOST' not in app
     assert 'EMAIL_USE_TLS' not in app
+    assert 'CSRF_TRUSTED_ORIGINS' not in app
     # The tracked defaults are left alone
     assert read_env(workdir / '.env.dev.db')['MYSQL_ROOT_PASSWORD'] == 'testingpw'
 
 
+def test_env_files_get_site_origin(workdir):
+    # The default port 8081 is not the standard https port, so the browser
+    # sends it in the Origin header and Django's CSRF check must allow it
+    dockersetup.write_service_env_files('dbpw', 'rootpw', 'sekrit', 'mqpw', None, None,
+                                        None, None, False, False, 'https://localhost:8081')
+    assert read_env(workdir / '.env.local.app')['CSRF_TRUSTED_ORIGINS'] == 'https://localhost:8081'
+
+
+@pytest.mark.parametrize('http_port, https_port, no_https, expected', [
+    ('8080', '8081', False, ('https', 'localhost:8081')),
+    ('80', '443', False, ('https', 'localhost')),
+    ('8080', None, True, ('http', 'localhost:8080')),
+    ('80', '8081', True, ('http', 'localhost')),
+])
+def test_site_protocol_host(http_port, https_port, no_https, expected):
+    assert dockersetup.site_protocol_host('localhost', http_port, https_port, no_https) == expected
+
+
 def load_compose(workdir):
     return yaml.safe_load((workdir / 'docker-compose.yml').read_text())