From 1e96616d9a57827b21d5b8f08ecd900b03f4849b Mon Sep 17 00:00:00 2001 From: Egor Poderiagin Date: Tue, 13 Oct 2026 01:40:20 +0700 Subject: [PATCH 1/2] fix: helm chart settings for csrf --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++++- Tiltfile | 7 ++++++ chart/templates/configmap.yaml | 18 +++++++++++++++ chart/values-prod.yaml.example | 5 +++- chart/values.yaml | 12 ++++++++++ docs/deployment.md | 38 +++++++++++++++++++++++++++++++ docs/development.md | 9 ++++++++ src/backend/typelearn/settings.py | 19 ++++++++++++++++ src/frontend/src/locales/ru.json | 2 +- 9 files changed, 138 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3449c19..f417683 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,8 @@ jobs: helm template typelearn chart $API --namespace typelearn-ci \ --set frontend.mode=dev --set frontend.hmrClientPort=8500 \ --set media.hostPath=/media --set worktree.slug=ci \ - --set postgres.allowCreateDatabase=true > /dev/null + --set postgres.allowCreateDatabase=true \ + --set-string env.DJANGO_BEHIND_HTTPS_PROXY=false > /dev/null echo "media served from outside the chart" helm template typelearn chart $API --set media.server.enabled=false > /dev/null echo "an external database" @@ -72,6 +73,34 @@ jobs: echo "ingestion" helm template typelearn chart $API \ --set ingest.enabled=true --set ingest.corpusHostPath=/corpus > /dev/null + # The chart is secure by default: Django is told requests arrive over + # HTTPS, which marks cookies Secure and trusts the https origin. Nothing in + # a plain-HTTP cluster exercises that, and its failure is a 403 on the + # first deployment's admin login — so it is asserted here, along with the + # opt-out development relies on. + - name: HTTPS is the default and plain HTTP is a choice + env: + API: --api-versions gateway.networking.k8s.io/v1 --api-versions postgresql.cnpg.io/v1 + run: | + set -e + cm=$(helm template typelearn chart $API --values chart/values-prod.yaml.example \ + --show-only templates/configmap.yaml) + echo "$cm" | grep -q 'DJANGO_BEHIND_HTTPS_PROXY: "true"' \ + || { echo "FAIL: a deployment is not secure by default"; exit 1; } + echo "$cm" | grep -q 'CSRF_TRUSTED_ORIGINS: "https://typelearn.example.com"' \ + || { echo "FAIL: the https origin was not trusted"; exit 1; } + echo "secure with nothing supplied at all" + helm template typelearn chart $API --show-only templates/configmap.yaml \ + | grep -q 'DJANGO_BEHIND_HTTPS_PROXY: "true"' \ + || { echo "FAIL: the bare defaults are not secure"; exit 1; } + echo "the local opt-out, as Tilt sets it, must turn both off" + cm=$(helm template typelearn chart $API --values chart/values-prod.yaml.example \ + --set-string env.DJANGO_BEHIND_HTTPS_PROXY=false \ + --show-only templates/configmap.yaml) + echo "$cm" | grep -q 'DJANGO_BEHIND_HTTPS_PROXY: "false"' \ + || { echo "FAIL: the opt-out was not honoured"; exit 1; } + echo "$cm" | grep -q 'CSRF_TRUSTED_ORIGINS' \ + && { echo "FAIL: an https origin was trusted in plain-HTTP mode"; exit 1; } || true # The requirement is "secrets are not configuration". Asserted here rather # than trusted, because the failure is silent: a secret in a ConfigMap # works perfectly and is readable by anyone who can read the namespace. diff --git a/Tiltfile b/Tiltfile index 97adaf6..c1c214c 100644 --- a/Tiltfile +++ b/Tiltfile @@ -342,6 +342,13 @@ if not DEV_MODE: ENV["DJANGO_DEBUG"] = "false" ENV["DJANGO_ALLOWED_HOSTS"] = "localhost,127.0.0.1" +# The chart is secure by default: it tells Django every request arrived over +# HTTPS, so cookies are marked Secure and a browser would not send them back over +# http://localhost. This cluster has no certificate, in either mode, so the one +# thing that always differs from a deployment is that it says so. Set after .env +# is read, so a stale line there cannot put the secure default back. +ENV["DJANGO_BEHIND_HTTPS_PROXY"] = "false" + # --- The stack ---------------------------------------------------------------- # One chart, rendered here with development values and by a deployment with its # own. The templates a developer exercises every day are the templates a diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index d2c434e..5183900 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -18,6 +18,24 @@ {{- if not (has .Values.gateway.host $hosts) }} {{- $_ := set $env "DJANGO_ALLOWED_HOSTS" (append (compact $hosts) .Values.gateway.host | join ",") }} {{- end }} + {{- if eq (toString (get $env "DJANGO_BEHIND_HTTPS_PROXY") | lower) "true" }} + {{/* + Secure mode, which is the default: the public origin is https. Django + cannot always see that for itself — Envoy forwards plain HTTP to it, and + a TLS-terminating proxy upstream of Envoy may not survive as + X-Forwarded-Proto — so a browser's `Origin: https://host` is trusted by + name or every POST, the admin login included, is a 403. + + Appended and de-duplicated like the host above, so an explicit list keeps + whatever else it names. Skipped in the plain-HTTP mode a local cluster + opts into: Django derives http://host from the request and agrees. + */}} + {{- $origin := printf "https://%s" .Values.gateway.host }} + {{- $origins := splitList "," (default "" (get $env "CSRF_TRUSTED_ORIGINS")) }} + {{- if not (has $origin $origins) }} + {{- $_ := set $env "CSRF_TRUSTED_ORIGINS" (append (compact $origins) $origin | join ",") }} + {{- end }} + {{- end }} {{- end }} apiVersion: v1 kind: ConfigMap diff --git a/chart/values-prod.yaml.example b/chart/values-prod.yaml.example index 5412467..eb5cf8e 100644 --- a/chart/values-prod.yaml.example +++ b/chart/values-prod.yaml.example @@ -36,7 +36,10 @@ gateway: # --- Configuration ------------------------------------------------------------ # Any backend environment variable can be set here; these override the chart's # defaults. gateway.host is added to DJANGO_ALLOWED_HOSTS automatically, so it -# does not need restating. +# does not need restating — and so is its https:// origin in CSRF_TRUSTED_ORIGINS, +# because DJANGO_BEHIND_HTTPS_PROXY defaults to "true": cookies are Secure and +# Django trusts that requests arrived over HTTPS. Nothing here turns that off; +# only a cluster with no certificate does. env: DJANGO_DEBUG: "false" diff --git a/chart/values.yaml b/chart/values.yaml index a11f0b6..82dd84c 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -113,6 +113,18 @@ env: # Extended automatically with gateway.host when that is set, so the common # case needs nothing here. DJANGO_ALLOWED_HOSTS: "localhost,127.0.0.1" + # Secure by default: Django is told every request reached it over HTTPS (the + # Gateway's TLS, or a proxy upstream of it), so the session and CSRF cookies + # are marked Secure, and with gateway.host set that host's https:// origin is + # added to CSRF_TRUSTED_ORIGINS — without which every POST, the admin login + # included, is a 403 behind a TLS-terminating proxy. + # + # A cluster with no certificate — development's kind cluster at + # http://localhost — has to say so: set "false", and cookies are sent over + # plain HTTP again. The Tiltfile does exactly that, so no one running it edits + # anything. A deployment terminating TLS upstream of the Gateway keeps "true" + # and has no gateway.tls.secretName to set. + DJANGO_BEHIND_HTTPS_PROXY: "true" # Where ingested clips live in the container; the media volume mounts here. MEDIA_ROOT: /app/media CORPUS_PATH: /corpus diff --git a/docs/deployment.md b/docs/deployment.md index ce34542..776053a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -68,6 +68,44 @@ env: ANY_NEW_SETTING: "value" ``` +#### HTTPS + +The chart is secure by default, and "secure" here means it assumes clients +reach it over HTTPS. That is not something Django can see for itself: the +Gateway terminates TLS and forwards plain HTTP, so a browser's +`Origin: https://` never matches the request's own scheme and every POST — +the admin login included — fails CSRF with a 403. `DJANGO_BEHIND_HTTPS_PROXY` +defaults to `"true"`, and with it: + +| Setting | Becomes | Why | +| --- | --- | --- | +| `SECURE_PROXY_SSL_HEADER` | trusts the Gateway's `X-Forwarded-Proto` | a request is known to be HTTPS | +| session and CSRF cookies | marked `Secure` | a browser never sends them over plain HTTP | +| `CSRF_TRUSTED_ORIGINS` | `https://`, appended to anything you list | the origin the browser sends | + +Name a certificate with `gateway.tls.secretName` and the Gateway gains an HTTPS +listener for it; terminate TLS upstream of the Gateway instead and there is +nothing to name, and the defaults still apply. + +**A cluster with no certificate** — development's kind cluster, or any trial on +plain HTTP — has to say so, because `Secure` cookies are not sent back over +`http://` and signing in would silently fail: + +```yaml +env: + DJANGO_BEHIND_HTTPS_PROXY: "false" +``` + +`tilt up` sets this itself; see [development.md](development.md). Treat it as +the one switch that makes a cluster not a deployment, and leave it out of +anything that is one. + +The Gateway also keeps its port-80 listener, and the same fails-closed rule +applies there: a page opened over it still loads, but signing in does not, so +send visitors to the `https://` address. + +#### Secrets + Secrets are a different map, and which one you use matters: | | Use it when | What it does | diff --git a/docs/development.md b/docs/development.md index e2f67e2..ef13052 100644 --- a/docs/development.md +++ b/docs/development.md @@ -347,6 +347,15 @@ same database, same chart. A deployment differs from development in its values, so these differ in their values too — otherwise the default would be a second arrangement rather than the one that ships. `tilt up` prints which mode it is in. +One value differs from a deployment in *both* modes: `DJANGO_BEHIND_HTTPS_PROXY`. +The chart defaults it to `"true"` — cookies `Secure`, requests assumed HTTPS — +and the cluster Tilt builds is `http://localhost` with no certificate, where a +`Secure` cookie is never sent back and the admin login would silently fail. The +Tiltfile sets it to `"false"` after reading `.env`, so there is nothing to edit +and a line in `.env` cannot turn it back on. Installing the chart on a cluster +without Tilt, by hand, needs `--set-string env.DJANGO_BEHIND_HTTPS_PROXY=false` +for the same reason. + The host list narrows rather than only `DJANGO_DEBUG` flipping, because a wildcard hides the misconfiguration the default exists to catch. `.env` keeps saying `DJANGO_DEBUG=true` — it is a development file, also read by management diff --git a/src/backend/typelearn/settings.py b/src/backend/typelearn/settings.py index d6a23d8..3687541 100644 --- a/src/backend/typelearn/settings.py +++ b/src/backend/typelearn/settings.py @@ -38,6 +38,25 @@ if host ] +# Behind a TLS-terminating Gateway Django sees plain HTTP, so a browser's +# `Origin: https://host` never matches the request's own scheme and every POST +# (the admin login included) fails CSRF. List the public origins, scheme +# included: "https://typelearn.app". +CSRF_TRUSTED_ORIGINS = [ + origin.strip() + for origin in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') + if origin.strip() +] + +# Set when every request arrives through a proxy that sets X-Forwarded-Proto +# (the Gateway's Envoy does): Django then knows the request was HTTPS and marks +# the session and CSRF cookies Secure. Off by default, so a plain-HTTP +# localhost cluster keeps working. +if os.environ.get('DJANGO_BEHIND_HTTPS_PROXY', 'false').lower() == 'true': + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + # Application definition diff --git a/src/frontend/src/locales/ru.json b/src/frontend/src/locales/ru.json index 12d0e79..90ec671 100644 --- a/src/frontend/src/locales/ru.json +++ b/src/frontend/src/locales/ru.json @@ -76,7 +76,7 @@ "exercisesCompleted": "Упражнений выполнено", "today": "Сегодня", "last7Days": "Последние 7 дней", - "next": "Следующее упражнение →" + "next": "Дальше →" }, "footer": { "source": "Исходный код на GitHub" From 99a16526afb39b2cb826b9325604b637f01e0c90 Mon Sep 17 00:00:00 2001 From: Egor Poderiagin Date: Tue, 13 Oct 2026 01:45:33 +0700 Subject: [PATCH 2/2] fix: improve button to the next exercise --- src/frontend/e2e/completion-stats.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/e2e/completion-stats.spec.js b/src/frontend/e2e/completion-stats.spec.js index 799cbea..540653f 100644 --- a/src/frontend/e2e/completion-stats.spec.js +++ b/src/frontend/e2e/completion-stats.spec.js @@ -242,7 +242,7 @@ test('the Next exercise label fits its button in a long-label language', async ( const check = await page.locator('input[lang="th"] + button').boundingBox() await page.locator('input[lang="th"]').fill(await sentenceOnScreen(page)) - const next = page.getByRole('button', { name: 'Следующее упражнение →' }) + const next = page.getByRole('button', { name: 'Дальше →' }) await expect(next).toBeVisible() expect(await next.boundingBox()).toEqual(check) expect(await next.evaluate((button) => button.scrollWidth <= button.clientWidth)).toBe(true)