Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions chart/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion chart/values-prod.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
12 changes: 12 additions & 0 deletions chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>` 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://<gateway.host>`, 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 |
Expand Down
9 changes: 9 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/backend/typelearn/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/frontend/e2e/completion-stats.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"exercisesCompleted": "Упражнений выполнено",
"today": "Сегодня",
"last7Days": "Последние 7 дней",
"next": "Следующее упражнение →"
"next": "Дальше →"
},
"footer": {
"source": "Исходный код на GitHub"
Expand Down
Loading