Skip to content

test(api): verify HSTS and HTTPS redirection over real TLS, or record what is unverifiable (follow-up to #340) #344

Closed

Description

@mforce

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) — whether Strict-Transport-Security is actually emitted depends on ExcludedHosts (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 from IServerAddressesFeature / the https_port setting. 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:

  1. 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 as TestJwtKeys), an HttpClientHandler that trusts exactly that cert, and a hand-fixed base address; plus HstsOptions.ExcludedHosts.Clear() in the test factory so loopback isn't skipped. Then assert the header is present on HTTPS, absent on plain HTTP, and that UseHttpsRedirection issues a 307 to the actual bound HTTPS port.
  2. Or decide the residual risk is acceptable, and instead record explicitly in SecurityHeadersTests which 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

  1. added this to the Phase 1.5 — Hardening milestone on Aug 1, 2026
  2. added
    enhancementNew feature or request
    area:apiAPI/endpoint layer
    sliceThin vertical work item
    and removed on Aug 1, 2026
  3. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    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:

    Request Strict-Transport-Security
    GET /login, correct Host, no proto hint absent
    GET /login, correct Host, X-Forwarded-Proto: https present — max-age=31536000; includeSubDomains

    Both returned 200.

    What that settles

    app.UseHsts() is live in production, not inert:

    • the header is emitted, with exactly the value in CluckworkEdgeSecurityServiceCollectionExtensions.cs:23-27;
    • it is gated on scheme — absent when the request does not look like HTTPS, present when it does;
    • the forwarded-headers path feeding that decision works end to end against a real connection.

    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 the ExcludedHosts behaviour, 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, and SecurityHeadersTests still goes green. The gap described at SecurityHeadersTests.cs:139 is unchanged.

    UseHttpsRedirection was 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, so Program.cs:191 never 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-Security of its own — the header reaching clients is the one produced by app.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:

    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 Host header 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:139 rather than left as the current comment.

    UseHttpsRedirection is worth a separate decision. Where a terminating proxy answers plaintext itself, Program.cs:191 never 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.

  4. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    Reviewed in the 2026-09-13 issue cleanup. Decision: keep at priority:tier1 and 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; includeSubDomains it 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:tier1 issue 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: ConfigureKestrel with a runtime-generated self-signed certificate (never committed, same rule as TestJwtKeys), an HttpClientHandler trusting exactly that certificate, a hand-fixed base address, and HstsOptions.ExcludedHosts.Clear() in the test factory so loopback is not skipped. Assert the header present on HTTPS, absent on plain HTTP, and that UseHttpsRedirection issues 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#8 and coupled to RateLimiting:TrustedProxies via the #260 boot guard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layerenhancementNew feature or requestpriority:tier1Gates first real userssliceThin vertical work item

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions