learning-website-django1-2 Exercise 3: Behind Apache and Gunicorn ================================================================= In production, Apache ends the HTTPS connection and passes the request to Gunicorn (the Python application server), which talks to Django over a plain local connection. Django only sees what the proxy tells it, so three things must be right: the host name, whether the request was HTTPS, and which headers to trust. First test Django's side with code, then write the proxy configuration. PART A: Test what Django does with proxy headers. Save as tests/test_proxy.py: from django.test import Client, RequestFactory, SimpleTestCase, override_settings PROXY_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") class BehindAProxyTests(SimpleTestCase): """Apache ends the HTTPS connection and talks plain HTTP to Django. These tests show what Django does with the headers the proxy adds.""" def test_https_is_only_believed_when_the_header_is_trusted(self): request = RequestFactory().get("/", HTTP_HOST="languages.localhost", HTTP_X_FORWARDED_PROTO="https") self.assertFalse(request.is_secure()) # default: the header is ignored with override_settings(SECURE_PROXY_SSL_HEADER=PROXY_HEADER): self.assertTrue(request.is_secure()) # trusted: now https def test_without_the_header_a_plain_request_is_not_secure(self): request = RequestFactory().get("/", HTTP_HOST="languages.localhost") with override_settings(SECURE_PROXY_SSL_HEADER=PROXY_HEADER): self.assertFalse(request.is_secure()) @override_settings(SECURE_SSL_REDIRECT=True, SECURE_PROXY_SSL_HEADER=PROXY_HEADER) def test_ssl_redirect_does_not_loop_behind_the_proxy(self): plain = Client().get("/", HTTP_HOST="languages.localhost") self.assertEqual(plain.status_code, 301) self.assertEqual(plain["Location"], "https://languages.localhost/") proxied = Client().get("/", HTTP_HOST="languages.localhost", HTTP_X_FORWARDED_PROTO="https") self.assertEqual(proxied.status_code, 200) # no redirect loop def test_x_forwarded_host_is_ignored_by_default(self): response = Client().get("/", HTTP_HOST="languages.localhost", HTTP_X_FORWARDED_HOST="systems.localhost") self.assertIn("site: languages", response.content.decode()) @override_settings(USE_X_FORWARDED_HOST=True) def test_trusting_x_forwarded_host_lets_a_visitor_pick_the_site(self): # This is why the setting stays off: anyone can send that header. response = Client().get("/", HTTP_HOST="languages.localhost", HTTP_X_FORWARDED_HOST="systems.localhost") self.assertIn("site: systems", response.content.decode()) python manage.py test tests Output (checked by running it; this is the full test run, with all the tests so far): Found 24 test(s). System check identified no issues (0 silenced). ........................ ---------------------------------------------------------------------- Ran 24 tests in 0.043s OK What the tests show: - With no SECURE_PROXY_SSL_HEADER, Django ignores X-Forwarded-Proto: the request is not "secure", even if the proxy says https. With the setting, it believes the header. - SECURE_SSL_REDIRECT=True sends plain HTTP requests to https, and does NOT loop when the proxy header says https. Without SECURE_PROXY_SSL_HEADER it would redirect forever, because Django never sees HTTPS. - X-Forwarded-Host is ignored by default. With USE_X_FORWARDED_HOST=True, a visitor can choose the site (and the host Django believes it is serving) by sending that header. Leave it off, and make the proxy pass the real host instead (ProxyPreserveHost On). PART B: The proxy configuration. THIS PART WAS NOT RUN: Apache and Gunicorn are not installed on the machine used to write the course. Test it on the server with apache2ctl configtest, and with curl -I against each site. Gunicorn, as a systemd service (/etc/systemd/system/learning-site.service): [Unit] Description=Learning site (Django, all sites) After=network.target [Service] User=www-data WorkingDirectory=/var/www/learning-site Environment=DJANGO_SETTINGS_MODULE=config.settings.prod EnvironmentFile=/etc/learning-site.env # holds DJANGO_SECRET_KEY, mode 600, NOT in git ExecStart=/var/www/learning-site/venv/bin/gunicorn config.wsgi:application \ --bind unix:/run/learning-site.sock --workers 3 Restart=on-failure [Install] WantedBy=multi-user.target Apache, one virtual host per site (the same text with ServerName changed). Enable the modules once: a2enmod proxy proxy_http headers ServerName languages.osztromok.com # Certbot adds the matching *:443 virtual host ServerName languages.osztromok.com ProxyPreserveHost On # Django sees languages.osztromok.com RequestHeader set X-Forwarded-Proto "https" # matches SECURE_PROXY_SSL_HEADER Alias /static/ /var/www/learning-site/staticfiles/ Require all granted ProxyPass /static/ ! # Apache serves static files itself ProxyPass / unix:/run/learning-site.sock|http://localhost/ ProxyPassReverse / unix:/run/learning-site.sock|http://localhost/ Checklist for the server: 1. apache2ctl configtest, then systemctl reload apache2 2. curl -I https://languages.osztromok.com/ expect 200 3. curl -I http://languages.osztromok.com/ expect a redirect to https, once 4. python manage.py check --deploy with the production settings 5. a request for the wrong site's folder gets 404 (languages with /linux/...) WHY THIS WORKS AS AN ANSWER --------------------------- The risky part of a proxied setup is the three things Django cannot see for itself. Writing each one as a test (HTTPS, redirect loop, forwarded host) turns the configuration from a hope into something you can check, and being clear about which half was not run keeps the server test on your list.