Error pages: the 500 page is English only and unstyled, and CSRF refusals and bad requests show Django's bare pages #421

Closed
opened 2026-10-01 10:46:35 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, by reading the error templates and rendering a server error, a CSRF refusal and a bad request through the test client with DEBUG off. Related to #337, which lists the hard-coded lang="en-gb" of 500.html among the places a language code is written in lower case; this issue adds that the page should not name a fixed language at all, and the rest below.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The browser test's line is now at tests/e2e/test_accessibility.py:967; the other locations have not moved.

Postulo ships 403.html, 404.html and 500.html. The first two extend base.html and are translated. The other error answers a person can meet are not in that state: the 500 page speaks English only and reaches the browser without its styles, and a refused CSRF token or a bad request is answered by Django's built-in fallback pages, because 403_csrf.html and 400.html do not exist.

1. The 500 page is English only

  • Where: templates/500.html:2 (<html lang="en-gb">, no dir), :6 (the <title>), :44 (<h1>Something went wrong</h1>), :45-48 (the explanation), :49 (Go back); the file has no {% load i18n %}.
  • What happens: a person reading Postulo in French or Arabic who meets a server error gets an English page with an English tab title, marked lang="en-gb" and with no dir, so a right-to-left reader also gets the wrong direction and a screen reader the wrong pronunciation. The page is self-contained on purpose (the comment at :8-11), but translation needs none of what it avoids. Django's server_error renders the template in the request's thread, after LocaleMiddleware and UserPreferencesMiddleware (core/middleware.py:141-142) have activated the person's language: a 500.html holding only {% get_current_language %} and {% get_current_language_bidi %} answered fr-fr/False and ar/True for those two Accept-Language values. {% translate %} needs the compiled catalogue and nothing else: no database, no stylesheet, no context processor.

2. The 500 page's inline styles are refused

  • Where: templates/500.html:7-39 (a <style> element with no nonce); config/settings/base.py:672 ("style-src": [CSP.SELF, CSP.NONCE]); tests/e2e/test_accessibility.py:964.
  • What happens: server_error renders the template with no request, so no nonce is drawn and the response's policy reads style-src 'self' alone (checked with the test client). The browser refuses the <style> element, and the page loses its centring, its own colours and its dark scheme, which the comment at :8-11 says were stated on purpose. The browser suite does not notice: test_the_error_pages_have_no_violations loads the page with page.set_content(render_to_string("500.html")), which carries no policy header, so axe checks a styled page that nobody in production sees.

