diff --git a/.agents/rules/security.md b/.agents/rules/security.md index b3fb38404b..ee5368f2d0 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` → `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. 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/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index 50c6568fda..412120abff 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,13 @@ 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 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().BindConfiguration(nameof(FrontendOptions)); + builder.Services.AddScoped(); + return builder; } @@ -143,6 +153,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 (!string.IsNullOrWhiteSpace(frontend.DefaultOrigin)) + { + return; + } + + // Same absolute-Uri guard the resolver applies. + var apiOrigin = app.Services.GetRequiredService>().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 diff --git a/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs new file mode 100644 index 0000000000..9a4ea93570 --- /dev/null +++ b/src/BuildingBlocks/Web/Frontend/FrontendOptions.cs @@ -0,0 +1,45 @@ +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. + /// + /// Strongly recommended, not required. Every deployment resolves through this at some + /// point (operator flows, non-browser callers, jobs). Left unset, the host still starts, logs a + /// single startup Warning and falls back to the API's own origin + /// (OriginOptions:OriginUrl, 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. + /// 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..d4fb7d14a1 --- /dev/null +++ b/src/BuildingBlocks/Web/Frontend/FrontendOriginResolver.cs @@ -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 options, + IOptions originOptions, + 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 = 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(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/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..b81ab37269 100644 --- a/src/Host/FSH.Starter.Api/appsettings.json +++ b/src/Host/FSH.Starter.Api/appsettings.json @@ -103,6 +103,13 @@ "AllowedHeaders": [ "content-type", "authorization" ], "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] }, + "FrontendOptions": { + "AllowedOrigins": [ + "http://localhost:5173", + "http://localhost:5174" + ], + "DefaultOrigin": "http://localhost:5174" + }, "JwtOptions": { "Issuer": "fsh.local", "Audience": "fsh.clients", 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..8ee428875e --- /dev/null +++ b/src/Tests/Framework.Tests/Web/FrontendOriginResolverTests.cs @@ -0,0 +1,296 @@ +using System.Net; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Frontend; +using FSH.Framework.Web.Origin; +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, falling back to the configured default and, when +/// that is unset, to the API's own origin. +/// +public sealed class FrontendOriginResolverTests +{ + private readonly IHttpContextAccessor _httpContextAccessor = Substitute.For(); + + private FrontendOriginResolver CreateResolver(string[] allowedOrigins, string? defaultOrigin = null, string? apiOrigin = null) + { + var options = Options.Create(new FrontendOptions + { + AllowedOrigins = allowedOrigins, + DefaultOrigin = defaultOrigin, + }); + var originOptions = Options.Create(new OriginOptions + { + OriginUrl = apiOrigin is null ? null : new Uri(apiOrigin, UriKind.RelativeOrAbsolute), + }); + return new FrontendOriginResolver(_httpContextAccessor, options, originOptions, 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_FallBackToApiOrigin_When_DefaultNotConfigured() + { + // An upgrader who never sets DefaultOrigin must keep booting and keep sending links: they + // land on the API's own origin instead of the SPA, and startup warns about the degradation. + var resolver = CreateResolver([], defaultOrigin: null, apiOrigin: "https://api.example.com"); + + resolver.ResolveDefault().ShouldBe("https://api.example.com"); + } + + [Fact] + public void ResolveDefault_Should_FallBackToApiOrigin_When_DefaultIsEmptyString() + { + // appsettings.Production.json ships "DefaultOrigin": "" — the empty string must take the + // same fallback path as an absent key, not resolve to an empty link. + var resolver = CreateResolver([], defaultOrigin: "", apiOrigin: "https://api.example.com/"); + + resolver.ResolveDefault().ShouldBe("https://api.example.com"); + } + + [Fact] + public void ResolveDefault_Should_PreferConfiguredDefault_Over_ApiOrigin() + { + var resolver = CreateResolver([], defaultOrigin: "https://app.example.com", apiOrigin: "https://api.example.com"); + + resolver.ResolveDefault().ShouldBe("https://app.example.com"); + } + + [Fact] + public void ResolveDefault_Should_IgnoreApiOrigin_When_NotAbsolute() + { + // OriginOptions:OriginUrl also ships as "" in Production, which binds to a relative Uri. + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var resolver = CreateResolver([], defaultOrigin: null, apiOrigin: ""); + + var ex = Should.Throw(() => resolver.ResolveDefault()); + ex.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + public void ResolveDefault_Should_FallBackToRequestHost_When_NothingConfigured() + { + // The both-empty upgrade case: appsettings.Production.json ships DefaultOrigin AND + // OriginUrl empty, so the link still has to resolve — to the API's own host, which is + // where register / self-register / resend built their links before this resolver existed. + SetRequestHost("https", "api.example.com"); + var resolver = CreateResolver([], defaultOrigin: null); + + resolver.ResolveDefault().ShouldBe("https://api.example.com"); + } + + [Fact] + public void ResolveDefault_Should_PreferApiOrigin_Over_RequestHost() + { + SetRequestHost("https", "internal.cluster.local"); + var resolver = CreateResolver([], defaultOrigin: null, apiOrigin: "https://api.example.com"); + + resolver.ResolveDefault().ShouldBe("https://api.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_FallBackToApiOrigin_When_NoHeaderAndNoDefault() + { + // The no-header path routes through ResolveDefault, so it inherits the same fallback. + SetOriginHeader(null); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: null, apiOrigin: "https://api.example.com"); + + resolver.ResolveForCurrentRequest().ShouldBe("https://api.example.com"); + } + + [Fact] + public void ResolveForCurrentRequest_Should_StillReject_ForgedHeader_When_NoDefault() + { + // The boot-safety fallback must not soften the security contract: a present-but-unlisted + // Origin is still a 400, never quietly swapped for the API origin. + SetOriginHeader("https://evil.example.com"); + var resolver = CreateResolver(["http://localhost:5173"], defaultOrigin: null, apiOrigin: "https://api.example.com"); + + var ex = Should.Throw(() => resolver.ResolveForCurrentRequest()); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } +} 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.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index ab8cfe3c65..0f215afa8a 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -103,6 +103,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 +132,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() {