Error handling and the two-layer deny model
Requests are denied at two layers and the application sees only one, which is
why the error surfaces look asymmetric. Read this before changing
portal/src/middleware.ts or any error page.
The two layers
Framework CSRF, before app code. Astro's security.checkOrigin is on by
default: for form-content-type mutations it compares Origin against the
request host and returns a bare 403 without rendering. It is sequenced ahead
of user middleware and returns without calling next(), so nothing in
application code, middleware included, can observe or rebrand it. Never
disable or reimplement it; the bypass of this exact check (CVE-2024-56140)
turned on content-type handling.
Application guards, inside the route. Past checkOrigin,
requireAdminSession in portal/src/lib/admin.ts redirects a session below
aal2 into the Kratos step-up flow and redirects an identity without the admin
marker to a branded error page; consent CSRF tampering answers 400. Application-layer denials are structured and reachable by real users; the
checkOrigin 403 is effectively attacker-only because a normal browser sends a
matching Origin. Both layers are asserted together in
e2e/playwright/tests/r-admin-post-origin.spec.ts and
h-member-admin-post.spec.ts: an origin-less POST gets the framework 403, a
same-origin POST gets the application's structured deny.
One branded surface
Login, consent and logout render through FocusPage; the error routes (404, 500,
auth/error) render through
portal/src/components/StatusPage.astro: status, title, message, an optional
reason badge, never any internals. Denials redirect to
portal/src/pages/auth/error.astro carrying a reason that page maps to copy;
middleware rebrands raw non-HTML application errors for HTML navigations only,
preserves the original status, and leaves API responses alone. Adding a denial reason is a data change, not a new page.
The one surface that stays unbranded
Because checkOrigin short-circuits before user middleware, the framework 403
is the one deny the portal cannot brand. It ships bare.