Error pages: the 500 page is English only and unstyled, and CSRF refusals and bad requests show Django's bare pages #421
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#421
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 withDEBUGoff. Related to #337, which lists the hard-codedlang="en-gb"of500.htmlamong 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
mainat0a5cad587: still present. The browser test's line is now attests/e2e/test_accessibility.py:967; the other locations have not moved.Postulo ships
403.html,404.htmland500.html. The first two extendbase.htmland 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, because403_csrf.htmland400.htmldo not exist.1. The 500 page is English only
templates/500.html:2(<html lang="en-gb">, nodir),:6(the<title>),:44(<h1>Something went wrong</h1>),:45-48(the explanation),:49(Go back); the file has no{% load i18n %}.lang="en-gb"and with nodir, 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'sserver_errorrenders the template in the request's thread, afterLocaleMiddlewareandUserPreferencesMiddleware(core/middleware.py:141-142) have activated the person's language: a500.htmlholding only{% get_current_language %}and{% get_current_language_bidi %}answeredfr-fr/Falseandar/Truefor those twoAccept-Languagevalues.{% translate %}needs the compiled catalogue and nothing else: no database, no stylesheet, no context processor.2. The 500 page's inline styles are refused
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.server_errorrenders the template with no request, so no nonce is drawn and the response's policy readsstyle-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-11says were stated on purpose. The browser suite does not notice:test_the_error_pages_have_no_violationsloads the page withpage.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 no403_csrf.htmland no400.html;config/settings/base.pysets noCSRF_FAILURE_VIEW, sodjango.views.csrf.csrf_failurelooks for403_csrf.html, does not find it and renders its built-in page;django.views.defaults.bad_requestlikewise 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 whosePOSTULO_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 aboveDATA_UPLOAD_MAX_MEMORY_SIZEor aHostoutsidePOSTULO_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: loadi18n, wrap the title, heading, explanation and link in{% translate %}("Go back" is already in the catalogue from403.html:18and404.html:18), and setlanganddirfrom{% get_current_language %}and{% get_current_language_bidi %}, in whatever spelling #337 settles on. Move the rules out of the<style>element into a smallstatic/css/error.csslinked with a plain<link>(allowed by'self', and still sharing nothing with the main stylesheet), or serve the page through ahandler500that renders it with the request so that the nonce is drawn.templates/403_csrf.html, extendingbase.htmlas403.htmldoes, 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 andbase.htmlwork as on any page.templates/400.htmlin the same frame. A trial400.htmlextendingbase.htmlrendered for a view raisingSuspiciousOperation, for an oversized body and for a disallowedHost, so the frame survives the cases raised before the session and the user exist.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
tests/test_error_pages.py(new) raises from a view withClient(raise_request_exception=False)and a French profile language, and asserts that the 500 body is in French with a Frenchlang; it fails today.Content-Security-Policyallows every style the body uses (no<style>element without a matching nonce); it fails today.Client(enforce_csrf_checks=True)and no token, and asserts a 403 rendered from403_csrf.html: it carries the masthead and a translated message, and not "More information is available with DEBUG=True."; it fails today.SuspiciousOperationfrom a view, and another sends aHostthat is not allowed, and both assert that the 400 body comes from400.html; they fail today.