Skip to content

Hotfix: revert the antiforgery Secure cookie — main returns 500 on every page - #351

Merged
dfe-lance merged 4 commits into
mainfrom
347-security-headers
Aug 26, 2026
Merged

Hotfix: revert the antiforgery Secure cookie — main returns 500 on every page#351
dfe-lance merged 4 commits into
mainfrom
347-security-headers

Conversation

@dfe-lance

Copy link
Copy Markdown
Contributor

Follow-up to #349, which is on main and is currently returning 500 on every
page in every non-Development environment. Refs #347.

This reverts one line of #349. The other five items in that change are
untouched and still work.

What is broken

#349 set AntiforgeryOptions.Cookie.SecurePolicy to Always outside
development, as the apparent counterpart to the session cookie, which already
does exactly that.

The session cookie system only marks the cookie. The antiforgery system does
not: DefaultAntiforgery.CheckSSLConfig throws when the policy is Always
and the request is not HTTPS. _Layout.cshtml mints an antiforgery token on
every page render, so every page throws.

System.InvalidOperationException: The antiforgery system has the configuration
value AntiforgeryOptions.Cookie.SecurePolicy = Always, but the current request
is not an SSL request.
at Microsoft.AspNetCore.Antiforgery.DefaultAntiforgery.CheckSSLConfig(HttpContext context)
at Microsoft.AspNetCore.Antiforgery.DefaultAntiforgery.GetAndStoreTokens(HttpContext httpContext)
at AspNetCoreGeneratedDocument.Views_Shared__Layout ... _Layout.cshtml:line 58

Deployed pods sit behind a TLS-terminating ingress and receive plain HTTP, so
Request.IsHttps is false and the assertion fires on every request.

/healthcheck still returns 200, because it is mapped before that
middleware. Health probes therefore report the service as fine while every page
is dead — worth knowing when checking whether an environment is affected.

Measured, not inferred

The same container image, run with ASPNETCORE_ENVIRONMENT=Review over plain
HTTP, on each tree:

/ /healthcheck antiforgery exceptions
current main 500 200 4
this branch 200 200 0

Why this is a revert and not a fix

UseForwardedHeaders() is already wired with XForwardedProto, which would
make Request.IsHttps true behind the ingress and the Always policy correct
and safe. It has no effect here because CoreWebExtensions calls
options.KnownProxies.Clear() while leaving KnownNetworks at its loopback
default, so the middleware ignores the ingress's forwarded headers and the
application believes it is serving plain HTTP in every deployed environment.

Correcting that means choosing which proxies to trust — accepting
X-Forwarded-Proto from anywhere lets a client claim HTTPS — which is a
security decision in its own right and not one to settle inside a hotfix. It is
also a pre-existing condition: HSTS and the HTTPS-redirect logic are working
from the same false premise today, independently of #349.

So the cookie returns to the framework default, and the reason is recorded in a
comment at the point where the next person will reach for Always again.

What is unaffected

The five other items from #349 remain in place, verified in the same
Review-over-HTTP container: X-Content-Type-Options, Referrer-Policy,
Permissions-Policy and the content-security policy are all applied (4/4),
TRACE is refused with 405, and Kestrel does not advertise itself.

Testing

Against the branch merged with current main:

Suite Result
Release build, full solution Clean, no new warnings
Unit 4,446 passed, 0 failed
Integration 713 passed, 0 failed
E2E (Category!=VisualRegression) 152 passed, 5 failed, 2 skipped

The five E2E failures are IncorrectGradeEnquiryTests, which fail the same way
on main in this environment and pass in CI — unrelated to this change.

Also confirmed across two teardown and relaunch cycles, one of them a cold start
with volumes destroyed: the application serves 200, the worker starts with no
restarts, and the headers and TRACE behaviour hold each time.

Follow-up worth raising separately

The forwarded-headers trust boundary. Until KnownNetworks is addressed,
Request.IsHttps is false in every deployed environment, which blocks securing
this cookie properly and means the HSTS and HTTPS-redirect logic are operating
on a false premise.

