Skip to content

Commit 9dd7a5b

Browse files
docs(security): document forwarded-headers trusted-proxy config
Match starter-kit PR #1334 (honor X-Forwarded-* behind a reverse proxy, trust bound to TrustedProxyOptions): - changelog: real-client-IP fix + TrustedProxyOptions, action-required note. - cors-and-headers: new "Reverse proxy & forwarded headers" section, and UseForwardedHeaders added to the pipeline-order slice. - production-checklist: item 6 (HTTPS) now notes X-Forwarded-Proto requires TrustedProxyOptions, with the config snippet.
1 parent ff6ba88 commit 9dd7a5b

3 files changed

Lines changed: 45 additions & 9 deletions

File tree

src/content/docs/changelog/index.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Notable changes to the kit, newest first.
1313

1414
## 2026-07-13
1515

16+
- **The real client IP now reaches the pipeline behind a reverse proxy (security fix).** `UseHeroPlatform` never called `UseForwardedHeaders`, so behind an ingress (cloudflared → Caddy → app) `Connection.RemoteIpAddress` was always the proxy's container IP. Two consequences: the IP-partitioned rate limiters (the anonymous `auth` policy and the global IP limiter) collapsed into a single shared bucket - one anonymous spike throttled every tenant's login, and per-origin brute-force protection was gone - and audit / `UserSession` IPs recorded the proxy for every request. The kit now honours `X-Forwarded-For` / `X-Forwarded-Proto`, applied first in the pipeline so rate limiting, auth, HTTPS redirect, and audit all see the real client. Trust is bound to a new **`TrustedProxyOptions`** section (`KnownProxies`, `KnownNetworks` CIDRs, `ForwardLimit`): forwarded headers are honoured **only** from the configured ingress, so a client reaching the app directly can't forge its IP or scheme. **Action required behind a proxy:** set `TrustedProxyOptions` to your ingress CIDR(s) and hop count - with nothing configured the framework default (loopback only) stands and forwarded headers are ignored. See [CORS & security headers](/docs/security/cors-and-headers/) and the [production checklist](/docs/security/production-checklist/).
17+
1618
- **Dashboard: tenants can now edit their own branding from Settings.** A new **Settings → Branding** tab lets a tenant admin holding `Tenants.UpdateTheme` customise their **light and dark palettes** and **brand asset URLs** (logo, dark-mode logo, favicon) with a live preview - mirroring the operator's existing tenant-branding card, but self-service and with no `tenant:` header, since the theme endpoints are already scoped to the current tenant. The tab renders only for holders of that permission; a direct-URL visit without it hits the API's `403`, surfaced as an error band. Editing is draft-based - a **Reset to defaults** action and per-palette reset are available, and unsaved edits are preserved while you work (a co-admin's concurrent change appears on a manual refresh rather than overwriting your form).
1719

1820
- **The `fsh` CLI and `dotnet new` template are now on NuGet as stable `10.0.0`.** The two distribution packages that 10.0.0 had been waiting on have shipped: `FullStackHero.CLI` (install with `dotnet tool install -g FullStackHero.CLI` - no more `--prerelease`) and `FullStackHero.NET.StarterKit` (`dotnet new install FullStackHero.NET.StarterKit`). Because `fsh new` scaffolds *from* that template, the one-command flow is now end-to-end: `dotnet tool install -g FullStackHero.CLI && fsh new MyApp` produces a fully renamed project - unique JWT signing key, generated Docker secrets, `npm install` run, initial commit on `main`. The [Install](/docs/getting-started/install/) and [CLI](/docs/cli/) pages now lead with the CLI as the recommended path; `git clone` and the GitHub template remain available for reading the source or zero-install runs. See the [10.0.0 release](https://github.com/fullstackhero/dotnet-starter-kit/releases/tag/10.0.0).

src/content/docs/security/cors-and-headers.mdx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
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.
55
sidebar:
66
label: CORS & headers
77
order: 7
@@ -47,13 +47,34 @@ Pipeline order (relevant slice):
4747

4848
```
4949
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. ...
5556
```
5657

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+
5778
## Why not AllowAnyOrigin for SignalR
5879

5980
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.

src/content/docs/security/production-checklist.mdx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Production security checklist
3-
lastUpdated: 2026-06-11
3+
lastUpdated: 2026-07-13
44
description: Ten configuration items you must check before shipping fullstackhero to production. Skip none.
55
sidebar:
66
label: Production checklist
@@ -114,9 +114,22 @@ Adjust `Auth` to your traffic profile: relax for high-volume consumer apps on sh
114114
`UseHttpsRedirection` is on by default. Verify:
115115

116116
- Reverse proxy / load balancer terminates TLS with a valid certificate.
117-
- `X-Forwarded-Proto: https` forwards to the kit so the redirect middleware doesn't double-redirect - and so the HSTS header (emitted only on HTTPS requests) actually fires.
117+
- `X-Forwarded-Proto: https` forwards to the kit so the redirect middleware doesn't double-redirect - and so the HSTS header (emitted only on HTTPS requests) actually fires. **This requires `TrustedProxyOptions` (below):** the kit only honours `X-Forwarded-Proto` / `X-Forwarded-For` from a configured trusted proxy, so with it unset the scheme stays `http` and the real client IP never reaches rate limiting or audit.
118118
- HTTP/2 or HTTP/3 enabled at the LB for performance.
119119

120+
Set `TrustedProxyOptions` to your ingress so forwarded headers are honoured - and only from your proxy, never a direct client (which could otherwise forge its IP/scheme):
121+
122+
```jsonc
123+
{
124+
"TrustedProxyOptions": {
125+
"KnownNetworks": [ "10.0.0.0/8" ], // your ingress CIDR(s), or KnownProxies for individual IPs
126+
"ForwardLimit": 2 // real hop count (cloudflared → Caddy → app = 2)
127+
}
128+
}
129+
```
130+
131+
Both lists ship empty (trust loopback only), so this is opt-in - see [CORS & security headers](/docs/security/cors-and-headers/#reverse-proxy--forwarded-headers).
132+
120133
If you're behind Cloudflare / a CDN, also enable "Always Use HTTPS" + "HSTS" at the CDN.
121134

122135
## 7. Lock down or remove debug endpoints

0 commit comments

Comments
 (0)