Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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` → `OriginOptions:OriginUrl` → the current request's host → throw. Never the caller's `Origin`, or an operator would send a tenant user a link into the admin console.

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 one startup `Warning` instead, counted after normalization so an all-typo list reports as the empty list it effectively is. `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
60 changes: 60 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,13 @@ 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 at startup on purpose: a deployment that never sends such a link must not
// be taken down by the setting. Unset, the resolver falls back to the API's own origin and
// UseHeroPlatform logs one Warning naming the setting and what degrades without it.
builder.Services.AddOptions<FrontendOptions>().BindConfiguration(nameof(FrontendOptions));
builder.Services.AddScoped<IFrontendOriginResolver, FrontendOriginResolver>();

return builder;
}

Expand All @@ -143,6 +153,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 +241,54 @@ 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 (!string.IsNullOrWhiteSpace(frontend.DefaultOrigin))
{
return;
}

// Same absolute-Uri guard the resolver applies.
var apiOrigin = app.Services.GetRequiredService<IOptions<OriginOptions>>().Value.OriginUrl;
if (apiOrigin is { IsAbsoluteUri: true })
{
app.Logger.LogWarning(
"FrontendOptions:DefaultOrigin is not set (appsettings.{Environment}.json). Auth e-mail links for operator-driven flows (admin register, resend confirmation) and for callers that send no Origin header will point at the API origin {ApiOrigin} instead of the front-end app. Set FrontendOptions:DefaultOrigin to your dashboard URL, e.g. \"https://app.example.com\".",
app.Environment.EnvironmentName,
apiOrigin);
return;
}

app.Logger.LogWarning(
"Neither FrontendOptions:DefaultOrigin nor OriginOptions:OriginUrl is set (appsettings.{Environment}.json). Auth e-mail links for operator-driven flows (admin register, resend confirmation) and for callers that send no Origin header will point at this API's own request host instead of the front-end app, and will fail outright in a background job, which has no request to derive a host from. Set FrontendOptions:DefaultOrigin to your dashboard URL, e.g. \"https://app.example.com\".",
app.Environment.EnvironmentName);
}
}

public sealed class FshPlatformOptions
Expand Down
45 changes: 45 additions & 0 deletions src/BuildingBlocks/Web/Frontend/FrontendOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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>Strongly recommended, not required.</b> Every deployment resolves through this at some
/// point (operator flows, non-browser callers, jobs). Left unset, the host still starts, logs a
/// single startup <c>Warning</c> and falls back to the API's own origin
/// (<c>OriginOptions:OriginUrl</c>, or the current request's host when that is empty too): links
/// then land on the API rather than the SPA — serviceable, and the same place register /
/// self-register / resend derived them from before this option existed, but not where a user
/// expects to arrive. A background job, having no request, fails instead.
/// <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; }
}
144 changes: 144 additions & 0 deletions src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
using System.Net;
using FSH.Framework.Core.Exceptions;
using FSH.Framework.Web.Origin;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace FSH.Framework.Web.Frontend;

internal sealed class FrontendOriginResolver(
IHttpContextAccessor httpContextAccessor,
IOptions<FrontendOptions> options,
IOptions<OriginOptions> originOptions,
ILogger<FrontendOriginResolver> logger) : IFrontendOriginResolver
{
// Normalize the allow-list once at construction: parse to Uri so matching is component-wise
// (scheme + host + port) instead of a raw string compare that an entry like ":443" or an IDN
// form would silently fail.
private readonly Uri[] _allowed = Normalize(options.Value.AllowedOrigins);
private readonly string? _default = options.Value.DefaultOrigin?.TrimEnd('/');
// IsAbsoluteUri guard: OriginUrl is operator-supplied, and only an absolute Uri has an
// AbsoluteUri to read.
private readonly string? _apiOrigin = originOptions.Value.OriginUrl is { IsAbsoluteUri: true } api
? api.AbsoluteUri.TrimEnd('/')
: null;

public string ResolveForCurrentRequest()
{
var header = httpContextAccessor.HttpContext?.Request.Headers.Origin.ToString();
if (string.IsNullOrWhiteSpace(header))
{
// Non-browser caller (curl, mobile, server-to-server) sends no Origin. Fall back to the
// configured default rather than failing an otherwise valid flow. Note the Scalar
// try-it UI is NOT in this group: it fetches from the browser, so it sends the API's
// own origin and needs that origin allow-listed to exercise these two endpoints.
return ResolveDefault();
}

if (_allowed.Length == 0)
{
// No allow-list configured: there is nothing to validate the header against, so trust
// the server-side default instead of rejecting. Browsers attach Origin to these POSTs
// even same-origin, so matching an empty list would 400 every legitimate reset on the
// single-SPA and reverse-proxy topologies — and on the shipped Production config.
// The header is discarded, never echoed, so this cannot leak a client-chosen origin.
return ResolveDefault();
}

var canonical = MatchAllowed(header);
if (canonical is not null)
{
return canonical;
}

// A present-but-unlisted Origin is a forged or misconfigured client, not a server fault:
// surface a 4xx so error-rate alerting doesn't page on bot traffic to anonymous endpoints.
// Logged at Debug, not Warning: these endpoints are anonymous, so bot/forged traffic would
// flood the aggregator at Warning. A genuine deployer misconfig (a real SPA origin missing
// from the list) already surfaces loudly as a 400 to that SPA's own users.
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("Rejected front-end origin {Origin}: not in FrontendOptions:AllowedOrigins", header);
}
throw new CustomException(
"The request origin is not an allowed front-end origin.",
errors: null,
HttpStatusCode.BadRequest);
}