A security assessment picked up a handful of low-severity header and cookie
items. Individually each is minor; together they are the cheapest security work
available, and several had been noted informally for a while.

The existing set was most of the way there — HSTS, a content-security policy
and X-Frame-Options were already in place, and the session cookie already sets
Secure outside development. This fills what was missing rather than starting
from nothing:

- X-Content-Type-Options: nosniff, so a browser stops second-guessing a
  declared content type. It matters here because the storage browser and the
  content pipeline both serve files an author supplied.
- Referrer-Policy: strict-origin-when-cross-origin. Without it the full URL,
  window and request identifiers included, travels to any third-party origin in
  the Referer header. Same-origin navigation is unaffected.
- Permissions-Policy denying camera, microphone, geolocation and the rest. The
  service asks for none of them, so an injected frame or script cannot ask on
  its behalf either.
- The antiforgery cookie now follows the same environment-dependent Secure
  policy the session cookie already had; it had been left on the default.
  SameAsRequest in development keeps local HTTP working, where Always would
  have the browser drop the cookie and fail every form POST.
- TRACE is refused with 405 before routing. Cross-site tracing is already closed
  by HttpOnly cookies and modern browsers, so this removes a surface rather than
  fixing an exploit.
- Kestrel no longer advertises itself. The banner carries no version, but naming
  the stack tells a scanner which exploits are worth trying and buys nothing.

The headers moved into one middleware. They only do anything if they are on
every response, and spread across controllers a new endpoint silently misses
them. The content-security policy moved there unchanged and is pinned by a test
so folding it in cannot quietly drop it.

Headers are assigned rather than appended: appending to one something upstream
already set produces two of it, and a browser given two conflicting security
headers may pick the one we did not want.

Left alone deliberately: 'unsafe-inline' and 'unsafe-eval' in the policy's
script-src. Removing them means threading a per-request nonce through every
inline script and style, including those the frontend toolkit and the analytics
tags emit — real regression risk that deserves its own change and its own
testing rather than riding along with a header sweep.

Refs #347
Setting AntiforgeryOptions.Cookie.SecurePolicy to Always outside development
looked like the obvious counterpart to the session cookie, which already does
exactly that. It is not. The session cookie system only marks the cookie;
DefaultAntiforgery.CheckSSLConfig throws when the policy is Always and the
request is not HTTPS, and _Layout mints a token on every page render. Every
page therefore returned 500.

It passed locally because development resolves to SameAsRequest and never
exercises the branch, and it passed a manual HTTPS check because Kestrel was
terminating TLS itself there so IsHttps was true. It failed in the review app,
where the pod sits behind a TLS-terminating ingress and receives plain HTTP.
Reproduced by running the container with ASPNETCORE_ENVIRONMENT=Review over
HTTP: 500 on every page before this change, 200 after, with the same antiforgery
exception in the log.

UseForwardedHeaders is already wired with XForwardedProto, which would make
Request.IsHttps true and the policy safe, but KnownProxies.Clear() leaves
KnownNetworks at its loopback default, so the ingress's header is dropped and
the app believes it is serving plain HTTP in every deployed environment.
Correcting that means deciding which proxies to trust — trusting
X-Forwarded-Proto from anywhere lets a client claim HTTPS — and that is a
security decision in its own right, not something to settle inside a header
change.

So the cookie keeps its default here and the reason is recorded where the next
person will look for it. The other five items in this change are unaffected:
verified in the same Review-over-HTTP container that all four headers are still
applied, TRACE is still refused, and the server banner is still suppressed.

Refs #347
# Conflicts:
#	src/DfE.CheckPerformanceData.Web/Startup/CoreWebExtensions.cs
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review app for PR 351 was deleted

@dfe-lance
dfe-lance merged commit 1df7438 into main Aug 26, 2026
21 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working deploy

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants