diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index 356edfea..fa0a5882 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -13,6 +13,7 @@ Notable changes to the kit, newest first. ## 2026-09-25 +- **Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request (breaking for Production config).** **Upgrade note - set `FrontendOptions:DefaultOrigin` before you upgrade, or Production won't boot.** In `Production` the API now refuses to start when `FrontendOptions:DefaultOrigin` is missing or not an absolute `http(s)` URL (`Missing required configuration 'FrontendOptions:DefaultOrigin' in Production…`), alongside the existing fail-fast on `DatabaseOptions:ConnectionString`, `CachingOptions:Redis` and `JwtOptions:SigningKey`. Point it at your tenant dashboard. The shipped deployment paths set it for you: `deploy/docker/docker-compose.yml` derives it from `FSH_DASHBOARD_URL`, and the AWS Terraform stack from `dashboard_url` (falling back to `admin_url`) - so the action is for deployments that roll their own hosting. The DbMigrator is unaffected. The reset link was built from a single configured `OriginOptions.OriginUrl` - which points at the API and ships empty in production, so `forgot-password` threw `Origin URL is not configured` - and the confirmation link was built from the request host and pointed straight at the API's `GET /confirm-email` route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). Link resolution now goes through a dedicated **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`), kept separate from CORS. **Self-service** flows (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` and returned as the canonical entry - so each user gets a link back to the app they started from; because forgot-password is anonymous a forged or unlisted `Origin` is rejected with **`400`** once the list is non-empty, and a request with no `Origin` (curl, mobile, server-to-server) falls back to `DefaultOrigin` - as does every request while the list is empty, since there is then nothing to validate against. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` - the recipient's app - so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation e-mail now lands on the SPA `/confirm-email` page (which then calls the API) instead of the raw API route. Also list every SPA origin in `FrontendOptions:AllowedOrigins`; both settings ship empty in `appsettings.Production.json`, and the shipped deploys fill the list from the same SPA URLs (`FSH_ADMIN_URL` / `FSH_DASHBOARD_URL`, or the Terraform site URLs - `api_extra_cors_origins` deliberately stays off it). Outside Production the host still boots without `DefaultOrigin` and logs one startup `Error`, but link building has no fallback, so those flows answer `500` until it is set. The two fallback tiers an earlier revision carried were removed on review - the request host is caller-supplied, so a password-reset link built from it delivers a working token to a domain the attacker named, and the API's own origin returns `404` for the SPA pages these links now target. `CorsOptions:AllowedOrigins` and `OriginOptions:OriginUrl` keep their own roles (browser CORS; the API's public base for avatar URLs). See [#1377](https://github.com/fullstackhero/dotnet-starter-kit/pull/1377). - **Object storage: MinIO replaced with RustFS for local dev, Docker Compose, and integration tests (breaking for docker-compose).** The `minio/minio` and `minio/mc` images were removed from Docker Hub and `quay.io/minio` now refuses anonymous pulls, so fresh clones could no longer bring up the stack. The kit now ships [RustFS](https://rustfs.com) (`rustfs/rustfs:1.0.0`, S3-compatible, Apache-2.0) on the same ports - **9000** (S3 API) and **9001** (web console) - with bucket bootstrap done by a pinned `amazon/aws-cli:2.37.3` init container. Nothing changes in application code: the API still talks to it through the `s3` storage provider with `ForcePathStyle`, and production can keep pointing at AWS S3 or any other S3-compatible store. See PR [#1390](https://github.com/fullstackhero/dotnet-starter-kit/pull/1390). - **Aspire:** the `minio` / `minio-init` resources are now `rustfs` / `rustfs-init`, and the AppHost parameters are renamed `minio-user` / `minio-password` → `rustfs-user` / `rustfs-password` (default `rustfsadmin`). If you set the old parameters in user secrets or config, rename them. The data volume is now `{appPrefix}-rustfs-data`, so local uploads start empty; delete the old `*-minio-data` volume when you no longer need it. - **Breaking (docker-compose):** in `deploy/docker/.env`, rename `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` → `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` (compose refuses to start without them). The services are now `rustfs` / `rustfs-init` and the volume `minio_data` → `rustfs_data`, so existing objects are **not** carried over: before upgrading, copy them out of the old MinIO bucket and into the new `fsh` bucket with `aws s3 sync` (against each `--endpoint-url`), or have users re-upload. `fsh new` now generates `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` for new projects. diff --git a/src/content/docs/deployment/aws-terraform.mdx b/src/content/docs/deployment/aws-terraform.mdx index 0f201196..842fd1f7 100644 --- a/src/content/docs/deployment/aws-terraform.mdx +++ b/src/content/docs/deployment/aws-terraform.mdx @@ -1,6 +1,6 @@ --- title: Deploy to AWS with Terraform -lastUpdated: 2026-06-06 +lastUpdated: 2026-09-25 description: End-to-end AWS deployment - prerequisites, bootstrapping the state backend, configuring an environment, and the one-command deploy that ships the API and both React apps. sidebar: label: AWS (Terraform) @@ -223,7 +223,14 @@ terraform output admin_site ``` Open the CloudFront `url` from each site output to reach the apps. The API's -CORS allow-list is wired automatically to include both SPA origins. +CORS allow-list is wired automatically to include both SPA origins, and so is +`FrontendOptions` - the separate allow-list that decides which origin a +password-reset or e-mail-confirmation link may point at, with the dashboard as +`DefaultOrigin` (falling back to the admin URL). Extra origins passed through +`api_extra_cors_origins` land on the CORS list only - never on `FrontendOptions`, +so a CORS grant can't become a grant to receive credential links. The API +refuses to start in Production without `FrontendOptions:DefaultOrigin`, so if +the stack hosts neither SPA, pass it yourself via `api_extra_environment_variables`. ## Layout reference diff --git a/src/content/docs/modules/identity.mdx b/src/content/docs/modules/identity.mdx index fef3d19d..008bbced 100644 --- a/src/content/docs/modules/identity.mdx +++ b/src/content/docs/modules/identity.mdx @@ -1,6 +1,6 @@ --- title: Identity module -lastUpdated: 2026-06-11 +lastUpdated: 2026-09-25 description: JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement. sidebar: label: Identity @@ -149,6 +149,10 @@ endpoints.MapPost("/users", handler) All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table: + +Auth e-mail links resolve through `FrontendOptions` (a dedicated config, separate from CORS). **Self-service** flows (`forgot-password`, `self-register`) link back to the front-end that made the request - the base URL comes from the request `Origin` header, validated against `FrontendOptions:AllowedOrigins` - so with more than one SPA each user gets a link to the app they started from; a forged or unlisted origin is rejected with `400`, and a request with no `Origin` (non-browser callers) falls back to `FrontendOptions:DefaultOrigin` - as does every request when the allowlist is empty, since there is then nothing to validate against. **Operator-driven** flows (`register`, `resend-confirmation-email`) target `DefaultOrigin` (the recipient's app), so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation link lands on the SPA `/confirm-email` page (which then calls `GET /confirm-email`), not the API route directly. `DefaultOrigin` is required: link building has no fallback, so in `Production` the API **refuses to start** until it is set to an absolute `http(s)` URL. Outside Production the host still boots but logs a startup `Error`, and the four flows that build a user-facing link - confirm-email, resend-confirmation, forgot-password, reset-password - answer `500` until you configure it. That is deliberate: the request host is whatever the caller puts in the `Host` header, so a password-reset link derived from it delivers a working token to an attacker-chosen domain, and the API's own origin returns `404` for the SPA pages these links now target. See [CORS & headers](/docs/security/cors-and-headers/). + + | Verb | Route | What it does | |---|---|---| | POST | `/token/issue` | Login | diff --git a/src/content/docs/security/cors-and-headers.mdx b/src/content/docs/security/cors-and-headers.mdx index a96b6849..60dfd2da 100644 --- a/src/content/docs/security/cors-and-headers.mdx +++ b/src/content/docs/security/cors-and-headers.mdx @@ -56,6 +56,25 @@ Pipeline order (relevant slice): 7. ... ``` +## Front-end origin for auth e-mail links + +Links that land on a front-end SPA - the password-reset and e-mail-confirmation e-mails - are **not** built from the CORS list. They resolve through a dedicated `FrontendOptions`, kept separate from CORS on purpose: the CORS allowlist governs which browsers may *call* the API, while this list governs which origins may appear *inside an outbound link*. The two often overlap but carry different duties, and coupling them breaks same-origin / reverse-proxy topologies (SPA + API on one domain need no CORS entries, yet the browser still sends `Origin` on the POST). + +```jsonc + "FrontendOptions": { + "AllowedOrigins": [ "http://localhost:5173", "http://localhost:5174" ], + "DefaultOrigin": "http://localhost:5174" // the tenant SPA + } +``` + +- **Self-service flows** (`forgot-password`, `self-register`) build the link from the request `Origin` header, validated against `AllowedOrigins` and returned as the canonical list entry - so with more than one SPA each user gets a link back to the app they started from. Because forgot-password is anonymous this is a security boundary: once the list is non-empty, a **forged or unlisted `Origin` is rejected with `400`** rather than turned into a link. A request with **no** `Origin` header (curl, mobile, server-to-server) falls back to `DefaultOrigin` instead of failing. The Scalar try-it UI is not in that group - it fetches from the browser, so it sends the API's own origin; add that origin to `AllowedOrigins` if you want to exercise these two endpoints from the docs UI. +- With `AllowedOrigins` **empty** there is nothing to validate against, so the header is discarded and the link uses `DefaultOrigin`. That keeps the single-SPA and reverse-proxy setups working on `DefaultOrigin` alone - browsers attach `Origin` to these POSTs even same-origin, so matching an empty list would otherwise reject every legitimate reset. The client's value is never echoed either way. List your origins as soon as you serve more than one front-end, or every user lands on the same app. +- **Operator-driven flows** (`register`, `resend-confirmation-email`) target `DefaultOrigin` - the recipient's app - not the calling operator's origin, so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. +- Matching is component-wise (scheme + host + port, port exact). `appsettings.Production.json` ships both settings empty. **In `Production` the API refuses to start** until `DefaultOrigin` is set to an absolute `http(s)` URL (`Missing required configuration 'FrontendOptions:DefaultOrigin' in Production…`), the same fail-fast it applies to the database connection string, the Redis connection and the JWT signing key. Outside Production the host still boots and logs one startup `Error` naming `FrontendOptions:DefaultOrigin`; link resolution has no fallback - it returns `DefaultOrigin` or throws - so confirm-email, resend-confirmation, forgot-password and reset-password answer `500` until it is set. (The DbMigrator never sends links and is unaffected.) Failing loudly is the deliberate choice: the request host is whatever the caller puts in the `Host` header, so a password-reset link derived from it delivers a working token to a domain the attacker picked, and the API's own origin returns `404` for the SPA pages these links target. Configure both (see the [production checklist](/docs/security/production-checklist/)) before you deploy. +- `DefaultOrigin` is a **single global**, not per-tenant or custom-domain aware, so operator-driven `register` / `resend-confirmation-email` point every tenant's link at that one SPA. That fits the kit's single-dashboard model; a deployment with per-tenant custom domains would need to resolve the recipient tenant's own origin instead. + +(`OriginOptions:OriginUrl` plays no part in building these links. Its role is the API's own public base for back-end-served assets such as avatar URLs, exposed via `IRequestContext.Origin`.) + ## Reverse proxy & forwarded headers 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. @@ -73,7 +92,7 @@ Blindly trusting `X-Forwarded-For` is itself a hole - any client that can reach ``` - 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. -- Only `X-Forwarded-For` and `X-Forwarded-Proto` are in the flag list. `X-Forwarded-Host` is deliberately left out: rewriting `Request.Host` from a header is a host-header injection primitive, and the registration / confirmation e-mail endpoints build their links straight from the request, so they would mail links pointing wherever the header said. The trade-off is that `Request.Host` keeps the internal host behind a proxy, and those links carry it. +- Only `X-Forwarded-For` and `X-Forwarded-Proto` are in the flag list. `X-Forwarded-Host` is deliberately left out: rewriting `Request.Host` from a header is a host-header injection primitive for anything that builds an absolute URL from the request. The trade-off is that `Request.Host` keeps the internal host behind a proxy. Auth e-mail links are not affected either way: they resolve through `FrontendOptions` (above), never the request host. - `ForwardLimit` must match the real number of proxy hops, and must be at least `1`. 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. Anything below `1` is rejected at startup for the same reason a malformed proxy entry is: `0` would leave forwarded headers unprocessed with no error at all, and a negative value would fail every request, including requests that carry no forwarded headers. - **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. - A malformed entry fails the host build with a message naming the offending setting and value (for example ``TrustedProxyOptions:KnownNetworks contains "10.0.0.0/999", which is not a valid CIDR network``), rather than an opaque parse error. A typo can't silently degrade into "trust nobody". diff --git a/src/content/docs/security/production-checklist.mdx b/src/content/docs/security/production-checklist.mdx index 59ec19b1..1e749cbd 100644 --- a/src/content/docs/security/production-checklist.mdx +++ b/src/content/docs/security/production-checklist.mdx @@ -59,6 +59,8 @@ Adjust for your industry. Healthcare (HIPAA) and finance (PCI-DSS) tend to requi `CorsOptions:AllowAll = true` (and the `SetIsOriginAllowed(_ => true)` policy it enables) is **dev only**. Production needs the explicit lists - and note that `appsettings.Production.json` ships `AllowedOrigins` empty, which means **no CORS middleware mounts at all** until you fill it in; your front-ends on other origins will be blocked by the browser. See [CORS & security headers](/docs/security/cors-and-headers/). +Separately, set **`FrontendOptions`** (`AllowedOrigins` + `DefaultOrigin`) - the allowlist and default origin the Identity module uses to build password-reset and e-mail-confirmation links. Both ship empty in production too. **`DefaultOrigin` is required: in `Production` the API refuses to start without it** (or with anything but an absolute `http(s)` URL), failing with `Missing required configuration 'FrontendOptions:DefaultOrigin' in Production…` - the same fail-fast the host applies to `DatabaseOptions:ConnectionString`, `CachingOptions:Redis` and `JwtOptions:SigningKey`. Link building has no fallback: the two candidates an earlier revision fell back to are both unacceptable - the request host is caller-supplied, so a reset link built from it hands a live token to an attacker-chosen domain, and the API's own origin returns `404` for the SPA pages these links target. `DefaultOrigin` is the tenant SPA: it's the fallback for non-browser callers and the target for operator-driven register/resend links. The two shipped deployment paths fill this in for you - `deploy/docker/docker-compose.yml` from `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` (`DefaultOrigin` = `FSH_DASHBOARD_URL`), and the AWS Terraform stack from `dashboard_url` / `admin_url` - so this item is about deployments that roll their own hosting. + ```jsonc { "CorsOptions": {