diff --git a/.agents/rules/security.md b/.agents/rules/security.md index b3fb38404b..62e615d7aa 100644 --- a/.agents/rules/security.md +++ b/.agents/rules/security.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index cbe60e9e1f..f17f598619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | diff --git a/deploy/docker/README.md b/deploy/docker/README.md index bcb1304593..acd1f80620 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -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. @@ -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 @@ -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. | diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index d43c744f5b..d90d0638cf 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -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"] @@ -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: @@ -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" diff --git a/deploy/terraform/apps/starter/app_stack/main.tf b/deploy/terraform/apps/starter/app_stack/main.tf index ee4eece47d..d0c1819b81 100644 --- a/deploy/terraform/apps/starter/app_stack/main.tf +++ b/deploy/terraform/apps/starter/app_stack/main.tf @@ -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), "") } + ) } ################################################################################ @@ -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 ) diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index 50c6568fda..3a271aa2de 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -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; @@ -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; @@ -135,6 +138,14 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild builder.Services.AddOptions().BindConfiguration(nameof(OriginOptions)); builder.Services.AddOptions().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().BindConfiguration(nameof(FrontendOptions)); + builder.Services.AddScoped(); + return builder; } @@ -143,6 +154,8 @@ public static WebApplication UseHeroPlatform(this WebApplication app, Action>().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 diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs new file mode 100644 index 0000000000..c9053f3b75 --- /dev/null +++ b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs @@ -0,0 +1,43 @@ +namespace FSH.Framework.Web.Frontend; + +/// +/// Configuration for resolving the front-end (SPA) origin used when building user-facing links +/// inside e-mails and notifications. Deliberately separate from CorsOptions: 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. +/// +public sealed class FrontendOptions +{ + /// + /// Origins trusted to appear in user-facing links. A request's Origin header is only + /// echoed into a link when it matches an entry here (scheme + host + port, port exact). Empty is + /// valid only when is set, in which case every link uses the default. + /// + public string[] AllowedOrigins { get; init; } = []; + + /// + /// Front-end origin used when the request carries no usable Origin 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. + /// + /// Required in practice. Every deployment resolves through this at some point (operator + /// flows, non-browser callers, jobs). Left unset, the host still starts and logs a startup + /// Error, but those flows return 500: there is no safe origin to substitute. The API's + /// own origin 404s, because these links address SPA routes (/confirm-email, + /// /reset-password) rather than API routes; the request host is whatever the caller put + /// in the Host header, which would mail a live reset token to a domain the attacker + /// chose. is additive: it only widens which request origins may be + /// echoed into self-service links, and cannot substitute for the default. + /// + /// + /// This is a single global value, not per-tenant or custom-domain aware: operator-driven + /// register / resend-confirmation therefore point every 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. + /// + /// + public string? DefaultOrigin { get; init; } +} diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs new file mode 100644 index 0000000000..8f77c1bac1 --- /dev/null +++ b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs @@ -0,0 +1,148 @@ +using System.Net; +using FSH.Framework.Core.Exceptions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FSH.Framework.Web.Frontend; + +internal sealed class FrontendOriginResolver( + IHttpContextAccessor httpContextAccessor, + IOptions options, + ILogger 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 = NormalizeDefault(options.Value.DefaultOrigin); + + 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. The shipped Production config reaches this + // branch because the dev origins live in appsettings.Development.json: an empty array in + // an environment overlay does NOT clear the base file's entries, so leaving them in + // appsettings.json put localhost in every production trust list (ShippedConfigurationTests). + // 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", + SanitizeForLog(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 usable origin, and deliberately nothing to fall back on. The two tiers that used to + // sit here — the API's own origin, then the request host — both produce a broken or unsafe + // link now that these paths address the SPA (/confirm-email, /reset-password) rather than + // the API route: the API origin returns 404 for them, and the request host is whatever the + // caller put in the Host header, which turns a password-reset e-mail into a token delivered + // to an attacker's domain. Failing here is the only outcome that is neither. + // + // Callers surface this as a 500, which is honest: the deployment is missing a setting, + // the user's request was fine. Startup logs an Error naming the setting. + throw new CustomException( + "No front-end origin is configured: set FrontendOptions:DefaultOrigin to the URL of the app that should receive these links.", + errors: null, + HttpStatusCode.InternalServerError); + } + + // The header is caller-controlled, so it is truncated and stripped of line breaks before it + // reaches a text sink: a newline in it would otherwise forge log lines. Same treatment the + // global exception handler gives the request path. + private static string SanitizeForLog(string value) + { + var single = value.Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal); + return single.Length <= 200 ? single : single[..200]; + } + + 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; + } + + // The same rule the allow-list gets, for the same reason. "app.example.com" (no scheme, the + // usual .env slip) is not something a mail client can make clickable, so carrying it would + // mean every link silently broke while the resolver reported success. Dropped here, it fails + // exactly the way an unset value does, which is what startup already logs it as. + private static string? NormalizeDefault(string? configured) + { + var trimmed = configured?.TrimEnd('/'); + return Uri.TryCreate(trimmed, UriKind.Absolute, out _) ? trimmed : null; + } + + // 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(origins.Length); + foreach (var origin in origins) + { + if (Uri.TryCreate(origin.TrimEnd('/'), UriKind.Absolute, out var uri)) + { + list.Add(uri); + } + } + + return [.. list]; + } +} diff --git a/src/BuildingBlocks/Web/Frontend/IFrontendOriginResolver.cs b/src/BuildingBlocks/Web/Frontend/IFrontendOriginResolver.cs new file mode 100644 index 0000000000..c53969cc58 --- /dev/null +++ b/src/BuildingBlocks/Web/Frontend/IFrontendOriginResolver.cs @@ -0,0 +1,28 @@ +namespace FSH.Framework.Web.Frontend; + +/// +/// 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. +/// +public interface IFrontendOriginResolver +{ + /// + /// 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 Origin header against + /// and returns the canonical matching entry (never the client's raw casing). Falls back to + /// when the request carries no Origin header. + /// Throws a 400-mapped exception when a header is present but not allow-listed — a forged origin + /// must never reach an e-mail. + /// + string ResolveForCurrentRequest(); + + /// + /// 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 + /// . + /// + string ResolveDefault(); +} diff --git a/src/BuildingBlocks/Web/Web.csproj b/src/BuildingBlocks/Web/Web.csproj index c84453709a..0c06183376 100644 --- a/src/BuildingBlocks/Web/Web.csproj +++ b/src/BuildingBlocks/Web/Web.csproj @@ -48,4 +48,8 @@ + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0d38b28190..854deb9530 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -9,7 +9,8 @@ - + + @@ -122,9 +123,10 @@ - - - + + + + diff --git a/src/Host/FSH.Starter.Api/appsettings.Development.json b/src/Host/FSH.Starter.Api/appsettings.Development.json index bd93b09956..dcc4f37c21 100644 --- a/src/Host/FSH.Starter.Api/appsettings.Development.json +++ b/src/Host/FSH.Starter.Api/appsettings.Development.json @@ -6,7 +6,12 @@ } }, "CorsOptions": { - "AllowAll": true + "AllowAll": true, + "AllowedOrigins": [ "http://localhost:5173", "http://localhost:5174" ] + }, + "FrontendOptions": { + "AllowedOrigins": [ "http://localhost:5173", "http://localhost:5174" ], + "DefaultOrigin": "http://localhost:5174" }, "OpenApiOptions": { "Enabled": true diff --git a/src/Host/FSH.Starter.Api/appsettings.Production.json b/src/Host/FSH.Starter.Api/appsettings.Production.json index 332724534b..2fdf8cbf84 100644 --- a/src/Host/FSH.Starter.Api/appsettings.Production.json +++ b/src/Host/FSH.Starter.Api/appsettings.Production.json @@ -62,6 +62,10 @@ "AllowedHeaders": [ "content-type", "authorization" ], "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] }, + "FrontendOptions": { + "AllowedOrigins": [], + "DefaultOrigin": "" + }, "JwtOptions": { "Issuer": "fsh.local", "Audience": "fsh.clients", diff --git a/src/Host/FSH.Starter.Api/appsettings.json b/src/Host/FSH.Starter.Api/appsettings.json index 293fdfebb6..6cd0457f27 100644 --- a/src/Host/FSH.Starter.Api/appsettings.json +++ b/src/Host/FSH.Starter.Api/appsettings.json @@ -96,13 +96,14 @@ }, "CorsOptions": { "AllowAll": false, - "AllowedOrigins": [ - "http://localhost:5173", - "http://localhost:5174" - ], + "AllowedOrigins": [], "AllowedHeaders": [ "content-type", "authorization" ], "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] }, + "FrontendOptions": { + "AllowedOrigins": [], + "DefaultOrigin": "" + }, "JwtOptions": { "Issuer": "fsh.local", "Audience": "fsh.clients", diff --git a/src/Host/FSH.Starter.AppHost/AppHost.cs b/src/Host/FSH.Starter.AppHost/AppHost.cs index e7a70abd05..4fc689599d 100644 --- a/src/Host/FSH.Starter.AppHost/AppHost.cs +++ b/src/Host/FSH.Starter.AppHost/AppHost.cs @@ -52,7 +52,10 @@ var minioUser = builder.AddParameter("minio-user", "minioadmin"); var minioPassword = builder.AddParameter("minio-password", "minioadmin", secret: true); +// quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. var minio = builder.AddContainer("minio", "minio/minio") + .WithImageRegistry("quay.io") + .WithImageTag("RELEASE.2025-09-07T16-13-09Z") .WithArgs("server", "/data", "--console-address", ":9001") .WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api") .WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console") @@ -73,6 +76,8 @@ """).ReplaceLineEndings("\n"); var minioInit = builder.AddContainer("minio-init", "minio/mc") + .WithImageRegistry("quay.io") + .WithImageTag("RELEASE.2025-08-13T08-35-41Z") .WithEntrypoint("/bin/sh") .WithArgs("-c", minioInitScript) .WithEnvironment("MC_USER", minioUser) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs index 267f49887b..c7442ee07d 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -1,31 +1,27 @@ -using FSH.Framework.Web.Origin; +using FSH.Framework.Web.Frontend; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; using Mediator; -using Microsoft.Extensions.Options; namespace FSH.Modules.Identity.Features.v1.Users.ForgotPassword; public sealed class ForgotPasswordCommandHandler : ICommandHandler { private readonly IUserService _userService; - private readonly IOptions _originOptions; + private readonly IFrontendOriginResolver _originResolver; - public ForgotPasswordCommandHandler(IUserService userService, IOptions originOptions) + public ForgotPasswordCommandHandler(IUserService userService, IFrontendOriginResolver originResolver) { _userService = userService; - _originOptions = originOptions; + _originResolver = originResolver; } public async ValueTask Handle(ForgotPasswordCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - var origin = _originOptions.Value?.OriginUrl?.ToString(); - if (string.IsNullOrWhiteSpace(origin)) - { - throw new InvalidOperationException("Origin URL is not configured."); - } + // Self-service flow: the reset link must land on the SPA the user is currently using. + var origin = _originResolver.ResolveForCurrentRequest(); await _userService.ForgotPasswordAsync(command.Email, origin, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs index e045edfb2b..043c02e64e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.Authorization; using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Web.Frontend; using FSH.Framework.Web.Idempotency; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; using Mediator; @@ -14,12 +15,13 @@ public static class RegisterUserEndpoint internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPost("/register", async (RegisterUserCommand command, - HttpContext context, + IFrontendOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) => { - var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; - command.Origin = origin; + // Operator-driven flow: an admin registers a tenant user, so the confirmation link must + // land on the recipient's app (the default front-end), not the operator's Origin. + command.Origin = originResolver.ResolveDefault(); var result = await mediator.Send(command, cancellationToken); return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result); }) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs index f6d2548325..f5d3523e4d 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailEndpoint.cs @@ -1,4 +1,5 @@ using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Web.Frontend; using FSH.Modules.Identity.Contracts.Authorization; using FSH.Modules.Identity.Contracts.v1.Users.ResendConfirmationEmail; using Mediator; @@ -26,12 +27,13 @@ internal static RouteHandlerBuilder MapResendConfirmationEmailEndpoint(this IEnd private static async Task Handler( Guid id, - HttpContext context, + IFrontendOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) { - // Build the confirmation-link base URL from the request, same as the registration endpoint. - var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; + // Operator-driven flow: an admin re-sends a tenant user's confirmation, so the link must + // land on the recipient's app (the default front-end), not the operator's Origin. + var origin = originResolver.ResolveDefault(); await mediator.Send(new ResendConfirmationEmailCommand(id.ToString(), origin), cancellationToken); return TypedResults.NoContent(); } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs index 022936da7c..3dbe7d80b6 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs @@ -1,4 +1,5 @@ using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Web.Frontend; using FSH.Framework.Web.Idempotency; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; using Mediator; @@ -15,12 +16,12 @@ internal static RouteHandlerBuilder MapSelfRegisterUserEndpoint(this IEndpointRo { return endpoints.MapPost("/self-register", async (RegisterUserCommand command, [FromHeader(Name = MultitenancyConstants.Identifier)] string tenant, - HttpContext context, + IFrontendOriginResolver originResolver, IMediator mediator, CancellationToken cancellationToken) => { - var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; - command.Origin = origin; + // Self-service flow: the confirmation link lands on the SPA the user registered from. + command.Origin = originResolver.ResolveForCurrentRequest(); var result = await mediator.Send(command, cancellationToken); return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result); }) diff --git a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs index 691e3e44d6..5046b3c413 100644 --- a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs @@ -1,4 +1,3 @@ -using FSH.Framework.Core.Context; using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Contracts.Services; using Microsoft.AspNetCore.Http; @@ -13,14 +12,14 @@ namespace FSH.Modules.Identity.Services; internal sealed class RequestContextService : IRequestContextService { private readonly IHttpContextAccessor _httpContextAccessor; - private readonly Uri? _originUrl; + private readonly Uri? _configuredOrigin; public RequestContextService( IHttpContextAccessor httpContextAccessor, IOptions originOptions) { _httpContextAccessor = httpContextAccessor; - _originUrl = originOptions.Value.OriginUrl; + _configuredOrigin = originOptions.Value.OriginUrl; } public string? IpAddress => @@ -38,13 +37,18 @@ public string ClientId } } + /// + /// Origin of the API itself (scheme + host + path base), used for back-end-served links and + /// assets such as avatars. Prefers the configured OriginOptions:OriginUrl, falling back + /// to the current request's host; null when neither is available (e.g. a background job). + /// public string? Origin { get { - if (_originUrl is not null) + if (_configuredOrigin is not null) { - return _originUrl.AbsoluteUri.TrimEnd('/'); + return _configuredOrigin.AbsoluteUri.TrimEnd('/'); } var request = _httpContextAccessor.HttpContext?.Request; diff --git a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs index c96c90384b..ac4ec275a1 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs @@ -4,14 +4,11 @@ using FSH.Framework.Shared.Storage; using FSH.Framework.Storage; using FSH.Framework.Storage.Services; -using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Domain; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; namespace FSH.Modules.Identity.Services; @@ -20,11 +17,8 @@ internal sealed class UserProfileService( SignInManager signInManager, IStorageService storageService, IMultiTenantContextAccessor multiTenantContextAccessor, - IOptions originOptions, - IHttpContextAccessor httpContextAccessor) : IUserProfileService + IRequestContextService requestContext) : IUserProfileService { - private readonly Uri? _originUrl = originOptions.Value.OriginUrl; - public async Task GetAsync(string userId, CancellationToken cancellationToken) { // Relies on Finbuckle's tenant filter — callers can only ever read @@ -174,21 +168,14 @@ private void EnsureValidTenant() return imageUrl.ToString(); } - // For relative paths from local storage, prefix with the API origin and wwwroot. - if (_originUrl is null) + // For relative paths from local storage, prefix with the API origin (configured, else the request host). + var baseUri = requestContext.Origin; + if (string.IsNullOrEmpty(baseUri)) { - var request = httpContextAccessor.HttpContext?.Request; - if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) - { - var baseUri = $"{request.Scheme}://{request.Host.Value}{request.PathBase}"; - var relativePath = imageUrl.ToString().TrimStart('/'); - return $"{baseUri.TrimEnd('/')}/{relativePath}"; - } - return imageUrl.ToString(); } - var originRelativePath = imageUrl.ToString().TrimStart('/'); - return $"{_originUrl.AbsoluteUri.TrimEnd('/')}/{originRelativePath}"; + var relativePath = imageUrl.ToString().TrimStart('/'); + return $"{baseUri}/{relativePath}"; } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs index 79409e4379..91f02aa47f 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs @@ -345,8 +345,10 @@ private async Task GetEmailVerificationUriAsync(FshUser user, string ori string code = await userManager.GenerateEmailConfirmationTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - const string route = "api/v1/identity/confirm-email"; - var endpointUri = new Uri(string.Concat($"{origin}/", route)); + // Point at the SPA confirm-email page on the front-end the user registered from, which in turn + // calls the API. The origin argument is the already-resolved front-end origin. + const string route = "confirm-email"; + var endpointUri = new Uri(string.Concat($"{origin.TrimEnd('/')}/", route)); string verificationUri = QueryHelpers.AddQueryString(endpointUri.ToString(), QueryStringKeys.UserId, user.Id); verificationUri = QueryHelpers.AddQueryString(verificationUri, QueryStringKeys.Code, code); diff --git a/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs new file mode 100644 index 0000000000..b69d9fc22e --- /dev/null +++ b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs @@ -0,0 +1,283 @@ +using System.Net; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Frontend; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Framework.Tests.Web; + +/// +/// Tests for FrontendOriginResolver — resolves the SPA origin for user-facing links, validating the +/// request Origin header against the allow-list and falling back to the configured default. There +/// is no tier below that: with neither an allow-list match nor a default, resolution fails. +/// +public sealed class FrontendOriginResolverTests +{ + private readonly IHttpContextAccessor _httpContextAccessor = Substitute.For(); + + private FrontendOriginResolver CreateResolver(string[] allowedOrigins, string? defaultOrigin = null) + { + var options = Options.Create(new FrontendOptions + { + AllowedOrigins = allowedOrigins, + DefaultOrigin = defaultOrigin, + }); + return new FrontendOriginResolver(_httpContextAccessor, options, NullLogger.Instance); + } + + private void SetOriginHeader(string? origin) + { + var context = new DefaultHttpContext(); + if (origin is not null) + { + context.Request.Headers.Origin = origin; + } + + _httpContextAccessor.HttpContext.Returns(context); + } + + private void SetRequestHost(string scheme, string host) + { + var context = new DefaultHttpContext(); + context.Request.Scheme = scheme; + context.Request.Host = new HostString(host); + _httpContextAccessor.HttpContext.Returns(context); + } + + // ── ResolveForCurrentRequest ──────────────────────────────────────────── + + [Fact] + public void ResolveForCurrentRequest_Should_ReturnCanonicalEntry_When_HeaderInAllowList() + { + SetOriginHeader("http://localhost:5173"); + var resolver = CreateResolver(["http://localhost:5173", "http://localhost:5174"]); + + resolver.ResolveForCurrentRequest().ShouldBe("http://localhost:5173"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_MatchIgnoringTrailingSlash() + { + SetOriginHeader("http://localhost:5173/"); + var resolver = CreateResolver(["http://localhost:5173"]); + + resolver.ResolveForCurrentRequest().ShouldBe("http://localhost:5173"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_ReturnCanonicalCasing_When_HeaderCasingDiffers() + { + // A client sending uppercased scheme/host must not steer the emitted link's casing: + // the resolver returns the canonical allow-list entry, not the raw header. + SetOriginHeader("HTTP://LOCALHOST:5173"); + var resolver = CreateResolver(["http://localhost:5173"]); + + resolver.ResolveForCurrentRequest().ShouldBe("http://localhost:5173"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_Reject_When_PortDiffers() + { + // :5174 must never match the :5173 allow-list entry (port compared exactly). + SetOriginHeader("http://localhost:5174"); + var resolver = CreateResolver(["http://localhost:5173"]); + + var ex = Should.Throw(() => resolver.ResolveForCurrentRequest()); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public void ResolveForCurrentRequest_Should_Reject_When_HeaderForged() + { + SetOriginHeader("https://evil.example.com"); + var resolver = CreateResolver(["http://localhost:5173"]); + + var ex = Should.Throw(() => resolver.ResolveForCurrentRequest()); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public void ResolveForCurrentRequest_Should_FallBackToDefault_When_AllowListEmpty() + { + // appsettings.Production.json ships AllowedOrigins empty, and browsers attach Origin to + // these POSTs even same-origin: matching an empty list would 400 every legitimate reset. + SetOriginHeader("https://app.example.com"); + var resolver = CreateResolver([], defaultOrigin: "https://tenant.example.com"); + + resolver.ResolveForCurrentRequest().ShouldBe("https://tenant.example.com"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_FallBackToDefault_When_EveryEntryIsUnparseable() + { + // Entries that are not absolute URLs are dropped at construction, so a list of nothing but + // typos behaves as the empty list it effectively is. The startup warning counts the same way. + SetOriginHeader("https://app.example.com"); + var resolver = CreateResolver(["https;//app.example.com"], defaultOrigin: "https://tenant.example.com"); + + resolver.ResolveForCurrentRequest().ShouldBe("https://tenant.example.com"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_Reject_When_OnlyOtherEntriesParse() + { + // One good entry keeps the list live, so an origin that is not on it is still a 400 — + // a partly-malformed list must not silently widen into the empty-list fallback. + SetOriginHeader("https://app.example.com"); + var resolver = CreateResolver(["https;//app.example.com", "https://admin.example.com"], defaultOrigin: "https://tenant.example.com"); + + var ex = Should.Throw(() => resolver.ResolveForCurrentRequest()); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public void ResolveForCurrentRequest_Should_ReturnConfiguredEntry_When_HeaderCarriesUserInfo() + { + // "http://evil.com@localhost:5173" compares equal on scheme+host+port, so the guarantee + // that holds is returning the configured entry rather than anything the client sent. + SetOriginHeader("http://evil.com@localhost:5173"); + var resolver = CreateResolver(["http://localhost:5173"]); + + resolver.ResolveForCurrentRequest().ShouldBe("http://localhost:5173"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_MatchIdnEntry_Against_PunycodeHeader() + { + // A list entry written in Unicode must match the punycode form the browser actually sends, + // otherwise a valid IDN deployment fails closed. The emitted value stays the configured + // entry, so an operator who writes Unicode gets Unicode in the link. + SetOriginHeader("https://xn--bcher-kva.example"); + var resolver = CreateResolver(["https://bücher.example"]); + + resolver.ResolveForCurrentRequest().ShouldBe("https://bücher.example"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_MatchDefaultPort_Written_Explicitly() + { + // ":443" is the same origin as the bare host; an entry carrying it must not fail closed. + SetOriginHeader("https://app.example.com"); + var resolver = CreateResolver(["https://app.example.com:443"]); + + resolver.ResolveForCurrentRequest().ShouldBe("https://app.example.com"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_FallBackToDefault_When_NoHeader() + { + // Non-browser callers (curl, mobile, server-to-server) send no Origin — use the default. + SetOriginHeader(null); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: "https://app.example.com"); + + resolver.ResolveForCurrentRequest().ShouldBe("https://app.example.com"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_FallBackToDefault_When_NoHttpContext() + { + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: "https://app.example.com"); + + resolver.ResolveForCurrentRequest().ShouldBe("https://app.example.com"); + } + + // ── ResolveDefault ────────────────────────────────────────────────────── + + [Fact] + public void ResolveDefault_Should_ReturnConfiguredDefault_TrailingSlashTrimmed() + { + var resolver = CreateResolver([], defaultOrigin: "https://app.example.com/"); + + resolver.ResolveDefault().ShouldBe("https://app.example.com"); + } + + [Fact] + public void ResolveDefault_Should_Throw_When_DefaultIsEmptyString() + { + // appsettings.Production.json ships "DefaultOrigin": "" — the empty string takes the same + // path as an absent key, and there is no longer an API-origin tier under it to catch it. + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var resolver = CreateResolver([], defaultOrigin: ""); + + var ex = Should.Throw(() => resolver.ResolveDefault()); + ex.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + public void ResolveDefault_Should_NotFallBackToRequestHost_When_NothingConfigured() + { + // The link paths address the SPA (/confirm-email, /reset-password), so the request host is + // not a serviceable substitute — and it is caller-supplied: honouring it mails a live reset + // token to whatever domain the attacker put in the Host header. Fail instead, and do not + // name the attacker's host in the message that reaches the caller. + SetRequestHost("https", "evil.example.com"); + var resolver = CreateResolver([], defaultOrigin: null); + + var ex = Should.Throw(() => resolver.ResolveDefault()); + ex.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + ex.Message.ShouldNotContain("evil.example.com"); + } + + [Fact] + public void ResolveDefault_Should_Throw_When_NothingConfiguredAndNoRequest() + { + // A background job: nothing configured and no request to derive a host from. + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: null); + + var ex = Should.Throw(() => resolver.ResolveDefault()); + ex.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + public void ResolveForCurrentRequest_Should_Throw_When_NoHeaderAndNoDefault() + { + // The no-header path routes through ResolveDefault, so it inherits the same failure. + SetOriginHeader(null); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: null); + + var ex = Should.Throw(() => resolver.ResolveForCurrentRequest()); + ex.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + public void ResolveForCurrentRequest_Should_StillReject_ForgedHeader_When_NoDefault() + { + // A present-but-unlisted Origin is a 400 — the caller's fault — and stays a 400 even when + // the deployment is also missing its default. The two failures must not blur into one. + SetOriginHeader("https://evil.example.com"); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: null); + + var ex = Should.Throw(() => resolver.ResolveForCurrentRequest()); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public void ResolveDefault_Should_Throw_When_DefaultOriginHasNoScheme() + { + // Startup logs a value like this as the same failure as an unset one. That has to hold at + // run time too: returning it would put a relative URL in the e-mail, which no mail client + // turns into a link, and nothing would report a problem. + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var resolver = CreateResolver([], defaultOrigin: "app.example.com"); + + var ex = Should.Throw(() => resolver.ResolveDefault()); + ex.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + public void ResolveDefault_Should_KeepBasePath_When_TheDefaultCarriesOne() + { + // A deployment serving the SPA under a sub-path configures it here. Validation must not + // collapse the value to its authority: /reset-password would then 404. + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var resolver = CreateResolver([], defaultOrigin: "https://example.com/app/"); + + resolver.ResolveDefault().ShouldBe("https://example.com/app"); + } +} diff --git a/src/Tests/Framework.Tests/Web/ShippedConfigurationTests.cs b/src/Tests/Framework.Tests/Web/ShippedConfigurationTests.cs new file mode 100644 index 0000000000..fd30c6c6f9 --- /dev/null +++ b/src/Tests/Framework.Tests/Web/ShippedConfigurationTests.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Configuration; + +namespace Framework.Tests.Web; + +/// +/// Loads the shipped appsettings the way the host does and pins what a Production deployment +/// actually gets. This exists because an environment overlay cannot clear a JSON array: an empty +/// [] writes no indices, so the base file's entries survive the overlay and +/// appsettings.Production.json shipping "AllowedOrigins": [] left +/// http://localhost:5173 and :5174 in the trust list of every production deployment. +/// The dev origins therefore live in appsettings.Development.json, and this asserts it stays +/// that way. +/// +public sealed class ShippedConfigurationTests +{ + private static string HostDirectory() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, "src", "Host", "FSH.Starter.Api"); + if (File.Exists(Path.Combine(candidate, "appsettings.json"))) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate src/Host/FSH.Starter.Api from the test output directory."); + } + + private static IConfigurationRoot LoadFor(string environment) + { + var host = HostDirectory(); + return new ConfigurationBuilder() + .SetBasePath(host) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile($"appsettings.{environment}.json", optional: false) + .Build(); + } + + private static string[] OriginsOf(IConfiguration configuration, string section) => + configuration.GetSection(section).Get() ?? []; + + [Fact] + public void Production_Should_TrustNoLocalhostOrigin_When_TheShippedFilesAreLoadedInOrder() + { + var configuration = LoadFor("Production"); + + OriginsOf(configuration, "FrontendOptions:AllowedOrigins").ShouldBeEmpty(); + OriginsOf(configuration, "CorsOptions:AllowedOrigins").ShouldBeEmpty(); + configuration["FrontendOptions:DefaultOrigin"].ShouldBeNullOrEmpty(); + } + + [Fact] + public void Development_Should_TrustTheTwoLocalSpas_When_TheShippedFilesAreLoadedInOrder() + { + var configuration = LoadFor("Development"); + + OriginsOf(configuration, "FrontendOptions:AllowedOrigins") + .ShouldBe(["http://localhost:5173", "http://localhost:5174"]); + configuration["FrontendOptions:DefaultOrigin"].ShouldBe("http://localhost:5174"); + } +} diff --git a/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs index eb5e1ae0bd..bd5244e7a4 100644 --- a/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs +++ b/src/Tests/Identity.Tests/Handlers/ForgotPasswordCommandHandlerTests.cs @@ -1,9 +1,10 @@ +using System.Net; using AutoFixture; -using FSH.Framework.Web.Origin; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Frontend; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; using FSH.Modules.Identity.Features.v1.Users.ForgotPassword; -using Microsoft.Extensions.Options; using NSubstitute; using Shouldly; using Xunit; @@ -13,44 +14,51 @@ namespace Identity.Tests.Handlers; public sealed class ForgotPasswordCommandHandlerTests { private readonly IUserService _userService; - private readonly IOptions _originOptions; + private readonly IFrontendOriginResolver _originResolver; private readonly ForgotPasswordCommandHandler _sut; private readonly IFixture _fixture; public ForgotPasswordCommandHandlerTests() { _userService = Substitute.For(); - _originOptions = Substitute.For>(); - _sut = new ForgotPasswordCommandHandler(_userService, _originOptions); + _originResolver = Substitute.For(); + _sut = new ForgotPasswordCommandHandler(_userService, _originResolver); _fixture = new Fixture(); } [Fact] - public async Task Handle_Should_CallForgotPasswordAsync_When_ValidRequest() + public async Task Handle_Should_CallForgotPasswordAsync_With_ResolvedFrontendOrigin() { // Arrange var command = _fixture.Create(); - var originUrl = "https://test.com"; - _originOptions.Value.Returns(new OriginOptions { OriginUrl = new Uri(originUrl) }); + const string origin = "https://app.example.com"; + _originResolver.ResolveForCurrentRequest().Returns(origin); // Act var result = await _sut.Handle(command, CancellationToken.None); // Assert result.ShouldBe("Password reset email sent."); - await _userService.Received(1).ForgotPasswordAsync(command.Email, Arg.Is(s => s.StartsWith(originUrl)), Arg.Any()); + await _userService.Received(1).ForgotPasswordAsync(command.Email, origin, Arg.Any()); } [Fact] - public async Task Handle_Should_ThrowInvalidOperationException_When_OriginNotConfigured() + public async Task Handle_Should_Propagate_When_OriginResolverThrows() { - // Arrange + // Arrange - a request with a forged Origin header cannot build a reset link. The resolver + // signals that with the 400-mapped CustomException, so that is the type the handler must + // let through: catching it here would turn a rejected origin into a sent e-mail. var command = _fixture.Create(); - _originOptions.Value.Returns(new OriginOptions { OriginUrl = null }); + _originResolver.ResolveForCurrentRequest().Returns(_ => throw new CustomException( + "The request origin is not an allowed front-end origin.", + errors: null, + HttpStatusCode.BadRequest)); // Act & Assert - await Should.ThrowAsync(async () => + var ex = await Should.ThrowAsync(async () => await _sut.Handle(command, CancellationToken.None)); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + await _userService.DidNotReceive().ForgotPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -66,14 +74,13 @@ public async Task Handle_Should_PassCancellationToken_ToUserService() { // Arrange var command = _fixture.Create(); - var originUrl = "https://test.com"; - _originOptions.Value.Returns(new OriginOptions { OriginUrl = new Uri(originUrl) }); + _originResolver.ResolveForCurrentRequest().Returns("https://app.example.com"); using var cts = new CancellationTokenSource(); // Act await _sut.Handle(command, cts.Token); // Assert - await _userService.Received(1).ForgotPasswordAsync(command.Email, Arg.Is(s => s.StartsWith(originUrl)), cts.Token); + await _userService.Received(1).ForgotPasswordAsync(command.Email, Arg.Any(), cts.Token); } } diff --git a/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs b/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs index ee800ef1e9..a26161b7ec 100644 --- a/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs +++ b/src/Tests/Identity.Tests/Services/RequestContextServiceTests.cs @@ -21,8 +21,8 @@ public RequestContextServiceTests() private RequestContextService CreateService(Uri? originUrl = null) { - var options = Options.Create(new OriginOptions { OriginUrl = originUrl }); - return new RequestContextService(_httpContextAccessor, options); + var originOptions = Options.Create(new OriginOptions { OriginUrl = originUrl }); + return new RequestContextService(_httpContextAccessor, originOptions); } private void SetHttpContext(HttpContext? context) diff --git a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs index 4c2939c454..e8b7898023 100644 --- a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs +++ b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs @@ -55,7 +55,8 @@ public sealed class MiddlewareWebApplicationFactory : WebApplicationFactory, I .WithCleanUp(true) .Build(); - private readonly MinioContainer _minio = new MinioBuilder("minio/minio:latest") + // quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. + private readonly MinioContainer _minio = new MinioBuilder("quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z") .WithUsername(MinioAccessKey) .WithPassword(MinioSecretKey) .WithAutoRemove(true) @@ -103,6 +104,15 @@ private async Task CreateMinioBucketAsync() } } + // Browsers always send an Origin header on the cross-origin auth POSTs (forgot-password, register, + // self-register). Simulate that globally so front-end-origin resolution matches the allow-list above. + protected override void ConfigureClient(HttpClient client) + { + ArgumentNullException.ThrowIfNull(client); + client.DefaultRequestHeaders.Add("Origin", "http://localhost"); + base.ConfigureClient(client); + } + protected override void ConfigureWebHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); @@ -123,6 +133,12 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["JwtOptions:AccessTokenMinutes"] = "30", ["JwtOptions:RefreshTokenDays"] = "7", ["OriginOptions:OriginUrl"] = "http://localhost", + ["CorsOptions:AllowedOrigins:0"] = "http://localhost", + // Front-end origin resolution: allow the simulated browser Origin for self-service + // flows, and set the same as the default so operator-driven flows (register/resend) + // and the startup validation both resolve. + ["FrontendOptions:AllowedOrigins:0"] = "http://localhost", + ["FrontendOptions:DefaultOrigin"] = "http://localhost", ["OpenTelemetryOptions:Enabled"] = "false", ["EventingOptions:UseHostedServiceDispatcher"] = "false", ["Serilog:MinimumLevel:Default"] = "Warning", diff --git a/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs b/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs index 244b8a28bc..e8b8092ffb 100644 --- a/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs +++ b/src/Tests/Integration.Tests/Tests/Users/ForgotPasswordRequestTests.cs @@ -69,6 +69,27 @@ public async Task ForgotPassword_Should_Return400_When_EmailIsMalformed() response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); } + [Fact] + public async Task ForgotPassword_Should_Reject_When_OriginNotAllowed() + { + // Arrange - a forged Origin header (not in FrontendOptions:AllowedOrigins) must never build a reset link. + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var user = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, adminClient, "forgot-forged"); + + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); + client.DefaultRequestHeaders.Remove("Origin"); + client.DefaultRequestHeaders.Add("Origin", "https://evil.example.com"); + + // Act + var response = await client.PostAsJsonAsync( + $"{TestConstants.IdentityBasePath}/forgot-password", new { email = user.Email }); + + // Assert - a present-but-unlisted origin is a client fault: 400, not the 500 a server fault + // would raise, and not the uniform OK the happy path returns. + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + [Fact] public async Task ForgotPassword_Should_ReturnUniformOk_When_EmailIsUnknown() {