Repository navigation
Description
Follow-up to #340 / PR #341, which added the first Kestrel-backed test suite (KestrelBackedFactory, WebApplicationFactory.UseKestrel(0)).
The gap
SecurityHeadersTests.cs:139 records the compromise directly: HSTS's default ExcludedHosts skips loopback, and the TestServer "speaks" a scheme rather than having one. So the HSTS + UseHttpsRedirection assertions run against a synthetic transport:
app.UseHsts()(Program.cs:180) — whetherStrict-Transport-Securityis actually emitted depends onExcludedHosts(loopback excluded by default) and on the request being HTTPS. Neither is a real property of a TestServer request.app.UseHttpsRedirection()(Program.cs:191) — the redirect target port comes fromIServerAddressesFeature/ thehttps_portsetting. TestServer has no bound HTTPS address, so the port-resolution path that runs in production never runs in the test.
The #260 boot guard ties HSTS's correctness to RateLimiting:TrustedProxies (HSTS is inert behind a proxy the app doesn't trust, because the request never looks like HTTPS). That coupling is asserted at boot but never over a real connection — see the sibling issue on forwarded headers.
Why it is worth closing rather than accepting
HSTS is a one-way door for clients: once a browser caches max-age=31536000; includeSubDomains (CluckworkEdgeSecurityServiceCollectionExtensions.cs:23-27) it refuses plaintext for a year. Both failure directions matter and neither is currently observable at the transport:
- Not emitted when it should be → the header silently does nothing in production and nobody notices.
- Emitted on the wrong host/scheme → a year-long client-side commitment made by accident.
Scope
This one needs real HTTPS, which UseKestrel() does not wire up (dotnet/aspnetcore#63012 is open on that): it gives plain HTTP, and the client base address comes back as http://127.0.0.1:<port> — wrong scheme, and an IP that dotnet dev-certs does not cover.
So either:
builder.ConfigureKestrel(o => o.Listen(IPAddress.Loopback, 0, l => l.UseHttps(cert)))with a test-generated self-signed certificate (generated at runtime, never committed — same rule asTestJwtKeys), anHttpClientHandlerthat trusts exactly that cert, and a hand-fixed base address; plusHstsOptions.ExcludedHosts.Clear()in the test factory so loopback isn't skipped. Then assert the header is present on HTTPS, absent on plain HTTP, and thatUseHttpsRedirectionissues a 307 to the actual bound HTTPS port.- Or decide the residual risk is acceptable, and instead record explicitly in
SecurityHeadersTestswhich properties are unverifiable in-process, so the next reader doesn't mistake a green suite for transport-level proof.
Option 1 is the real close; option 2 is honest if the certificate machinery is judged not worth it. Worth a decision either way rather than leaving the current comment as the only record.
Activity
mforce commentedon Sep 13, 2026
Some transport-level evidence on the HSTS half, from a deployed instance running behind a reverse proxy. It does not close this, but it narrows it.
What was measured
Requests sent directly to the app process, bypassing the proxy, so that only the app's own middleware could be responsible for the response:
Strict-Transport-SecurityGET /login, correctHost, no proto hintGET /login, correctHost,X-Forwarded-Proto: httpsmax-age=31536000; includeSubDomainsBoth returned
200.What that settles
app.UseHsts()is live in production, not inert:CluckworkEdgeSecurityServiceCollectionExtensions.cs:23-27;That last point is the one worth recording, because it is the coupling #260's boot guard asserts and this issue notes has never been observed over a real connection: HSTS being inert behind an untrusted proxy. It is not inert here. The guard's premise holds in at least one real deployment.
What it does not settle
The wrong-host direction is untested. These probes sent the correct
Host. The failure mode this issue names second — the header emitted for a host that should not receive a year-long client commitment — is exactly theExcludedHostsbehaviour, and nothing above touches it. That is the half where the one-way-door argument bites.This is an observation, not a test. It proves the behaviour today; it does nothing about the regression this issue exists to prevent. Change
ExcludedHosts, or the forwarded-headers configuration, andSecurityHeadersTestsstill goes green. The gap described atSecurityHeadersTests.cs:139is unchanged.UseHttpsRedirectionwas not exercised, and may not be reachable in a proxied deployment. Where the reverse proxy answers plaintext requests itself, the app never sees an HTTP request to redirect, soProgram.cs:191never runs in production. Worth deciding deliberately: if that middleware is unreachable wherever the app is deployed behind a terminating proxy, then testing its port-resolution path is lower value than this issue currently implies, and saying so is better than leaving it as untested-and-assumed-important.Why this raises the stakes rather than lowering them
The obvious reading of the evidence above is reassuring: it works, so the risk is smaller. The opposite is true, and it took checking to find out.
The deployment this was measured on has no second emitter. The edge in front of the app does not add
Strict-Transport-Securityof its own — the header reaching clients is the one produced byapp.UseHsts(), forwarded unchanged. So the year-long client commitment rests entirely on this middleware and on the forwarded-headers path that decides whether a request counts as HTTPS.That makes both failure directions in the original description live rather than theoretical:
ExcludedHosts, the forwarded-headers configuration, or the trusted-proxy list removes HSTS completely, with nothing behind it and no signal that anything changed;I had assumed an edge-level fallback existed before checking. It did not. Worth stating plainly because anyone reading a green production response would make the same assumption.
Suggestion
Option 1 for the wrong-host case specifically. That assertion needs a
Hostheader rather than a certificate, so it avoids the self-signed-certificate machinery entirely, and it covers the direction with the irreversible consequence.Option 2 for what genuinely cannot be reached in-process, recorded explicitly at
SecurityHeadersTests.cs:139rather than left as the current comment.UseHttpsRedirectionis worth a separate decision. Where a terminating proxy answers plaintext itself,Program.cs:191never runs, so its port-resolution path may be untestable and unreachable. If that holds wherever this is deployed, descoping it from here is more honest than carrying it as an untested requirement.Happy to re-run the probes against any specific header or host combination if that would help the decision.
mforce commentedon Sep 13, 2026
Reviewed in the 2026-09-13 issue cleanup. Decision: keep at
priority:tier1and build the real-TLS harness — the escape hatch this issue's own title allows ("or record what is unverifiable") is explicitly not being taken.Rationale: HSTS is a one-way door for clients. Once a browser caches
max-age=31536000; includeSubDomainsit refuses plaintext for a year, and both failure directions — not emitted when it should be, emitted on the wrong host or scheme — are currently unobservable at the transport. Writing that down is not the same as knowing which way it fails.This is now the only open
priority:tier1issue in the repo. #244 closed (audit complete, findings dispositioned), #268 deferred to #793, #272 closed as an accepted risk. So this is the next production-readiness build.Scope is unchanged from the body above — option 1:
ConfigureKestrelwith a runtime-generated self-signed certificate (never committed, same rule asTestJwtKeys), anHttpClientHandlertrusting exactly that certificate, a hand-fixed base address, andHstsOptions.ExcludedHosts.Clear()in the test factory so loopback is not skipped. Assert the header present on HTTPS, absent on plain HTTP, and thatUseHttpsRedirectionissues the 307.Note the boundary while building it: this verifies the application emits the header correctly. Whether the production edge terminates TLS and forwards a scheme the app trusts is a deploy concern, tracked in
cluckwork-deploy#8and coupled toRateLimiting:TrustedProxiesvia the #260 boot guard.