Skip to content
fullstackhero

Concept

CORS & security headers

CORS-before-HTTPS-redirect ordering, the SignalR-credentialed-CORS gotcha, and the production security headers the kit emits by default.

views 0 Last updated

CORS in fullstackhero has two non-default conventions: CORS middleware runs before HTTPS redirect (preflight requests can’t follow redirects), and credentialed CORS uses SetIsOriginAllowed rather than AllowAnyOrigin (the latter silently breaks SignalR). Security headers (CSP, HSTS, X-Frame-Options, Referrer-Policy) ship with sensible defaults and a few configurable knobs.

How the kit wires CORS

AddHeroCors binds CorsOptions from configuration and registers a single global policy (FSHCorsPolicy):

{
"CorsOptions": {
"AllowAll": false,
"AllowedOrigins": [
"https://app.example.com",
"https://admin.example.com"
],
"AllowedHeaders": [ "content-type", "authorization" ],
"AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ]
}
}
  • AllowAll: trueSetIsOriginAllowed(_ => true) + any header + any method + AllowCredentials(). Dev only.
  • AllowAll: falseWithOrigins/WithHeaders/WithMethods from the three lists + AllowCredentials(). Startup validation fails if any of the three lists is empty while AllowAll is false.
  • Credentials are always allowed by the policy — there is no AllowCredentials config key.
  • If AllowAll is false and AllowedOrigins is empty, CORS isn’t mounted at all — cross-origin browser calls will simply fail. (appsettings.Production.json ships with an empty list precisely so you have to fill it in.)

UseHeroPlatform mounts the middleware before HTTPS redirect, because OPTIONS preflight requests cannot follow HTTP→HTTPS redirects per the Fetch spec — the redirect breaks the preflight and the actual request never goes out.

Pipeline order (relevant slice):

1. UseExceptionHandler
2. UseResponseCompression
3. UseCors ← before HTTPS redirect
4. UseHttpsRedirection
5. Security headers
6. ...

Why not AllowAnyOrigin for SignalR

CORS spec says: when a response has Access-Control-Allow-Credentials: true, the Access-Control-Allow-Origin must be an explicit origin, not *. SignalR’s negotiate request is credentialed (it carries Cookie or the JWT via accessTokenFactory’s query-param fallback). With AllowAnyOrigin(), the server emits Allow-Origin: *, which violates the spec — the browser silently refuses to use the response, and SignalR’s HubConnection fails to start with a confusing CORS error.

SetIsOriginAllowed(origin => true) echoes the actual origin back in Allow-Origin, satisfying the spec, while accepting every origin in practice (which is what dev wants).

Security headers

SecurityHeadersMiddleware emits the following on every response (except excluded paths):

HeaderValuePurpose
X-Content-Type-OptionsnosniffPrevent MIME-type sniffing
X-Frame-OptionsDENYPrevent clickjacking via iframe
Referrer-Policystrict-origin-when-cross-originLimit referrer leakage
X-XSS-Protection0Explicitly disable the legacy XSS auditor (modern guidance — CSP does this job)
Strict-Transport-Securitymax-age=31536000; includeSubDomains (HTTPS requests only)Enforce HTTPS
Content-Security-Policycomposed default, see belowRestrict what scripts, frames, images can load

The CSP default (set only if nothing upstream already set one):

default-src 'self'; img-src 'self' data: https:;
script-src 'self' https: {ScriptSources};
style-src 'self' 'unsafe-inline' {StyleSources};
object-src 'none'; frame-ancestors 'none'; base-uri 'self';

Tune via SecurityHeadersOptions — the CSP isn’t free-form config; you get these knobs:

{
"SecurityHeadersOptions": {
"Enabled": true,
"ExcludedPaths": [ "/scalar", "/openapi" ], // docs UI manages its own scripts/styles
"AllowInlineStyles": true, // drops 'unsafe-inline' from style-src when false
"ScriptSources": [], // extra origins appended to script-src
"StyleSources": [] // extra origins appended to style-src
}
}

If you embed third-party scripts (analytics, customer-support widgets), add the origin to ScriptSources. For a different policy shape entirely (e.g. a connect-src restriction), set the Content-Security-Policy header yourself earlier in the pipeline — the middleware respects an existing header. Test with browser dev tools open — CSP violations log to the console.

Sub-resource integrity

CSP doesn’t enforce that third-party scripts haven’t been tampered with. For the few external scripts you’d ship (analytics, payment SDK), add integrity attributes:

<script src="https://cdn.example.com/widget.js"
integrity="sha384-XYZ..."
crossorigin="anonymous"></script>

The browser verifies the hash before executing.

The kit defaults to JWT bearer (cookies not used for auth). If you fork to cookie auth, the standard hardening applies:

services.ConfigureApplicationCookie(o =>
{
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = SameSiteMode.Strict; // or Lax for cross-site form-post flows
o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
o.Cookie.Name = "fsh.auth";
});

The cookie should be HttpOnly (no JS access — limits XSS impact), Secure (HTTPS only), and SameSite=Strict for the strongest CSRF defence.

Common mistakes

  • Setting AllowAll = true in production. CORS exists to give browsers a sanity check on cross-origin calls. Opening to the world removes the check (it doesn’t directly compromise auth — auth still gates the request — but it removes the browser-enforced “is this site allowed to call you?” layer).
  • Forgetting to fill AllowedOrigins in production. With AllowAll: false and no origins, CORS isn’t mounted — your React apps on other origins will get blocked by the browser. The symptom is “works in Postman, fails in the browser”.
  • Missing HSTS. Without HSTS, an attacker on the network can downgrade to HTTP for the first request. The kit emits it on HTTPS responses automatically; verify your proxy doesn’t strip it.
  • CSP that breaks the UI. If a third-party widget breaks after tightening CSP, look at the browser console — CSP violations are logged. Add the needed origins to ScriptSources/StyleSources, don’t disable the middleware.