diff --git a/README.md b/README.md index df77b44e..0aca0673 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,12 @@ When the panel is served over HTTPS on a hostname other than localhost, add it t environment=CSRF_TRUSTED_ORIGINS="https://webvirtcloud.example.com" ``` +For a panel served over HTTPS, also set `WEBVIRTCLOUD_HTTPS=1` (same place). It +makes the session and CSRF cookies HTTPS-only, redirects HTTP to HTTPS (except +`/datasource/`, which VMs fetch over HTTP) and sends HSTS for one year. TLS can +end at the bundled nginx or at a proxy in front of it; such a proxy must send +`X-Forwarded-Proto: https`. + ## How To Update Back up `db.sqlite3` before updating (in Docker it lives in the `data` volume). @@ -428,6 +434,14 @@ requirement. If provisioning cannot write a new private password file, it stops without creating an admin account or printing its password; fix the directory permissions and rerun migrations. +HTTPS deployments: copy the `SECURE_PROXY_SSL_HEADER` / `WEBVIRTCLOUD_HTTPS` block +of `webvirtcloud/settings.py.template` into your `webvirtcloud/settings.py`, set +`WEBVIRTCLOUD_HTTPS=1` in the service environment, copy the `map` block and the +`X-Forwarded-Proto` line of `conf/nginx/webvirtcloud.conf` into +`/etc/nginx/conf.d/webvirtcloud.conf`, and reload nginx +(`sudo nginx -t && sudo systemctl reload nginx`). Re-running the installer +replaces that file, including any TLS directives you added to it. + ```bash # Go to Installation Directory cd /srv/webvirtcloud diff --git a/conf/nginx/webvirtcloud.conf b/conf/nginx/webvirtcloud.conf index 0da9a2f3..13c55d45 100644 --- a/conf/nginx/webvirtcloud.conf +++ b/conf/nginx/webvirtcloud.conf @@ -1,3 +1,11 @@ +# "https" from a TLS-terminating proxy in front of this nginx is kept; otherwise +# the scheme of this server is used. A client claiming https over plain HTTP +# only changes how its own request is handled (no redirect, secure cookies). +map $http_x_forwarded_proto $wvc_forwarded_proto { + default $scheme; + https https; +} + server { listen 80; @@ -14,7 +22,7 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $wvc_forwarded_proto; proxy_connect_timeout 1800; proxy_read_timeout 1800; proxy_send_timeout 1800; diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template index df47116c..8f143606 100644 --- a/webvirtcloud/settings.py.template +++ b/webvirtcloud/settings.py.template @@ -35,9 +35,20 @@ CSRF_TRUSTED_ORIGINS = ["http://localhost", "http://127.0.0.1", "http://localhos if extra_origins := os.getenv("CSRF_TRUSTED_ORIGINS"): CSRF_TRUSTED_ORIGINS.extend([origin.strip() for origin in extra_origins.split(",") if origin.strip()]) -# Honor X-Forwarded-Proto header for request.is_secure() when behind a reverse proxy (Nginx, Traefik, etc.) +# Honor X-Forwarded-Proto for request.is_secure(). gunicorn listens on +# 127.0.0.1 only, so the bundled nginx is the one client that sets it. SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +# Production profile for a panel served over HTTPS: secure cookies, HTTP to +# HTTPS redirect and HSTS. Off by default (plain HTTP). +if os.getenv("WEBVIRTCLOUD_HTTPS") == "1": + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_SSL_REDIRECT = True + # VMs fetch cloud-init metadata over plain HTTP + SECURE_REDIRECT_EXEMPT = [r"^datasource/"] + SECURE_HSTS_SECONDS = 31536000 + # Application definition INSTALLED_APPS = [ "django.contrib.auth", diff --git a/webvirtcloud/test_settings_env.py b/webvirtcloud/test_settings_env.py new file mode 100644 index 00000000..d679121a --- /dev/null +++ b/webvirtcloud/test_settings_env.py @@ -0,0 +1,70 @@ +"""Settings read from the environment. Each case imports the settings in a +subprocess, so the environment of one case cannot leak into another.""" + +import json +import os +import subprocess +import sys + +from django.conf import settings +from django.test import TestCase, override_settings + +# Env vars that change the settings; removed from the inherited environment so +# every case starts from the defaults. +CONFIG_VARS = ["WEBVIRTCLOUD_HTTPS"] + + +def _env(env): + base = {k: v for k, v in os.environ.items() if k not in CONFIG_VARS} + return {**base, "DJANGO_SETTINGS_MODULE": "webvirtcloud.settings", **env} + + +def load_settings(env, names): + code = ( + "import json, django; django.setup(); from django.conf import settings as s; " + f"print(json.dumps({{n: getattr(s, n) for n in {names!r}}}, default=str))" + ) + result = subprocess.run( + [sys.executable, "-c", code], env=_env(env), cwd=settings.BASE_DIR, + capture_output=True, text=True, + ) + if result.returncode != 0: + raise AssertionError(result.stderr) + return json.loads(result.stdout) + + +def run_manage(env, *args): + result = subprocess.run( + [sys.executable, "manage.py", *args], env=_env(env), cwd=settings.BASE_DIR, + capture_output=True, text=True, + ) + output = result.stdout + result.stderr + if result.returncode != 0: + raise AssertionError(output) + return output + + +# TestCase: the request cases may touch the database (sessions, axes). +class HttpsProfileTestCase(TestCase): + def test_https_profile_passes_deploy_checks(self): + output = run_manage({"WEBVIRTCLOUD_HTTPS": "1"}, "check", "--deploy") + for check_id in ("security.W004", "security.W008", "security.W012", "security.W016"): + self.assertNotIn(check_id, output) + + def test_plain_http_is_the_default(self): + names = ["SESSION_COOKIE_SECURE", "CSRF_COOKIE_SECURE", "SECURE_SSL_REDIRECT", "SECURE_HSTS_SECONDS"] + self.assertEqual(load_settings({}, names), dict.fromkeys(names[:3], False) | {"SECURE_HSTS_SECONDS": 0}) + + def test_datasource_is_not_redirected(self): + exempt = load_settings({"WEBVIRTCLOUD_HTTPS": "1"}, ["SECURE_REDIRECT_EXEMPT"])["SECURE_REDIRECT_EXEMPT"] + with override_settings(SECURE_SSL_REDIRECT=True, SECURE_REDIRECT_EXEMPT=exempt): + self.assertNotEqual(self.client.get("/datasource/openstack/").status_code, 301) + response = self.client.get("/accounts/login/") + self.assertEqual(response.status_code, 301) + self.assertTrue(response["Location"].startswith("https://")) + + def test_proxied_https_request_is_not_redirected(self): + # TLS ends at a proxy, which tells Django through X-Forwarded-Proto + with override_settings(SECURE_SSL_REDIRECT=True): + response = self.client.get("/accounts/login/", HTTP_X_FORWARDED_PROTO="https") + self.assertNotEqual(response.status_code, 301)