public string ResolveDefault()
{
if (!string.IsNullOrWhiteSpace(_default))
{
return _default;
}

// No DefaultOrigin: fall back to the API's own origin rather than taking the host down at
// boot over a setting a deployment may never exercise. Links then land on the API — which
// is where register / self-register / resend derived them from before the resolver existed
// — and startup logs a single Warning naming what degrades. The configured value first, the
// request host second: appsettings.Production.json ships OriginUrl empty too, and a
// deployment that set neither must still send a usable link.
//
// Note this is the API's own host, never the caller's Origin header: an operator-driven
// link must not point at the admin SPA the request came from, which is the whole reason
// ResolveDefault exists apart from ResolveForCurrentRequest.
if (!string.IsNullOrWhiteSpace(_apiOrigin))
{
return _apiOrigin;
}

var request = httpContextAccessor.HttpContext?.Request;
if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue)
{
return $"{request.Scheme}://{request.Host.Value}{request.PathBase}".TrimEnd('/');
}

// Nothing configured and no request to derive from (a background job): there is no origin
// to build a link out of.
throw new CustomException(
"No front-end origin is configured: set FrontendOptions:DefaultOrigin (or OriginOptions:OriginUrl as a fallback).",
errors: null,
HttpStatusCode.InternalServerError);
}

private string? MatchAllowed(string header)
{
if (!Uri.TryCreate(header.TrimEnd('/'), UriKind.Absolute, out var candidate))
{
return null;
}

// Return the canonical configured entry, never the client-supplied casing.
return _allowed.FirstOrDefault(allowed => IsSameOrigin(candidate, allowed))
?.GetLeftPart(UriPartial.Authority);
}

// Scheme + host + port, port exact. Compared through IdnHost so a list entry written in Unicode
// ("https://bücher.example") matches the punycode form the browser actually sends; Uri.Port
// supplies the scheme's default, so ":443" and the bare host are the same origin.
private static bool IsSameOrigin(Uri candidate, Uri allowed)
{
return string.Equals(candidate.Scheme, allowed.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(candidate.IdnHost, allowed.IdnHost, StringComparison.OrdinalIgnoreCase)
&& candidate.Port == allowed.Port;
}

// Internal so the startup warning reports the list the resolver will actually match against,
// not the raw config array: an entry that fails to parse is dropped here and would otherwise
// leave a fully malformed list looking configured while every link silently used the default.
internal static Uri[] Normalize(string[] origins)
{
var list = new List<Uri>(origins.Length);
foreach (var origin in origins)
{
if (Uri.TryCreate(origin.TrimEnd('/'), UriKind.Absolute, out var uri))
{
list.Add(uri);
}
}

return [.. list];
}
}
28 changes: 28 additions & 0 deletions src/BuildingBlocks/Web/Frontend/IFrontendOriginResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace FSH.Framework.Web.Frontend;

/// <summary>
/// Resolves the front-end (SPA) origin used to build user-facing links inside e-mails and
/// notifications. Framework-level so any module that sends such links (Identity, Notifications,
/// Billing, Tickets, …) resolves the origin the same way.
/// </summary>
public interface IFrontendOriginResolver
{
/// <summary>
/// Origin for a link that lands on the SPA the caller is currently using — self-service flows
/// (password reset, self-registration) where the request comes from the user's own app.
/// Validates the request <c>Origin</c> header against <see cref="FrontendOptions.AllowedOrigins"/>
/// and returns the canonical matching entry (never the client's raw casing). Falls back to
/// <see cref="FrontendOptions.DefaultOrigin"/> when the request carries no <c>Origin</c> header.
/// Throws a 400-mapped exception when a header is present but not allow-listed — a forged origin
/// must never reach an e-mail.
/// </summary>
string ResolveForCurrentRequest();

/// <summary>
/// Origin for a link whose recipient is not the caller — operator-driven flows (an admin
/// registering or re-inviting a tenant user, whose confirmation link must land on the tenant's
/// app, not the operator's) — or where no HTTP request exists (background jobs). Returns
/// <see cref="FrontendOptions.DefaultOrigin"/>.
/// </summary>
string ResolveDefault();
}
Loading
Loading