3. A refused CSRF token and a bad request show Django's pages

  • Where: templates/ holds no 403_csrf.html and no 400.html; config/settings/base.py sets no CSRF_FAILURE_VIEW, so django.views.csrf.csrf_failure looks for 403_csrf.html, does not find it and renders its built-in page; django.views.defaults.bad_request likewise falls back to <h1>Bad Request (400)</h1>.

  • What happens: a person signs out and in again in one tab, which rotates the CSRF secret, then submits a form still open in another tab, for example a cover letter they were writing. The answer is Django's page, "Forbidden (403)", "CSRF verification failed. Request aborted.", a paragraph about cookies and "More information is available with DEBUG=True.", marked lang="en", with no masthead, none of Postulo's theme (it follows the browser's colour scheme, not the one the person chose) and no link back. An instance whose POSTULO_CSRF_TRUSTED_ORIGINS (config/settings/base.py:30) does not match its address behind a proxy shows that page on every form. A request Django refuses as suspicious, for example a form body above DATA_UPLOAD_MAX_MEMORY_SIZE or a Host outside POSTULO_ALLOWED_HOSTS, answers with the bare "Bad Request (400)". Unlike the 500 page, Django's CSRF page does carry the request's nonce on its <style>, so it is not also unstyled.

  • Why it matters: these are the pages a person sees when something has already gone wrong, and the CSRF one arrives with work in hand. None of the three speaks the person's language or says what happened in Postulo's words; two offer no way back, one addresses its advice to a developer, and the accessibility check of the 500 page passes on a rendering that never reaches anybody.

  • Fix:

    • 500.html: load i18n, wrap the title, heading, explanation and link in {% translate %} ("Go back" is already in the catalogue from 403.html:18 and 404.html:18), and set lang and dir from {% get_current_language %} and {% get_current_language_bidi %}, in whatever spelling #337 settles on. Move the rules out of the <style> element into a small static/css/error.css linked with a plain <link> (allowed by 'self', and still sharing nothing with the main stylesheet), or serve the page through a handler500 that renders it with the request so that the nonce is drawn.
    • Add templates/403_csrf.html, extending base.html as 403.html does, saying in translated words that the form had expired or could not be checked, that nothing was saved, and offering to go back, reload and send it again. Django renders it with the request, so the context processors and base.html work as on any page.
    • Add templates/400.html in the same frame. A trial 400.html extending base.html rendered for a view raising SuspiciousOperation, for an oversized body and for a disallowed Host, so the frame survives the cases raised before the session and the user exist.
    • In tests/e2e/test_accessibility.py, reach the 500 page through a real response (for example a test-only URL whose view raises) so that it is checked under the policy, and add the CSRF and 400 pages to the same check.

Done when

  • A test in tests/test_error_pages.py (new) raises from a view with Client(raise_request_exception=False) and a French profile language, and asserts that the 500 body is in French with a French lang; it fails today.
  • The same test asserts that the response's Content-Security-Policy allows every style the body uses (no <style> element without a matching nonce); it fails today.
  • A test there posts to a form view with Client(enforce_csrf_checks=True) and no token, and asserts a 403 rendered from 403_csrf.html: it carries the masthead and a translated message, and not "More information is available with DEBUG=True."; it fails today.
  • A test there raises SuspiciousOperation from a view, and another sends a Host that is not allowed, and both assert that the 400 body comes from 400.html; they fail today.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, by reading the error templates and rendering a server error, a CSRF refusal and a bad request through the test client with `DEBUG` off. Related to #337, which lists the hard-coded `lang="en-gb"` of `500.html` among the places a language code is written in lower case; this issue adds that the page should not name a fixed language at all, and the rest below. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The browser test's line is now at `tests/e2e/test_accessibility.py:967`; the other locations have not moved. Postulo ships `403.html`, `404.html` and `500.html`. The first two extend `base.html` and are translated. The other error answers a person can meet are not in that state: the 500 page speaks English only and reaches the browser without its styles, and a refused CSRF token or a bad request is answered by Django's built-in fallback pages, because `403_csrf.html` and `400.html` do not exist. ## 1. The 500 page is English only - **Where:** `templates/500.html:2` (`<html lang="en-gb">`, no `dir`), `:6` (the `<title>`), `:44` (`<h1>Something went wrong</h1>`), `:45-48` (the explanation), `:49` (`Go back`); the file has no `{% load i18n %}`. - **What happens:** a person reading Postulo in French or Arabic who meets a server error gets an English page with an English tab title, marked `lang="en-gb"` and with no `dir`, so a right-to-left reader also gets the wrong direction and a screen reader the wrong pronunciation. The page is self-contained on purpose (the comment at `:8-11`), but translation needs none of what it avoids. Django's `server_error` renders the template in the request's thread, after `LocaleMiddleware` and `UserPreferencesMiddleware` (`core/middleware.py:141-142`) have activated the person's language: a `500.html` holding only `{% get_current_language %}` and `{% get_current_language_bidi %}` answered `fr-fr`/`False` and `ar`/`True` for those two `Accept-Language` values. `{% translate %}` needs the compiled catalogue and nothing else: no database, no stylesheet, no context processor. ## 2. The 500 page's inline styles are refused - **Where:** `templates/500.html:7-39` (a `<style>` element with no nonce); `config/settings/base.py:672` (`"style-src": [CSP.SELF, CSP.NONCE]`); `tests/e2e/test_accessibility.py:964`. - **What happens:** `server_error` renders the template with no request, so no nonce is drawn and the response's policy reads `style-src 'self'` alone (checked with the test client). The browser refuses the `<style>` element, and the page loses its centring, its own colours and its dark scheme, which the comment at `:8-11` says were stated on purpose. The browser suite does not notice: `test_the_error_pages_have_no_violations` loads the page with `page.set_content(render_to_string("500.html"))`, which carries no policy header, so axe checks a styled page that nobody in production sees. ## 3. A refused CSRF token and a bad request show Django's pages - **Where:** `templates/` holds no `403_csrf.html` and no `400.html`; `config/settings/base.py` sets no `CSRF_FAILURE_VIEW`, so `django.views.csrf.csrf_failure` looks for `403_csrf.html`, does not find it and renders its built-in page; `django.views.defaults.bad_request` likewise falls back to `<h1>Bad Request (400)</h1>`. - **What happens:** a person signs out and in again in one tab, which rotates the CSRF secret, then submits a form still open in another tab, for example a cover letter they were writing. The answer is Django's page, "Forbidden (403)", "CSRF verification failed. Request aborted.", a paragraph about cookies and "More information is available with DEBUG=True.", marked `lang="en"`, with no masthead, none of Postulo's theme (it follows the browser's colour scheme, not the one the person chose) and no link back. An instance whose `POSTULO_CSRF_TRUSTED_ORIGINS` (`config/settings/base.py:30`) does not match its address behind a proxy shows that page on every form. A request Django refuses as suspicious, for example a form body above `DATA_UPLOAD_MAX_MEMORY_SIZE` or a `Host` outside `POSTULO_ALLOWED_HOSTS`, answers with the bare "Bad Request (400)". Unlike the 500 page, Django's CSRF page does carry the request's nonce on its `<style>`, so it is not also unstyled. - **Why it matters:** these are the pages a person sees when something has already gone wrong, and the CSRF one arrives with work in hand. None of the three speaks the person's language or says what happened in Postulo's words; two offer no way back, one addresses its advice to a developer, and the accessibility check of the 500 page passes on a rendering that never reaches anybody. - **Fix:** - `500.html`: load `i18n`, wrap the title, heading, explanation and link in `{% translate %}` ("Go back" is already in the catalogue from `403.html:18` and `404.html:18`), and set `lang` and `dir` from `{% get_current_language %}` and `{% get_current_language_bidi %}`, in whatever spelling #337 settles on. Move the rules out of the `<style>` element into a small `static/css/error.css` linked with a plain `<link>` (allowed by `'self'`, and still sharing nothing with the main stylesheet), or serve the page through a `handler500` that renders it with the request so that the nonce is drawn. - Add `templates/403_csrf.html`, extending `base.html` as `403.html` does, saying in translated words that the form had expired or could not be checked, that nothing was saved, and offering to go back, reload and send it again. Django renders it with the request, so the context processors and `base.html` work as on any page. - Add `templates/400.html` in the same frame. A trial `400.html` extending `base.html` rendered for a view raising `SuspiciousOperation`, for an oversized body and for a disallowed `Host`, so the frame survives the cases raised before the session and the user exist. - In `tests/e2e/test_accessibility.py`, reach the 500 page through a real response (for example a test-only URL whose view raises) so that it is checked under the policy, and add the CSRF and 400 pages to the same check. ## Done when - A test in `tests/test_error_pages.py` (new) raises from a view with `Client(raise_request_exception=False)` and a French profile language, and asserts that the 500 body is in French with a French `lang`; it fails today. - The same test asserts that the response's `Content-Security-Policy` allows every style the body uses (no `<style>` element without a matching nonce); it fails today. - A test there posts to a form view with `Client(enforce_csrf_checks=True)` and no token, and asserts a 403 rendered from `403_csrf.html`: it carries the masthead and a translated message, and not "More information is available with DEBUG=True."; it fails today. - A test there raises `SuspiciousOperation` from a view, and another sends a `Host` that is not allowed, and both assert that the 400 body comes from `400.html`; they fail today.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:46:35 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#421
No description provided.