|
1 | 1 | --- |
2 | 2 | title: CORS & security headers |
3 | | -lastUpdated: 2026-06-11 |
4 | | -description: CORS-before-HTTPS-redirect ordering, the SignalR-credentialed-CORS gotcha, and the production security headers the kit emits by default. |
| 3 | +lastUpdated: 2026-07-13 |
| 4 | +description: CORS-before-HTTPS-redirect ordering, the SignalR-credentialed-CORS gotcha, forwarded-headers trusted-proxy config, and the production security headers the kit emits by default. |
5 | 5 | sidebar: |
6 | 6 | label: CORS & headers |
7 | 7 | order: 7 |
@@ -47,13 +47,34 @@ Pipeline order (relevant slice): |
47 | 47 |
|
48 | 48 | ``` |
49 | 49 | 1. UseExceptionHandler |
50 | | -2. UseResponseCompression |
51 | | -3. UseCors ← before HTTPS redirect |
52 | | -4. UseHttpsRedirection |
53 | | -5. Security headers |
54 | | -6. ... |
| 50 | +2. UseForwardedHeaders ← before anything reads the client IP or scheme |
| 51 | +3. UseResponseCompression |
| 52 | +4. UseCors ← before HTTPS redirect |
| 53 | +5. UseHttpsRedirection |
| 54 | +6. Security headers |
| 55 | +7. ... |
55 | 56 | ``` |
56 | 57 |
|
| 58 | +## Reverse proxy & forwarded headers |
| 59 | + |
| 60 | +The kit runs behind a reverse proxy in production (Cloudflare / cloudflared → Caddy / Nginx → app). Without `UseForwardedHeaders`, `Connection.RemoteIpAddress` is the proxy's IP and `Request.Scheme` is the internal `http`, which breaks two things: the **IP-partitioned rate limiters** (`auth` policy + global IP limiter) collapse into one shared bucket - losing per-origin brute-force protection - and audit / `UserSession` records log the proxy IP for every request. `UseHeroPlatform` mounts `UseForwardedHeaders` **first** (right after the exception handler), so `X-Forwarded-For` / `X-Forwarded-Proto` are applied before rate limiting, auth, HTTPS redirect, and audit read the client. |
| 61 | + |
| 62 | +Blindly trusting `X-Forwarded-For` is itself a hole - any client that can reach the app could forge its own IP, poisoning audit trails and evading the rate limiter. So trust is bound to the ingress you actually run, via `TrustedProxyOptions`: |
| 63 | + |
| 64 | +```jsonc |
| 65 | +{ |
| 66 | + "TrustedProxyOptions": { |
| 67 | + "KnownProxies": [ "10.0.0.5" ], // individual upstream proxy IPs |
| 68 | + "KnownNetworks": [ "10.0.0.0/8" ], // or trusted upstream CIDRs |
| 69 | + "ForwardLimit": 2 // ingress hop count (cloudflared → Caddy → app = 2) |
| 70 | + } |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +- Forwarded headers are honoured **only** when the immediate upstream is one of the configured proxies/networks; from any other source they're ignored and the connection IP/scheme stand. |
| 75 | +- `ForwardLimit` must match the real number of proxy hops. The framework default of `1` reads only the rightmost hop, which in a multi-hop ingress yields the nearest proxy's IP (or an attacker-injected value) instead of the real client. |
| 76 | +- **Secure by default:** with `KnownProxies` and `KnownNetworks` both empty (as `appsettings.json` / `appsettings.Production.json` ship them), the framework default - trust loopback only - stands, so forwarded headers from a real proxy are ignored until you configure the ingress. Set them as part of your deploy. |
| 77 | + |
57 | 78 | ## Why not AllowAnyOrigin for SignalR |
58 | 79 |
|
59 | 80 | 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. |
|
0 commit comments