Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
626eb18
fix(identity): resolve front-end origin per-request for auth e-mail l…
marcelo-maciel Jul 2, 2026
78edf16
test(identity): assert forgot-password rejects a forged Origin end-to…
marcelo-maciel Jul 2, 2026
ffb5aff
test(identity): assert e-mail links resolve to the requesting front-end
marcelo-maciel Jul 2, 2026
22862d4
test(identity): match confirmation/reset e-mail by subject; richer ti…
marcelo-maciel Jul 2, 2026
4cafc42
test(identity): drop e-mail-body integration test; rely on unit coverage
marcelo-maciel Jul 2, 2026
aa4037f
docs(identity): describe origin comments by intent, not the prior beh…
marcelo-maciel Jul 2, 2026
1b8c299
fix(identity): reword confirm-email comment to satisfy S125
marcelo-maciel Jul 2, 2026
0ee35bc
refactor(identity): dedicated FrontendOptions for e-mail link origins
marcelo-maciel Jul 4, 2026
112ae5f
refactor(web): address origin-resolver review nits (log level, docs, …
marcelo-maciel Jul 13, 2026
b635632
fix(web): require FrontendOptions:DefaultOrigin at startup
marcelo-maciel Jul 23, 2026
67e026f
fix(web): keep booting when FrontendOptions:DefaultOrigin is unset
marcelo-maciel Aug 10, 2026
7e0198c
fix(web): fall back to the request host when no origin is configured …
marcelo-maciel Aug 10, 2026
a48e957
fix(web): resolve links against the default when no allow-list is con…
marcelo-maciel Aug 10, 2026
9374510
fix(web): count the allow-list after normalization in the startup war…
marcelo-maciel Aug 10, 2026
dc767b5
docs(web): stop claiming the Scalar try-it UI sends no Origin header
marcelo-maciel Aug 10, 2026
0fdca92
docs(rules): document the front-end origin resolver in the security rule
marcelo-maciel Aug 10, 2026
124f182
docs(agents): list front-end link origins in the security rule index
marcelo-maciel Aug 10, 2026
0be4ede
fix(deploy): wire FrontendOptions into the docker and terraform deploys
marcelo-maciel Sep 14, 2026
78bc580
build(deps): bump Testcontainers to 4.14.0 and SourceLink past their …
marcelo-maciel Sep 14, 2026
84aae7a
fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub
marcelo-maciel Sep 14, 2026
e36d4a0
fix(infra): keep api_extra_cors_origins out of the e-mail-link allow-…
marcelo-maciel Sep 16, 2026
7231bee
fix(web): stop deriving e-mail-link origins from the request host
marcelo-maciel Sep 17, 2026
95b12f6
fix(config): keep the dev SPA origins out of Production
marcelo-maciel Sep 18, 2026
7bd6059
fix(infra): pull minio/mc from quay.io too, not just minio/minio
marcelo-maciel Sep 18, 2026
362e500
fix(frontend-origin): validate DefaultOrigin and correct three stale …
marcelo-maciel Sep 18, 2026
024cb07
fix(frontend-origin): treat an unusable DefaultOrigin as an unset one…
marcelo-maciel Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .agents/rules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ For auth/JWT/permissions see `modules/identity.md`; for the global exception han

Policy `FSHCorsPolicy`. When `CorsOptions.AllowAll=true` it uses **`SetIsOriginAllowed(_ => true).AllowAnyHeader().AllowAnyMethod().AllowCredentials()`** — deliberately **NOT `AllowAnyOrigin()`**. `Access-Control-Allow-Origin: *` is illegal with credentialed requests, and **SignalR's negotiate always runs credentialed**, so `AllowAnyOrigin()` silently breaks SignalR while REST keeps working. Never "simplify" it to `AllowAnyOrigin()`. `UseHeroCors()` runs **before** `UseHttpsRedirection()` so OPTIONS preflight isn't 307-redirected.

## Front-end origin for outbound links (`Web/Frontend/`)

`IFrontendOriginResolver` builds the origin of user-facing links sent in e-mails (password reset, e-mail confirmation). Backed by **`FrontendOptions`**, deliberately separate from `CorsOptions`: CORS governs which browsers may *call* the API, this governs which origins may appear *inside an outbound link*. Never merge the two lists — coupling them breaks same-origin/reverse-proxy topologies and overloads a security boundary.

- **`ResolveForCurrentRequest()`** — self-service flows only (forgot-password, self-register), where the caller *is* the recipient. Validates the request `Origin` against `FrontendOptions:AllowedOrigins` and returns the **canonical configured entry**, never the client's string. Present-but-unlisted against a non-empty list → `CustomException(HttpStatusCode.BadRequest)`: these endpoints are anonymous, so a forged header must never reach an e-mail. No `Origin` header, or an empty/all-unparseable list, falls through to the default.
- **`ResolveDefault()`** — operator-driven flows (admin register, resend-confirmation) and background jobs, where the caller is **not** the recipient. Chain: `FrontendOptions:DefaultOrigin` → throw. Never the caller's `Origin`, or an operator would send a tenant user a link into the admin console; and never the request's `Host` either, which is the same string under a different name. There is deliberately no fallback to `OriginOptions:OriginUrl`: these links address SPA routes (`/confirm-email`, `/reset-password`), so the API's own origin would 404 them.

Picking the wrong method is a silent bug — both compile and both return a plausible origin. Match the method to **who receives the link**, not to who sent the request.

No `ValidateOnStart` on `FrontendOptions`, on purpose: a deployment that never sends such a link must not be taken down by the setting. `UseHeroPlatform` logs at startup instead — `Warning` for an empty or all-typo allow-list (counted after normalization, so a list of nothing but typos reports as the empty list it effectively is), `Error` for a missing `DefaultOrigin`, since without it those flows return 500 rather than degrading. `OriginOptions:OriginUrl` keeps its own job — the API's own public base for back-end-served assets (avatars), read through `IRequestContext.Origin`.

## Security headers (`Web/Security/`)

`UseHeroSecurityHeaders()` sets `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, HSTS (HTTPS), and a CSP. `SecurityHeadersOptions.ExcludedPaths` defaults to `["/scalar","/openapi"]` (they manage their own scripts) — keep those excluded.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Single long-lived branch: **`main`** (the default) — there is **no `develop`**
| Background jobs (Hangfire), recurring jobs | `jobs.md` |
| Outbound HTTP resilience (Polly) | `resilience.md` |
| Files/blobs, presigned uploads, providers | `storage.md` |
| CORS, security headers, rate limiting, idempotency, quotas | `security.md` |
| CORS, security headers, rate limiting, idempotency, quotas, front-end link origins | `security.md` |
| SignalR / SSE backend | `realtime.md` |
| Logging, correlation, OpenTelemetry | `logging.md` |
| Unit test conventions, NetArchTest | `testing.md` |
Expand Down
9 changes: 5 additions & 4 deletions deploy/docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ This brings up the full stack on a single host:
| `admin` | `fsh/admin:local` | `FSH_ADMIN_PORT` (default 8081) | Operator console (nginx + React) |
| `dashboard` | `fsh/dashboard:local` | `FSH_DASHBOARD_PORT` (default 8082) | Tenant dashboard (nginx + React) |
| `migrator` | `fsh/dbmigrator:local` | — | One-shot: applies EF migrations + seeds the root tenant + creates the default admin user |
| `postgres` | `postgres:17-alpine` | (internal) | Identity, tenant catalog, module schemas |
| `redis` | `redis:7-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store |
| `minio` | `minio/minio:latest` | (internal) | S3-compatible blob store for the Files module |
| `postgres` | `postgres:18-alpine` | (internal) | Identity, tenant catalog, module schemas |
| `redis` | `valkey/valkey:9.1.0-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store |
| `minio` | `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` | (internal) | S3-compatible blob store for the Files module |

The compose file does **not** include a reverse proxy or TLS terminator. You bring your own edge — Cloudflare Tunnel, AWS ALB, Tailscale Funnel, your existing nginx, anything that can route a TLS subdomain to a host:port on this machine.

Expand Down Expand Up @@ -56,7 +56,7 @@ Point three TLS subdomains at the published ports:
| `admin.example.com` | `8081` |
| `app.example.com` | `8082` |

Make sure the URLs you serve match the `FSH_API_URL` / `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` you set in `.env` — those values are baked into the frontends' runtime `/config.json` (CORS will fail loudly otherwise).
Make sure the URLs you serve match the `FSH_API_URL` / `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` you set in `.env` — those values are baked into the frontends' runtime `/config.json` (CORS will fail loudly otherwise). They also drive the origins the API is allowed to put inside password-reset and e-mail-confirmation links, with `FSH_DASHBOARD_URL` as the default target for links the API sends on an operator's behalf.

## Sign in for the first time

Expand Down Expand Up @@ -111,4 +111,5 @@ The data-plane volumes (`pg_data`, `redis_data`, `minio_data`) can be deleted on
| Migrator exits non-zero with `Failed to fetch dynamically imported module` | A frontend bundle baked the wrong API URL. Check `FSH_API_URL` in `.env` and re-run with `--build`. |
| `OptionsValidationException: SigningKey looks like a sample placeholder` | `JWT_SIGNING_KEY` contains `replace-with` (the framework's placeholder detector). Generate a real key: `openssl rand -base64 48`. |
| API up but admin shows a CORS error | `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` in `.env` doesn't match what your external proxy serves. Both go on the CORS allow-list. |
| A reset or confirmation e-mail links to the API instead of the app | Same cause: `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` don't match the origins the browser actually uses. Both also feed `FrontendOptions__AllowedOrigins`, and `FSH_DASHBOARD_URL` feeds `FrontendOptions__DefaultOrigin`. |
| `migrator` retries Postgres for 2 minutes then fails | Postgres didn't come up — check `docker compose logs postgres`. Most often a `POSTGRES_PASSWORD` change against an existing `pg_data` volume; delete the volume with `docker compose down -v` (destructive) and start over. |
10 changes: 8 additions & 2 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ services:
# - "6379:6379"

minio:
image: minio/minio:latest
# quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest.
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: fsh-minio
restart: unless-stopped
command: ["server", "/data", "--console-address", ":9001"]
Expand All @@ -79,7 +80,8 @@ services:
# policy is set — objects are served via the API / presigned URLs, not a
# public bucket.
minio-init:
image: minio/mc:latest
# quay.io: minio/mc is gone from Docker Hub too. Tag pinned; quay stopped moving :latest.
image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z
container_name: fsh-minio-init
restart: "no"
depends_on:
Expand Down Expand Up @@ -142,6 +144,10 @@ services:
HangfireOptions__Password: ${HANGFIRE_PASSWORD:?HANGFIRE_PASSWORD is required}
CorsOptions__AllowedOrigins__0: ${FSH_ADMIN_URL:?FSH_ADMIN_URL is required}
CorsOptions__AllowedOrigins__1: ${FSH_DASHBOARD_URL:?FSH_DASHBOARD_URL is required}
# E-mail-link origins. Separate list from CORS: the API origin must stay off it.
FrontendOptions__AllowedOrigins__0: ${FSH_ADMIN_URL}
FrontendOptions__AllowedOrigins__1: ${FSH_DASHBOARD_URL}
FrontendOptions__DefaultOrigin: ${FSH_DASHBOARD_URL}
OpenTelemetryOptions__Exporter__Otlp__Endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
ports:
- "${FSH_API_PORT:-8080}:8080"
Expand Down
19 changes: 19 additions & 0 deletions deploy/terraform/apps/starter/app_stack/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,24 @@ locals {
for idx, origin in local.cors_allowed_origins :
"CorsOptions__AllowedOrigins__${idx}" => origin
}

# Origins allowed inside e-mail links. Not the CORS list: the API domain must stay off it, and
# api_extra_cors_origins must stay out of it. That variable grants permission to CALL the API;
# copying it here would also let those origins receive a password-reset or confirmation URL with
# the token in it, turning a CORS grant into a credential-link grant. An origin that must appear
# in an e-mail link belongs to a SPA this stack hosts, so it arrives via admin_url/dashboard_url.
frontend_allowed_origins = compact([local.admin_url, local.dashboard_url])

frontend_environment_variables = merge(
{
for idx, origin in local.frontend_allowed_origins :
"FrontendOptions__AllowedOrigins__${idx}" => origin
},
# Empty when the stack hosts neither SPA. There is no fallback tier any more, so those flows
# (register, resend-confirmation, forgot-password) answer 500 until admin_url or dashboard_url
# is set. The API logs one Error at startup naming the setting.
{ FrontendOptions__DefaultOrigin = try(coalesce(local.dashboard_url, local.admin_url), "") }
)
}

################################################################################
Expand Down Expand Up @@ -635,6 +653,7 @@ module "api_service" {
# CorsOptions__AllowedOrigins__0..N — the React SPA origins plus the app
# domain, so browsers on those origins can call the API cross-origin.
local.cors_environment_variables,
local.frontend_environment_variables,
var.api_extra_environment_variables
)

Expand Down
57 changes: 57 additions & 0 deletions src/BuildingBlocks/Web/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using FSH.Framework.Web.Cors;
using FSH.Framework.Web.Exceptions;
using FSH.Framework.Web.FeatureFlags;
using FSH.Framework.Web.Frontend;
using FSH.Framework.Web.Idempotency;
using FSH.Framework.Web.Sse;
using FSH.Framework.Web.Health;
Expand All @@ -28,6 +29,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mediator;

namespace FSH.Framework.Web;
Expand Down Expand Up @@ -135,6 +138,14 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild
builder.Services.AddOptions<OriginOptions>().BindConfiguration(nameof(OriginOptions));
builder.Services.AddOptions<SecurityHeadersOptions>().BindConfiguration(nameof(SecurityHeadersOptions));

// Front-end origin resolution for user-facing links in e-mails/notifications. DefaultOrigin
// is not validated with ValidateOnStart on purpose: a deployment that never sends such a
// link must not be taken down by the setting. There is no fallback tier — the resolver
// throws when it is unset — so UseHeroPlatform logs one Error at startup naming the setting
// and the flows that will answer 500 without it.
builder.Services.AddOptions<FrontendOptions>().BindConfiguration(nameof(FrontendOptions));
builder.Services.AddScoped<IFrontendOriginResolver, FrontendOriginResolver>();

return builder;
}

Expand All @@ -143,6 +154,8 @@ public static WebApplication UseHeroPlatform(this WebApplication app, Action<Fsh
{
ArgumentNullException.ThrowIfNull(app);

WarnOnMissingFrontendOrigin(app);

var options = new FshPipelineOptions();
configure?.Invoke(options);

Expand Down Expand Up @@ -229,6 +242,50 @@ private static bool IsOpenApiEnabled(IConfiguration configuration)
{
return configuration.GetValue("OpenApiOptions:Enabled", true);
}

// One Warning at boot, never per request: the resolver is scoped, so logging there would either
// flood the aggregator or stay silent on a host that simply never sends a link. An operator who
// upgrades into this change reads it once, in the startup banner, with the fix in the message.
private static void WarnOnMissingFrontendOrigin(WebApplication app)
{
var frontend = app.Services.GetRequiredService<IOptions<FrontendOptions>>().Value;

// Reported independently of DefaultOrigin: a deployment that sets only the default still
// has every self-service link falling back to it, which is wrong the moment there is more
// than one front-end. Counted after normalization, so a list of nothing but unparseable
// entries reports as the empty list it effectively is rather than looking configured.
var usableOrigins = FrontendOriginResolver.Normalize(frontend.AllowedOrigins).Length;
if (usableOrigins == 0)
{
app.Logger.LogWarning(
"FrontendOptions:AllowedOrigins is empty or entirely unparseable (appsettings.{Environment}.json). Password-reset and self-registration links cannot follow the front-end that made the request and will all point at FrontendOptions:DefaultOrigin instead. With more than one front-end that sends users to the wrong app. List every SPA origin as an absolute URL, e.g. [ \"https://app.example.com\", \"https://admin.example.com\" ].",
app.Environment.EnvironmentName);
}
else if (usableOrigins < frontend.AllowedOrigins.Length)
{
app.Logger.LogWarning(
"{DroppedCount} of {ConfiguredCount} FrontendOptions:AllowedOrigins entries are not absolute URLs and were ignored (appsettings.{Environment}.json). Requests from those origins will be rejected with 400. Each entry must carry a scheme, e.g. \"https://app.example.com\".",
frontend.AllowedOrigins.Length - usableOrigins,
frontend.AllowedOrigins.Length,
app.Environment.EnvironmentName);
}

if (Uri.TryCreate(frontend.DefaultOrigin, UriKind.Absolute, out _))
{
return;
}

// Absolute, not merely non-empty: "app.example.com" (no scheme, a common .env slip) binds
// fine and then every link in every e-mail is a relative URL no mail client makes clickable.
// Same failure class as an unset value, so it gets the same Error, not Warning: without a
// usable default there is nothing left to build these links out of.
// The resolver used to fall back to the API origin and then to the request host; both are
// gone, because the links now address SPA paths (the API origin 404s them) and the request
// host is caller-controlled (it hands the reset token to whoever set the Host header).
app.Logger.LogError(
"FrontendOptions:DefaultOrigin is not set to an absolute URL (appsettings.{Environment}.json). Admin register, resend confirmation, self-registration and password reset will return 500 for any caller that does not match FrontendOptions:AllowedOrigins, including every background job. Set FrontendOptions:DefaultOrigin to your dashboard URL, e.g. \"https://app.example.com\".",
app.Environment.EnvironmentName);
}
}

public sealed class FshPlatformOptions
Expand Down
43 changes: 43 additions & 0 deletions src/BuildingBlocks/Web/Frontend/FrontendOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace FSH.Framework.Web.Frontend;

/// <summary>
/// Configuration for resolving the front-end (SPA) origin used when building user-facing links
/// inside e-mails and notifications. Deliberately separate from <c>CorsOptions</c>: the CORS
/// allow-list governs which browsers may call the API, while this list governs which origins may
/// be embedded in an outbound link. The two often overlap but carry different security duties, and
/// coupling them breaks same-origin/reverse-proxy topologies where CORS needs no entries yet links
/// still must resolve.
/// </summary>
public sealed class FrontendOptions
{
/// <summary>
/// Origins trusted to appear in user-facing links. A request's <c>Origin</c> header is only
/// echoed into a link when it matches an entry here (scheme + host + port, port exact). Empty is
/// valid only when <see cref="DefaultOrigin"/> is set, in which case every link uses the default.
/// </summary>
public string[] AllowedOrigins { get; init; } = [];

/// <summary>
/// Front-end origin used when the request carries no usable <c>Origin</c> header (non-browser
/// callers such as curl / mobile apps / server-to-server), for
/// operator-driven flows whose link must land on the recipient's app rather than the caller's,
/// and for background jobs that run without an HTTP request. Typically the tenant dashboard URL.
/// <para>
/// <b>Required in practice.</b> Every deployment resolves through this at some point (operator
/// flows, non-browser callers, jobs). Left unset, the host still starts and logs a startup
/// <c>Error</c>, but those flows return 500: there is no safe origin to substitute. The API's
/// own origin 404s, because these links address SPA routes (<c>/confirm-email</c>,
/// <c>/reset-password</c>) rather than API routes; the request host is whatever the caller put
/// in the <c>Host</c> header, which would mail a live reset token to a domain the attacker
/// chose. <see cref="AllowedOrigins"/> is additive: it only widens which request origins may be
/// echoed into self-service links, and cannot substitute for the default.
/// </para>
/// <para>
/// This is a single global value, not per-tenant or custom-domain aware: operator-driven
/// register / resend-confirmation therefore point <em>every</em> tenant's link at this 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 here instead.
/// </para>
/// </summary>
public string? DefaultOrigin { get; init; }
}
Loading
Loading