diff --git a/.github/workflows/test-chart.yaml b/.github/workflows/test-chart.yaml index 6885fe7be2..907beb69c0 100644 --- a/.github/workflows/test-chart.yaml +++ b/.github/workflows/test-chart.yaml @@ -137,13 +137,14 @@ jobs: --set hub.image.name=quay.io/jupyterhub/k8s-hub-slim --set prePuller.hook.enabled=true --set prePuller.hook.pullOnlyOnChanges=true - - k3s-channel: v1.31 # also test hub.existingSecret + - k3s-channel: v1.31 # also test hub.existingSecret and subdomain_host test: install local-chart-extra-args: >- --set hub.existingSecret=test-hub-existing-secret --set proxy.secretToken=aaaa1111 --set hub.cookieSecret=bbbb2222 --set hub.config.CryptKeeper.keys[0]=cccc3333 + --set hub.config.JupyterHub.subdomain_host=jupyterhub.example.org create-k8s-test-resources: true # We run three upgrade tests where we first install an already released @@ -368,6 +369,9 @@ jobs: continue-on-error: ${{ matrix.accept-failure == true }} run: | . ./ci/common + if [ "${{ contains(matrix.local-chart-extra-args, 'subdomain_host') }}" = "true" ]; then + export CI_SUBDOMAIN_HOST=jupyterhub.example.org + fi # If you have problems with the tests add '--capture=no' to show stdout pytest --verbose --maxfail=2 --color=yes ./tests diff --git a/docs/source/administrator/security.md b/docs/source/administrator/security.md index e25c5b4b33..d6cf58c788 100644 --- a/docs/source/administrator/security.md +++ b/docs/source/administrator/security.md @@ -489,3 +489,44 @@ proxy: ``` This would restrict the access to only two IP addresses: `111.111.111.111` and `222.222.222.222`. + +(jupyterhub_subdomains)= + +## Host user servers on a subdomain + +You can reduce the chance of cross-origin attacks by giving each user +their own subdomain `.jupyter.example.org`. +This requires setting [`subdomain_host`](schema_hub.config.JupyterHub.subdomain_host), creating a wildcard DNS record `*.jupyter.example.org`, and creating a wildcard SSL certificate. + +```yaml +hub: + config: + JupyterHub: + subdomain_host: jupyter.example.org +``` + +If you are using a Kubernetes ingress this must include hosts +`jupyter.example.org` and `*.jupyter.example.org`. +For example: + +```yaml +ingress: + enabled: true + hosts: + - jupyter.example.org + - "*.jupyter.example.org" + tls: + - hosts: + - jupyter.example.org + - "*.jupyter.example.org" + secretName: example-tls +``` + +where `example-tls` is the name of a Kubernetes secret containing the wildcard certificate and key. + +The chart does not support the automatic creation of wildcard HTTPS certificates. +You must obtain a certificate from an external source, +for example by using an ACME client such as [cert-manager with the DNS-01 challenge](https://cert-manager.io/docs/configuration/acme/dns01/), +and ensure the certificate and key are stored in the secret. + +See {ref}`jupyterhub:subdomains` in the JupyterHub documentation for more information. diff --git a/jupyterhub/templates/proxy/deployment.yaml b/jupyterhub/templates/proxy/deployment.yaml index 85220a86ef..d5a987824d 100644 --- a/jupyterhub/templates/proxy/deployment.yaml +++ b/jupyterhub/templates/proxy/deployment.yaml @@ -100,6 +100,9 @@ spec: {{- if .Values.debug.enabled }} - --log-level=debug {{- end }} + {{- if .Values.hub.config.JupyterHub.subdomain_host }} + - --host-routing + {{- end }} {{- range .Values.proxy.chp.extraCommandLineFlags }} - {{ tpl . $ }} {{- end }} diff --git a/jupyterhub/values.schema.yaml b/jupyterhub/values.schema.yaml index 769038f55a..4d735ca20c 100644 --- a/jupyterhub/values.schema.yaml +++ b/jupyterhub/values.schema.yaml @@ -217,7 +217,7 @@ properties: values, you need to use [`hub.extraConfig`](schema_hub.extraConfig) instead. - ```{admonition} Currently intended only for auth config + ```{admonition} Some configuration must be set in multiple places :class: warning This config _currently_ (0.11.0) only influence the software in the `hub` Pod, but some Helm chart config options such as @@ -271,6 +271,25 @@ properties: the `--values` or `-f` flag. During merging, lists are replaced while dictionaries are updated. ``` + properties: + JupyterHub: + type: object + additionalProperties: true + description: | + JupyterHub Traitlets configuration. + + See {py:mod}`jupyterhub:jupyterhub.app` for the full list, + but take note of the [above warnings](schema_hub.config). + properties: + subdomain_host: + type: string + description: | + The subdomain to use for hosting singleuser servers. + + This helps protect against some cross-origin attacks by giving each user + their own subdomain `.jupyter.example.org`. + + See {ref}`jupyterhub_subdomains`. extraFiles: &extraFiles type: object additionalProperties: false diff --git a/tests/test_spawn.py b/tests/test_spawn.py index a20a2dc2b2..86cd325da1 100644 --- a/tests/test_spawn.py +++ b/tests/test_spawn.py @@ -1,10 +1,14 @@ import json +import os import subprocess import time import pytest import requests +# If we're testing subdomain hosts in GitHub CI our workflow will set this +CI_SUBDOMAIN_HOST = os.getenv("CI_SUBDOMAIN_HOST") + def test_spawn_basic( api_request, @@ -28,12 +32,39 @@ def test_spawn_basic( api_request, jupyter_user, request_data["test_timeout"] ) assert server_model - r = requests.get( - request_data["hub_url"].partition("/hub/api")[0] - + server_model["url"] - + "api", - verify=pebble_acme_ca_cert, - ) + + hub_parent_url = request_data["hub_url"].partition("/hub/api")[0] + + if CI_SUBDOMAIN_HOST: + # We can't make a proper request since wildcard DNS isn't setup, + # but we can set the Host header to test that CHP correctly forwards + # the request to the singleuser server + assert ( + server_model["url"] + == f"https://{jupyter_user}.{CI_SUBDOMAIN_HOST}/user/{jupyter_user}/" + ) + + # It shouldn't be possible to access the server without the subdomain, + # should instead be redirected to hub + r_incorrect = requests.get( + f"{hub_parent_url}/user/{jupyter_user}/api", + verify=pebble_acme_ca_cert, + allow_redirects=False, + ) + assert r_incorrect.status_code == 302 + + r = requests.get( + f"{hub_parent_url}/user/{jupyter_user}/api", + headers={"Host": f"{jupyter_user}.{CI_SUBDOMAIN_HOST}"}, + verify=False, + allow_redirects=False, + ) + else: + r = requests.get( + hub_parent_url + server_model["url"] + "api", + verify=pebble_acme_ca_cert, + ) + assert r.status_code == 200 assert "version" in r.json()