From 9fbbd85ffcf2ec3a96fbb1e5d9817ffe7d1b1752 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 27 Apr 2026 20:14:35 -0700 Subject: [PATCH 01/18] Add Headers, Redirects, and NavigationFallback.Exclude routing slice Routes-first design for the YARP container app's static-host pipeline: - Redirects -> Map(...).ShortCircuit() routed endpoints (early order). - NavigationFallback.Exclude -> MapFallback(...) endpoints ordered just ahead of the SPA fallback so proxy and other real routes still win. - Headers -> middleware that targets static-file responses (OnPrepareResponse) and the SPA fallback (OnStarting via endpoint metadata). Static files are not endpoints, so this remains the only consumer of RequestMatchEvaluator.TryMatch. - StaticFilesFeature wraps UseFileServer to stash/clear and restore the selected endpoint, preserving the rule that static files beat routed fallback endpoints while routed real endpoints still win. - RequestMatchEvaluator exposes a static ValidatePath helper so callers that delegate matching to ASP.NET routing skip the TemplateMatcher allocation entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Application/Configuration/HeaderRule.cs | 11 + .../NavigationFallbackOptions.cs | 2 + src/Application/Configuration/RedirectRule.cs | 13 + src/Application/Configuration/RequestMatch.cs | 9 + .../Configuration/YarpAppConfig.cs | 2 + .../Configuration/YarpAppConfigBinder.cs | 2 + .../NavigationFallbackEndpointMetadata.cs | 15 ++ .../NavigationFallbackExclusionsFeature.cs | 41 +++ .../Features/NavigationFallbackFeature.cs | 6 +- src/Application/Features/RedirectsFeature.cs | 90 +++++++ .../Features/RequestMatchEvaluator.cs | 60 +++++ .../Features/StaticFilesFeature.cs | 53 +++- .../Features/StaticHostHeadersFeature.cs | 120 +++++++++ src/Application/Program.cs | 5 +- src/Application/README.md | 73 +++++- src/Application/yarp-config.schema.json | 66 +++++ test/Application.Tests/SpaFallbackTests.cs | 239 ++++++++++++++++++ .../YarpAppConfigBinderTests.cs | 58 +++++ 18 files changed, 861 insertions(+), 4 deletions(-) create mode 100644 src/Application/Configuration/HeaderRule.cs create mode 100644 src/Application/Configuration/RedirectRule.cs create mode 100644 src/Application/Configuration/RequestMatch.cs create mode 100644 src/Application/Features/NavigationFallbackEndpointMetadata.cs create mode 100644 src/Application/Features/NavigationFallbackExclusionsFeature.cs create mode 100644 src/Application/Features/RedirectsFeature.cs create mode 100644 src/Application/Features/RequestMatchEvaluator.cs create mode 100644 src/Application/Features/StaticHostHeadersFeature.cs diff --git a/src/Application/Configuration/HeaderRule.cs b/src/Application/Configuration/HeaderRule.cs new file mode 100644 index 000000000..59ce0d31a --- /dev/null +++ b/src/Application/Configuration/HeaderRule.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +public sealed class HeaderRule +{ + public RequestMatch Match { get; set; } = new(); + + public Dictionary Set { get; set; } = []; +} diff --git a/src/Application/Configuration/NavigationFallbackOptions.cs b/src/Application/Configuration/NavigationFallbackOptions.cs index fb724e118..8707dd651 100644 --- a/src/Application/Configuration/NavigationFallbackOptions.cs +++ b/src/Application/Configuration/NavigationFallbackOptions.cs @@ -6,4 +6,6 @@ namespace Yarp.Application.Configuration; public sealed class NavigationFallbackOptions { public string? Path { get; set; } + + public List Exclude { get; set; } = []; } diff --git a/src/Application/Configuration/RedirectRule.cs b/src/Application/Configuration/RedirectRule.cs new file mode 100644 index 000000000..3072de2ef --- /dev/null +++ b/src/Application/Configuration/RedirectRule.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +public sealed class RedirectRule +{ + public RequestMatch Match { get; set; } = new(); + + public string? Destination { get; set; } + + public int StatusCode { get; set; } = 301; +} diff --git a/src/Application/Configuration/RequestMatch.cs b/src/Application/Configuration/RequestMatch.cs new file mode 100644 index 000000000..10b6972f9 --- /dev/null +++ b/src/Application/Configuration/RequestMatch.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +public sealed class RequestMatch +{ + public string? Path { get; set; } +} diff --git a/src/Application/Configuration/YarpAppConfig.cs b/src/Application/Configuration/YarpAppConfig.cs index c55e0439f..d1e802306 100644 --- a/src/Application/Configuration/YarpAppConfig.cs +++ b/src/Application/Configuration/YarpAppConfig.cs @@ -11,5 +11,7 @@ public sealed class YarpAppConfig { public StaticFilesOptions StaticFiles { get; set; } = new(); public NavigationFallbackOptions NavigationFallback { get; set; } = new(); + public List Headers { get; set; } = []; + public List Redirects { get; set; } = []; public TelemetryOptions Telemetry { get; set; } = new(); } diff --git a/src/Application/Configuration/YarpAppConfigBinder.cs b/src/Application/Configuration/YarpAppConfigBinder.cs index 5ecffec4e..7e189e294 100644 --- a/src/Application/Configuration/YarpAppConfigBinder.cs +++ b/src/Application/Configuration/YarpAppConfigBinder.cs @@ -17,6 +17,8 @@ public static YarpAppConfig Bind(IConfiguration configuration) configuration.GetSection(nameof(config.StaticFiles)).Bind(config.StaticFiles); configuration.GetSection(nameof(config.NavigationFallback)).Bind(config.NavigationFallback); + configuration.GetSection(nameof(config.Headers)).Bind(config.Headers); + configuration.GetSection(nameof(config.Redirects)).Bind(config.Redirects); configuration.GetSection(nameof(config.Telemetry)).Bind(config.Telemetry); // Legacy env var support diff --git a/src/Application/Features/NavigationFallbackEndpointMetadata.cs b/src/Application/Features/NavigationFallbackEndpointMetadata.cs new file mode 100644 index 000000000..85eb0babc --- /dev/null +++ b/src/Application/Features/NavigationFallbackEndpointMetadata.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Features; + +// Marker metadata applied to the built-in SPA fallback endpoint. +// Static files do not become endpoints, so only fallback-specific middleware relies on this. +internal sealed class NavigationFallbackEndpointMetadata +{ + public static NavigationFallbackEndpointMetadata Instance { get; } = new(); + + private NavigationFallbackEndpointMetadata() + { + } +} diff --git a/src/Application/Features/NavigationFallbackExclusionsFeature.cs b/src/Application/Features/NavigationFallbackExclusionsFeature.cs new file mode 100644 index 000000000..02bdc091e --- /dev/null +++ b/src/Application/Features/NavigationFallbackExclusionsFeature.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class NavigationFallbackExclusionsFeature +{ + public static WebApplication MapNavigationFallbackExclusions(this WebApplication app, YarpAppConfig config) + { + if (config.NavigationFallback.Path is null || config.NavigationFallback.Exclude.Count == 0) + { + return app; + } + + for (var i = 0; i < config.NavigationFallback.Exclude.Count; i++) + { + var path = RequestMatchEvaluator.ValidatePath(config.NavigationFallback.Exclude[i], "NavigationFallback exclusion"); + app.MapFallback( + path, + context => + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + }) + .Add(endpointBuilder => + { + endpointBuilder.DisplayName = $"Fallback exclusion {path}"; + // Keep exclusions just ahead of the SPA fallback while still letting proxy + // routes and other normal endpoints win when they match. + ((RouteEndpointBuilder)endpointBuilder).Order = int.MaxValue - 1000 + i; + }); + } + + return app; + } +} diff --git a/src/Application/Features/NavigationFallbackFeature.cs b/src/Application/Features/NavigationFallbackFeature.cs index 9ba98a9ac..1df9a7e0d 100644 --- a/src/Application/Features/NavigationFallbackFeature.cs +++ b/src/Application/Features/NavigationFallbackFeature.cs @@ -12,7 +12,11 @@ public static WebApplication MapNavigationFallback(this WebApplication app, Yarp { if (config.NavigationFallback.Path is not null) { - app.MapFallbackToFile(config.NavigationFallback.Path); + var fallback = app.MapFallbackToFile(config.NavigationFallback.Path); + // Mark the SPA fallback endpoint so the fallback-specific middleware can distinguish + // it from reverse proxy endpoints, while static files continue to flow through + // StaticFileMiddleware without endpoint metadata. + fallback.Add(endpointBuilder => endpointBuilder.Metadata.Add(NavigationFallbackEndpointMetadata.Instance)); } return app; diff --git a/src/Application/Features/RedirectsFeature.cs b/src/Application/Features/RedirectsFeature.cs new file mode 100644 index 000000000..e667c60c5 --- /dev/null +++ b/src/Application/Features/RedirectsFeature.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class RedirectsFeature +{ + public static WebApplication MapRedirects(this WebApplication app, YarpAppConfig config) + { + if (config.Redirects.Count == 0) + { + return app; + } + + for (var i = 0; i < config.Redirects.Count; i++) + { + var rule = new CompiledRedirectRule(config.Redirects[i]); + app.Map( + rule.Path, + context => + { + context.Response.StatusCode = rule.StatusCode; + context.Response.Headers.Location = rule.BuildDestination(context.Request.RouteValues); + return Task.CompletedTask; + }) + // Redirects need to run ahead of static files, so execute them directly from + // endpoint routing instead of waiting for the normal endpoint middleware. + .ShortCircuit() + .Add(endpointBuilder => + { + endpointBuilder.DisplayName = $"Redirect {rule.Path}"; + ((RouteEndpointBuilder)endpointBuilder).Order = -1000 + i; + }); + } + + return app; + } + + private sealed class CompiledRedirectRule + { + private static readonly HashSet AllowedStatusCodes = [301, 302, 307, 308]; + + public CompiledRedirectRule(RedirectRule rule) + { + Path = RequestMatchEvaluator.ValidatePath(rule.Match, "Redirect rules"); + + if (string.IsNullOrWhiteSpace(rule.Destination)) + { + throw new InvalidOperationException( + $"Redirect rule '{Path}' requires a non-empty Destination."); + } + + if (!AllowedStatusCodes.Contains(rule.StatusCode)) + { + throw new InvalidOperationException( + $"Redirect rule '{Path}' has unsupported status code '{rule.StatusCode}'. Expected one of: 301, 302, 307, 308."); + } + + Destination = rule.Destination; + StatusCode = rule.StatusCode; + } + + public string Destination { get; } + + public int StatusCode { get; } + + public string Path { get; } + + public string BuildDestination(RouteValueDictionary values) + { + if (values.Count == 0 || Destination.IndexOf('{') < 0) + { + return Destination; + } + + var builder = new System.Text.StringBuilder(Destination); + foreach (var value in values) + { + builder.Replace("{" + value.Key + "}", value.Value?.ToString() ?? string.Empty); + } + + return builder.ToString(); + } + } +} diff --git a/src/Application/Features/RequestMatchEvaluator.cs b/src/Application/Features/RequestMatchEvaluator.cs new file mode 100644 index 000000000..daada4e9d --- /dev/null +++ b/src/Application/Features/RequestMatchEvaluator.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Template; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +internal sealed class RequestMatchEvaluator +{ + private readonly TemplateMatcher _pathMatcher; + + public RequestMatchEvaluator(RequestMatch match, string ruleDisplayName) + { + var path = ValidatePath(match, ruleDisplayName); + Path = path; + _pathMatcher = new TemplateMatcher(TemplateParser.Parse(path), new RouteValueDictionary()); + } + + public string Path { get; } + + /// + /// Validates that has a non-empty + /// and returns it. Use this when only the path string is needed (e.g. when matching is + /// delegated to ASP.NET endpoint routing) and a full is + /// unnecessary. + /// + public static string ValidatePath(RequestMatch match, string ruleDisplayName) + { + if (match is null) + { + throw new InvalidOperationException($"{ruleDisplayName} requires a Match object."); + } + + if (string.IsNullOrWhiteSpace(match.Path)) + { + throw new InvalidOperationException($"{ruleDisplayName} requires Match.Path to be set."); + } + + return match.Path; + } + + public bool TryMatch(HttpContext context, RouteValueDictionary values) + { + ArgumentNullException.ThrowIfNull(context); + return TryMatch(context.Request.Path, values); + } + + public bool TryMatch(PathString path, RouteValueDictionary values) + { + ArgumentNullException.ThrowIfNull(values); + + // Route matching expects a rooted request path. Normal requests already have one, but + // normalize the empty-path case so "/" behaves consistently in tests and callbacks. + path = string.IsNullOrEmpty(path.Value) ? new PathString("/") : path; + return _pathMatcher.TryMatch(path, values); + } +} diff --git a/src/Application/Features/StaticFilesFeature.cs b/src/Application/Features/StaticFilesFeature.cs index 32dca2a31..bceead258 100644 --- a/src/Application/Features/StaticFilesFeature.cs +++ b/src/Application/Features/StaticFilesFeature.cs @@ -2,18 +2,69 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Yarp.Application.Configuration; namespace Yarp.Application.Features; public static class StaticFilesFeature { + private static readonly object PreservedEndpointKey = new(); + public static WebApplication UseStaticFiles(this WebApplication app, YarpAppConfig config) { - if (config.StaticFiles.Enabled) + if (!config.StaticFiles.Enabled) + { + return app; + } + + app.Use((context, next) => + { + var endpoint = context.GetEndpoint(); + if (endpoint?.RequestDelegate is null) + { + return next(); + } + + // Redirects short-circuit during UseRouting, but normal routed endpoints would block + // StaticFileMiddleware once UseRouting runs first. Clear the selected endpoint around + // UseFileServer so static files still win when a physical file exists, then restore it + // for later middleware and endpoint execution if no file handled the request. + context.Items[PreservedEndpointKey] = endpoint; + context.SetEndpoint(null); + return next(); + }); + + var onPrepareResponse = StaticHostHeadersFeature.CreateStaticFileHeaderCallback(config); + if (onPrepareResponse is null) { app.UseFileServer(); } + else + { + // UseFileServer keeps default documents + static file serving together; only the + // response preparation callback changes when header rules are configured. + app.UseFileServer(new FileServerOptions + { + StaticFileOptions = + { + OnPrepareResponse = onPrepareResponse + } + }); + } + + app.Use((context, next) => + { + if (!context.Response.HasStarted + && context.GetEndpoint() is null + && context.Items.TryGetValue(PreservedEndpointKey, out var endpoint) + && endpoint is Endpoint preservedEndpoint) + { + context.SetEndpoint(preservedEndpoint); + } + + return next(); + }); return app; } diff --git a/src/Application/Features/StaticHostHeadersFeature.cs b/src/Application/Features/StaticHostHeadersFeature.cs new file mode 100644 index 000000000..450e43161 --- /dev/null +++ b/src/Application/Features/StaticHostHeadersFeature.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class StaticHostHeadersFeature +{ + public static WebApplication UseStaticHostHeaders(this WebApplication app, YarpAppConfig config) + { + var headerRules = CompileHeaderRules(config); + if (headerRules.Length == 0) + { + return app; + } + + app.Use((context, next) => + { + // This middleware runs after UseRouting. The SPA fallback endpoint carries explicit + // metadata, while static files use OnPrepareResponse because they are not endpoints. + if (context.GetEndpoint()?.Metadata.GetMetadata() is null) + { + return next(); + } + + var requestPath = context.Request.Path; + + context.Response.OnStarting( + static state => + { + var (httpContext, originalPath, rules) = ((HttpContext, PathString, CompiledHeaderRule[]))state; + ApplyHeaders(originalPath, httpContext.Response.Headers, rules); + return Task.CompletedTask; + }, + (context, requestPath, headerRules)); + + return next(); + }); + + return app; + } + + internal static Action? CreateStaticFileHeaderCallback(YarpAppConfig config) + { + var headerRules = CompileHeaderRules(config); + if (headerRules.Length == 0) + { + return null; + } + + // StaticFileMiddleware doesn't create endpoints, so apply the same header rules through + // OnPrepareResponse to keep static files and SPA fallback behavior aligned. + return context => ApplyHeaders(context.Context.Request.Path, context.Context.Response.Headers, headerRules); + } + + private static CompiledHeaderRule[] CompileHeaderRules(YarpAppConfig config) + => config.Headers.Select(rule => new CompiledHeaderRule(rule)).ToArray(); + + private static void ApplyHeaders(PathString requestPath, IHeaderDictionary headers, CompiledHeaderRule[] headerRules) + { + foreach (var headerRule in headerRules) + { + headerRule.Apply(requestPath, headers); + } + } + + private sealed class CompiledHeaderRule + { + private readonly RequestMatchEvaluator _matcher; + private readonly KeyValuePair[] _headers; + + public CompiledHeaderRule(HeaderRule rule) + { + if (rule.Set.Count == 0) + { + throw new InvalidOperationException( + $"Header rule '{rule.Match.Path ?? ""}' must set at least one header."); + } + + _matcher = new RequestMatchEvaluator(rule.Match, "Header rules"); + _headers = rule.Set + .Select(pair => + { + if (string.IsNullOrWhiteSpace(pair.Key)) + { + throw new InvalidOperationException( + $"Header rule '{rule.Match.Path ?? ""}' contains an empty header name."); + } + + if (pair.Value is null) + { + throw new InvalidOperationException( + $"Header rule '{rule.Match.Path ?? ""}' contains a null value for header '{pair.Key}'."); + } + + return new KeyValuePair(pair.Key, pair.Value); + }) + .ToArray(); + } + + public void Apply(PathString requestPath, IHeaderDictionary headers) + { + if (!_matcher.TryMatch(requestPath, new())) + { + return; + } + + foreach (var header in _headers) + { + // Rules are additive at the config level, but later matches overwrite the same + // header name so users can layer broad defaults with narrow exceptions. + headers[header.Key] = header.Value; + } + } + } +} diff --git a/src/Application/Program.cs b/src/Application/Program.cs index ead454944..d8a2a73d0 100644 --- a/src/Application/Program.cs +++ b/src/Application/Program.cs @@ -65,9 +65,12 @@ LoggingFeature.PrintBanner(config, configFilePath, app); // Middleware pipeline — order matters -app.UseStaticFiles(config); app.UseRouting(); +app.UseStaticFiles(config); +app.UseStaticHostHeaders(config); +app.MapRedirects(config); app.MapReverseProxy(); +app.MapNavigationFallbackExclusions(config); app.MapNavigationFallback(config); await app.RunAsync(); diff --git a/src/Application/README.md b/src/Application/README.md index a597ca668..9eaf2d154 100644 --- a/src/Application/README.md +++ b/src/Application/README.md @@ -75,6 +75,8 @@ Simple toggles work as environment variables. Complex config (proxy routes, etc. All configuration goes through `IConfiguration` — JSON files, environment variables, or any other provider. See [`yarp-config.schema.json`](yarp-config.schema.json) for IDE autocomplete and validation. +Route-like features use ASP.NET route pattern syntax: `Headers` and `Redirects` match on `Match.Path`, and fallback exclusions use the same syntax in `Exclude[].Path`. + ### `StaticFiles` Serve static files from `wwwroot/`. @@ -88,9 +90,71 @@ Serve static files from `wwwroot/`. SPA fallback — serve a file (typically `index.html`) for unmatched routes so client-side routing works. ```json -{ "NavigationFallback": { "Path": "/index.html" } } +{ + "NavigationFallback": { + "Path": "/index.html", + "Exclude": [ + { "Path": "/api/{**catch-all}" }, + { "Path": "/.well-known/{**catch-all}" } + ] + } +} +``` + +### `Headers` + +Response header rules for static-file and SPA-fallback responses. All matching rules are applied. + +```json +{ + "Headers": [ + { + "Match": { + "Path": "/{**path}" + }, + "Set": { + "X-Content-Type-Options": "nosniff" + } + }, + { + "Match": { + "Path": "/_astro/{**path}" + }, + "Set": { + "Cache-Control": "public, max-age=31536000, immutable" + } + } + ] +} +``` + +### `Redirects` + +Declarative redirects. Rules are evaluated in order and the first match wins. + +```json +{ + "Redirects": [ + { + "Match": { + "Path": "/old-page" + }, + "Destination": "/new-page", + "StatusCode": 301 + }, + { + "Match": { + "Path": "/install.sh" + }, + "Destination": "https://aka.ms/install.sh", + "StatusCode": 302 + } + ] +} ``` +`Destination` can reference route values captured by `Match.Path`, such as `{slug}`. + ### `ReverseProxy` YARP reverse proxy routes and clusters. See the [YARP configuration docs](https://learn.microsoft.com/aspnet/core/fundamentals/servers/yarp/config-files) for the full reference. @@ -131,12 +195,19 @@ This is an opinionated, pre-built application — not an extensible framework. U Configuration/ Config model (IConfiguration → POCOs) YarpAppConfig.cs Root config object YarpAppConfigBinder.cs Single conversion point + legacy key mapping + RequestMatch.cs Shared route-style match object StaticFilesOptions.cs Per-feature options NavigationFallbackOptions.cs + HeaderRule.cs + RedirectRule.cs TelemetryOptions.cs Features/ Per-feature extension methods + RequestMatchEvaluator.cs Route-template-based match evaluation StaticFilesFeature.cs NavigationFallbackFeature.cs + NavigationFallbackExclusionsFeature.cs + RedirectsFeature.cs + StaticHostHeadersFeature.cs ReverseProxyFeature.cs LoggingFeature.cs Program.cs Pipeline ordering diff --git a/src/Application/yarp-config.schema.json b/src/Application/yarp-config.schema.json index 35a561486..085763458 100644 --- a/src/Application/yarp-config.schema.json +++ b/src/Application/yarp-config.schema.json @@ -3,6 +3,20 @@ "title": "YARP Container Configuration", "description": "Configuration schema for the YARP container application", "type": "object", + "definitions": { + "requestMatch": { + "type": "object", + "description": "Request match criteria. Path uses ASP.NET route pattern syntax.", + "properties": { + "Path": { + "type": "string", + "description": "Request path route pattern to match" + } + }, + "required": ["Path"], + "additionalProperties": false + } + }, "properties": { "StaticFiles": { "type": "object", @@ -23,10 +37,62 @@ "Path": { "type": "string", "description": "File to serve for unmatched non-file routes (e.g., /index.html)" + }, + "Exclude": { + "type": "array", + "description": "Route-style path matches that bypass SPA fallback", + "items": { + "$ref": "#/definitions/requestMatch" + } } }, "additionalProperties": false }, + "Headers": { + "type": "array", + "description": "Response header rules for static-host responses only. All matching rules are applied.", + "items": { + "type": "object", + "properties": { + "Match": { + "$ref": "#/definitions/requestMatch" + }, + "Set": { + "type": "object", + "description": "Headers to set on matching responses", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["Match", "Set"], + "additionalProperties": false + } + }, + "Redirects": { + "type": "array", + "description": "Redirect rules. First matching rule wins.", + "items": { + "type": "object", + "properties": { + "Match": { + "$ref": "#/definitions/requestMatch" + }, + "Destination": { + "type": "string", + "description": "Redirect target (relative or absolute)" + }, + "StatusCode": { + "type": "integer", + "enum": [301, 302, 307, 308], + "default": 301, + "description": "HTTP redirect status code" + } + }, + "required": ["Match", "Destination"], + "additionalProperties": false + } + }, "Telemetry": { "type": "object", "description": "Telemetry configuration. OTLP export uses standard OTEL_* env vars.", diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index 9d432a813..69d5af23d 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -75,6 +75,12 @@ private static YarpTestApp CreateApp(Dictionary? config = null) ["YARP_ENABLE_STATIC_FILES"] = "true" }; + private static HttpClient CreateNoRedirectClient(WebApplicationFactory factory) + => factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + [Fact] public async Task SpaFallback_ReturnsIndexHtml_ForUnknownRoutes() { @@ -234,6 +240,25 @@ public async Task SpaFallback_CatchAllYarpRoute_WinsOverFallback() Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); } + [Fact] + public async Task StaticFiles_StillWinOverCatchAllYarpRoute() + { + using var factory = CreateApp(new Dictionary() + { + ["YARP_ENABLE_STATIC_FILES"] = "true", + ["ReverseProxy:Routes:catchall:ClusterId"] = "backend", + ["ReverseProxy:Routes:catchall:Match:Path"] = "{**catch-all}", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = "https://localhost:9999" + }); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + // Tests using the strongly-typed config object model [Fact] @@ -287,6 +312,220 @@ public async Task ObjectModel_CustomFallbackPath() Assert.Contains("SPA Index", content); } + [Fact] + public async Task ObjectModel_FallbackExclude_Returns404_ForExcludedPaths() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/api/v1/users"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ObjectModel_FallbackExclude_DoesNotAffectOtherSpaRoutes() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/dashboard/settings"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("SPA Index", content); + } + + [Fact] + public async Task ObjectModel_FallbackExclude_DoesNotAffectStaticFiles() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/style.css" } } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + + [Fact] + public async Task ObjectModel_FallbackExclude_DoesNotAffectReverseProxyRoutes() + { + using var factory = CreateApp(new Dictionary() + { + ["StaticFiles:Enabled"] = "true", + ["NavigationFallback:Path"] = "/index.html", + ["NavigationFallback:Exclude:0:Path"] = "/api/{**catch-all}", + ["ReverseProxy:Routes:api:ClusterId"] = "backend", + ["ReverseProxy:Routes:api:Match:Path"] = "/api/{**catch-all}", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = "https://localhost:9999" + }); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/ping"); + + Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ObjectModel_HeaderRules_ApplyToStaticFiles() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/{**path}" }, + Set = { ["X-Test"] = "applied" } + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("applied", response.Headers.GetValues("X-Test").Single()); + } + + [Fact] + public async Task ObjectModel_HeaderRules_ApplyToFallbackResponses() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = { Path = "/index.html" }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/{**path}" }, + Set = { ["X-Test"] = "applied" } + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/docs/spa/route"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("applied", response.Headers.GetValues("X-Test").Single()); + } + + [Fact] + public async Task HeaderRules_DoNotApplyToProxiedResponses() + { + using var factory = CreateApp(new Dictionary() + { + ["StaticFiles:Enabled"] = "true", + ["Headers:0:Match:Path"] = "/{**path}", + ["Headers:0:Set:X-Test"] = "applied", + ["ReverseProxy:Routes:api:ClusterId"] = "backend", + ["ReverseProxy:Routes:api:Match:Path"] = "/api/{**catch-all}", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = "https://localhost:9999" + }); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/ping"); + + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("X-Test")); + } + + [Fact] + public async Task ObjectModel_Redirects_RunBeforeStaticFiles() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Redirects = + { + new RedirectRule + { + Match = new RequestMatch { Path = "/style.css" }, + Destination = "/redirected.css", + StatusCode = 302 + } + } + }); + using var client = CreateNoRedirectClient(app); + + var response = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("/redirected.css", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task Redirects_RunBeforeReverseProxy() + { + using var factory = CreateApp(new Dictionary() + { + ["Redirects:0:Match:Path"] = "/api/{**catch-all}", + ["Redirects:0:Destination"] = "/docs", + ["Redirects:0:StatusCode"] = "302", + ["ReverseProxy:Routes:api:ClusterId"] = "backend", + ["ReverseProxy:Routes:api:Match:Path"] = "/api/{**catch-all}", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = "https://localhost:9999" + }); + using var client = CreateNoRedirectClient(factory); + + var response = await client.GetAsync("/api/ping"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("/docs", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task ObjectModel_Redirects_CanUseRouteValuesInDestination() + { + using var app = CreateApp(new YarpAppConfig + { + Redirects = + { + new RedirectRule + { + Match = new RequestMatch { Path = "/docs/{**slug}" }, + Destination = "/articles/{slug}", + StatusCode = 302 + } + } + }); + using var client = CreateNoRedirectClient(app); + + var response = await client.GetAsync("/docs/getting-started/install"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("/articles/getting-started/install", response.Headers.Location?.ToString()); + } + [Fact] public async Task ObjectModel_EverythingDisabled() { diff --git a/test/Application.Tests/YarpAppConfigBinderTests.cs b/test/Application.Tests/YarpAppConfigBinderTests.cs index c798fa801..5835e6d78 100644 --- a/test/Application.Tests/YarpAppConfigBinderTests.cs +++ b/test/Application.Tests/YarpAppConfigBinderTests.cs @@ -33,6 +33,61 @@ public void Bind_NavigationFallbackPath() Assert.Equal("/app.html", config.NavigationFallback.Path); } + [Fact] + public void Bind_NavigationFallbackExclude() + { + var config = Bind(new() + { + ["NavigationFallback:Exclude:0:Path"] = "/api/{**catch-all}", + ["NavigationFallback:Exclude:1:Path"] = "/.well-known/{**catch-all}" + }); + Assert.Collection( + config.NavigationFallback.Exclude, + match => Assert.Equal("/api/{**catch-all}", match.Path), + match => Assert.Equal("/.well-known/{**catch-all}", match.Path)); + } + + [Fact] + public void Bind_HeaderRules() + { + var config = Bind(new() + { + ["Headers:0:Match:Path"] = "/{**path}", + ["Headers:0:Set:X-Test"] = "applied", + ["Headers:1:Match:Path"] = "/_astro/{**path}", + ["Headers:1:Set:Cache-Control"] = "public, max-age=31536000, immutable" + }); + + Assert.Collection( + config.Headers, + rule => + { + Assert.Equal("/{**path}", rule.Match.Path); + Assert.Equal("applied", rule.Set["X-Test"]); + }, + rule => + { + Assert.Equal("/_astro/{**path}", rule.Match.Path); + Assert.Equal("public, max-age=31536000, immutable", rule.Set["Cache-Control"]); + }); + } + + [Fact] + public void Bind_RedirectRules() + { + var config = Bind(new() + { + ["Redirects:0:Match:Path"] = "/old-page", + ["Redirects:0:Destination"] = "/new-page", + ["Redirects:0:StatusCode"] = "302" + }); + + var rule = Assert.Single(config.Redirects); + Assert.Equal("/old-page", rule.Match.Path); + Assert.Equal("/new-page", rule.Destination); + Assert.Equal(302, rule.StatusCode); + } + [Fact] public void Bind_TelemetryUnsafeCert() { @@ -46,6 +101,9 @@ public void Bind_Defaults_EverythingOff() var config = Bind(new()); Assert.False(config.StaticFiles.Enabled); Assert.Null(config.NavigationFallback.Path); + Assert.Empty(config.NavigationFallback.Exclude); + Assert.Empty(config.Headers); + Assert.Empty(config.Redirects); Assert.False(config.Telemetry.UnsafeAcceptAnyCertificate); } From 48eb56573e56ac2b4908d01320b001c9c69c59a2 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 27 Apr 2026 20:18:25 -0700 Subject: [PATCH 02/18] Add Rewrites: pre-routing path rewrites for the YARP container app Delegates to the standard ASP.NET rewrite middleware (Microsoft.AspNetCore.Rewrite) instead of inventing a new syntax. Config maps directly to RewriteOptions.AddRewrite parameters: { "Regex": "^old/(.*)$", "Replacement": "new/$1" } Slots in before UseRouting so every downstream stage (route matching, static files, redirects, SPA fallback, reverse proxy) sees the rewritten path. SkipRemainingRules defaults to true (first match wins); set false to chain rewrites. 7 new tests cover passthrough, capture-group substitution, ordering vs. routing/redirects/proxy/fallback exclusions, and the no-chaining default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Application/Configuration/RewriteRule.cs | 20 ++ .../Configuration/YarpAppConfig.cs | 1 + .../Configuration/YarpAppConfigBinder.cs | 1 + src/Application/Features/RedirectsFeature.cs | 15 +- .../Features/RequestMatchEvaluator.cs | 20 ++ src/Application/Features/RewritesFeature.cs | 47 +++++ src/Application/Program.cs | 1 + src/Application/README.md | 25 ++- src/Application/yarp-config.schema.json | 24 +++ test/Application.Tests/SpaFallbackTests.cs | 183 ++++++++++++++++++ 10 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 src/Application/Configuration/RewriteRule.cs create mode 100644 src/Application/Features/RewritesFeature.cs diff --git a/src/Application/Configuration/RewriteRule.cs b/src/Application/Configuration/RewriteRule.cs new file mode 100644 index 000000000..9b99f08a3 --- /dev/null +++ b/src/Application/Configuration/RewriteRule.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Yarp.Application.Configuration; + +/// +/// URL rewrite rule. Maps directly to +/// . +/// +public sealed class RewriteRule +{ + /// The regular expression to match against the request path. + public string? Regex { get; set; } + + /// The replacement string. May reference regex capture groups via $1, $2, etc. + public string? Replacement { get; set; } + + /// If true (default), skip remaining rules when this rule matches. + public bool SkipRemainingRules { get; set; } = true; +} diff --git a/src/Application/Configuration/YarpAppConfig.cs b/src/Application/Configuration/YarpAppConfig.cs index d1e802306..ae5cd8900 100644 --- a/src/Application/Configuration/YarpAppConfig.cs +++ b/src/Application/Configuration/YarpAppConfig.cs @@ -13,5 +13,6 @@ public sealed class YarpAppConfig public NavigationFallbackOptions NavigationFallback { get; set; } = new(); public List Headers { get; set; } = []; public List Redirects { get; set; } = []; + public List Rewrites { get; set; } = []; public TelemetryOptions Telemetry { get; set; } = new(); } diff --git a/src/Application/Configuration/YarpAppConfigBinder.cs b/src/Application/Configuration/YarpAppConfigBinder.cs index 7e189e294..e7f5f957c 100644 --- a/src/Application/Configuration/YarpAppConfigBinder.cs +++ b/src/Application/Configuration/YarpAppConfigBinder.cs @@ -19,6 +19,7 @@ public static YarpAppConfig Bind(IConfiguration configuration) configuration.GetSection(nameof(config.NavigationFallback)).Bind(config.NavigationFallback); configuration.GetSection(nameof(config.Headers)).Bind(config.Headers); configuration.GetSection(nameof(config.Redirects)).Bind(config.Redirects); + configuration.GetSection(nameof(config.Rewrites)).Bind(config.Rewrites); configuration.GetSection(nameof(config.Telemetry)).Bind(config.Telemetry); // Legacy env var support diff --git a/src/Application/Features/RedirectsFeature.cs b/src/Application/Features/RedirectsFeature.cs index e667c60c5..a96f82732 100644 --- a/src/Application/Features/RedirectsFeature.cs +++ b/src/Application/Features/RedirectsFeature.cs @@ -72,19 +72,6 @@ public CompiledRedirectRule(RedirectRule rule) public string Path { get; } public string BuildDestination(RouteValueDictionary values) - { - if (values.Count == 0 || Destination.IndexOf('{') < 0) - { - return Destination; - } - - var builder = new System.Text.StringBuilder(Destination); - foreach (var value in values) - { - builder.Replace("{" + value.Key + "}", value.Value?.ToString() ?? string.Empty); - } - - return builder.ToString(); - } + => RequestMatchEvaluator.ExpandTemplate(Destination, values); } } diff --git a/src/Application/Features/RequestMatchEvaluator.cs b/src/Application/Features/RequestMatchEvaluator.cs index daada4e9d..72409f375 100644 --- a/src/Application/Features/RequestMatchEvaluator.cs +++ b/src/Application/Features/RequestMatchEvaluator.cs @@ -57,4 +57,24 @@ public bool TryMatch(PathString path, RouteValueDictionary values) path = string.IsNullOrEmpty(path.Value) ? new PathString("/") : path; return _pathMatcher.TryMatch(path, values); } + + /// + /// Substitutes {name} placeholders in with values from + /// . Missing or null values resolve to an empty string. + /// + public static string ExpandTemplate(string template, RouteValueDictionary values) + { + if (values.Count == 0 || template.IndexOf('{') < 0) + { + return template; + } + + var builder = new System.Text.StringBuilder(template); + foreach (var value in values) + { + builder.Replace("{" + value.Key + "}", value.Value?.ToString() ?? string.Empty); + } + + return builder.ToString(); + } } diff --git a/src/Application/Features/RewritesFeature.cs b/src/Application/Features/RewritesFeature.cs new file mode 100644 index 000000000..dfe4b822a --- /dev/null +++ b/src/Application/Features/RewritesFeature.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Rewrite; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class RewritesFeature +{ + /// + /// Wires rules into the pipeline before + /// UseRouting, so every downstream stage (route matching, static files, redirects, + /// fallback, proxy) sees the rewritten path. Uses the standard ASP.NET rewrite-middleware + /// regex/$n syntax — no custom matching. + /// + public static WebApplication UseRewrites(this WebApplication app, YarpAppConfig config) + { + if (config.Rewrites.Count == 0) + { + return app; + } + + var options = new RewriteOptions(); + for (var i = 0; i < config.Rewrites.Count; i++) + { + var rule = config.Rewrites[i]; + if (string.IsNullOrWhiteSpace(rule.Regex)) + { + throw new InvalidOperationException( + $"Rewrite rule at index {i} requires a non-empty Regex."); + } + + if (rule.Replacement is null) + { + throw new InvalidOperationException( + $"Rewrite rule '{rule.Regex}' requires a Replacement."); + } + + options.AddRewrite(rule.Regex, rule.Replacement, rule.SkipRemainingRules); + } + + app.UseRewriter(options); + return app; + } +} diff --git a/src/Application/Program.cs b/src/Application/Program.cs index d8a2a73d0..b888f4b79 100644 --- a/src/Application/Program.cs +++ b/src/Application/Program.cs @@ -65,6 +65,7 @@ LoggingFeature.PrintBanner(config, configFilePath, app); // Middleware pipeline — order matters +app.UseRewrites(config); app.UseRouting(); app.UseStaticFiles(config); app.UseStaticHostHeaders(config); diff --git a/src/Application/README.md b/src/Application/README.md index 9eaf2d154..591b8edad 100644 --- a/src/Application/README.md +++ b/src/Application/README.md @@ -75,7 +75,7 @@ Simple toggles work as environment variables. Complex config (proxy routes, etc. All configuration goes through `IConfiguration` — JSON files, environment variables, or any other provider. See [`yarp-config.schema.json`](yarp-config.schema.json) for IDE autocomplete and validation. -Route-like features use ASP.NET route pattern syntax: `Headers` and `Redirects` match on `Match.Path`, and fallback exclusions use the same syntax in `Exclude[].Path`. +Route-like features use ASP.NET route pattern syntax: `Headers`, `Redirects`, and `Rewrites` match on `Match.Path`, and fallback exclusions use the same syntax in `Exclude[].Path`. ### `StaticFiles` @@ -155,6 +155,27 @@ Declarative redirects. Rules are evaluated in order and the first match wins. `Destination` can reference route values captured by `Match.Path`, such as `{slug}`. +### `Rewrites` + +URL rewrites applied **before routing**, so every downstream stage (routing, static files, redirects, SPA fallback, reverse proxy) sees the rewritten path. Uses the standard [ASP.NET rewrite middleware](https://learn.microsoft.com/aspnet/core/fundamentals/url-rewriting) — regex pattern + `$n` capture-group substitution. By default, the first matching rule wins. + +```json +{ + "Rewrites": [ + { + "Regex": "^blog/(.*)$", + "Replacement": "posts/$1" + }, + { + "Regex": "^legacy/(.*)$", + "Replacement": "$1" + } + ] +} +``` + +Set `SkipRemainingRules: false` to allow subsequent rules to also evaluate against the rewritten path. + ### `ReverseProxy` YARP reverse proxy routes and clusters. See the [YARP configuration docs](https://learn.microsoft.com/aspnet/core/fundamentals/servers/yarp/config-files) for the full reference. @@ -200,6 +221,7 @@ Configuration/ Config model (IConfiguration → POCOs) NavigationFallbackOptions.cs HeaderRule.cs RedirectRule.cs + RewriteRule.cs TelemetryOptions.cs Features/ Per-feature extension methods RequestMatchEvaluator.cs Route-template-based match evaluation @@ -207,6 +229,7 @@ Features/ Per-feature extension methods NavigationFallbackFeature.cs NavigationFallbackExclusionsFeature.cs RedirectsFeature.cs + RewritesFeature.cs StaticHostHeadersFeature.cs ReverseProxyFeature.cs LoggingFeature.cs diff --git a/src/Application/yarp-config.schema.json b/src/Application/yarp-config.schema.json index 085763458..aafb8fa3e 100644 --- a/src/Application/yarp-config.schema.json +++ b/src/Application/yarp-config.schema.json @@ -93,6 +93,30 @@ "additionalProperties": false } }, + "Rewrites": { + "type": "array", + "description": "URL rewrite rules applied before routing using the ASP.NET rewrite middleware (regex syntax).", + "items": { + "type": "object", + "properties": { + "Regex": { + "type": "string", + "description": "Regular expression matched against the request path" + }, + "Replacement": { + "type": "string", + "description": "Replacement string. May reference capture groups via $1, $2, etc." + }, + "SkipRemainingRules": { + "type": "boolean", + "default": true, + "description": "If true (default), skip remaining rules when this rule matches" + } + }, + "required": ["Regex", "Replacement"], + "additionalProperties": false + } + }, "Telemetry": { "type": "object", "description": "Telemetry configuration. OTLP export uses standard OTEL_* env vars.", diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index 69d5af23d..050500a8b 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -538,4 +538,187 @@ public async Task ObjectModel_EverythingDisabled() Assert.Equal(HttpStatusCode.NotFound, indexResponse.StatusCode); Assert.Equal(HttpStatusCode.NotFound, cssResponse.StatusCode); } + + [Fact] + public async Task ObjectModel_Rewrites_RewriteToStaticFile() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Rewrites = + { + new RewriteRule + { + Regex = "^styles$", + Replacement = "style.css" + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/styles"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + + [Fact] + public async Task ObjectModel_Rewrites_SubstituteCaptureGroups() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Rewrites = + { + new RewriteRule + { + Regex = "^assets/(.*)$", + Replacement = "$1" + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/assets/style.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + + [Fact] + public async Task ObjectModel_Rewrites_RunBeforeRouting_AffectFallbackExclude() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + }, + Rewrites = + { + // /old-api/* gets rewritten to /api/* — the exclude rule should still match + new RewriteRule + { + Regex = "^old-api/(.*)$", + Replacement = "api/$1" + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/old-api/users"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ObjectModel_Rewrites_RunBeforeRedirects() + { + using var app = CreateApp(new YarpAppConfig + { + Redirects = + { + new RedirectRule + { + Match = new RequestMatch { Path = "/new" }, + Destination = "/destination", + StatusCode = 302 + } + }, + Rewrites = + { + new RewriteRule + { + Regex = "^old$", + Replacement = "new" + } + } + }); + using var client = CreateNoRedirectClient(app); + + var response = await client.GetAsync("/old"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("/destination", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task ObjectModel_Rewrites_FirstMatchWins_NoChaining() + { + // SkipRemainingRules defaults to true, so the second rule must NOT re-fire + // even though the rewritten path matches it. + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Rewrites = + { + new RewriteRule + { + Regex = "^a$", + Replacement = "b" + }, + new RewriteRule + { + Regex = "^b$", + Replacement = "style.css" + } + } + }); + using var client = app.CreateClient(); + + var responseA = await client.GetAsync("/a"); + var responseB = await client.GetAsync("/b"); + + // /a -> /b, no chaining, so /a returns 404 (no /b file) + Assert.Equal(HttpStatusCode.NotFound, responseA.StatusCode); + // /b matches the second rule directly and rewrites to /style.css + Assert.Equal(HttpStatusCode.OK, responseB.StatusCode); + } + + [Fact] + public async Task ObjectModel_Rewrites_NoMatch_PassesThroughUnchanged() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Rewrites = + { + new RewriteRule + { + Regex = "^blog/(.*)$", + Replacement = "posts/$1" + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/style.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + + [Fact] + public async Task ObjectModel_Rewrites_AffectReverseProxyRouting() + { + using var factory = CreateApp(new Dictionary() + { + ["Rewrites:0:Regex"] = "^legacy/(.*)$", + ["Rewrites:0:Replacement"] = "api/$1", + ["ReverseProxy:Routes:api:ClusterId"] = "backend", + ["ReverseProxy:Routes:api:Match:Path"] = "/api/{**catch-all}", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = "https://localhost:9999" + }); + using var client = factory.CreateClient(); + + // Should hit the proxy route (which fails to connect, but routing matched). + var response = await client.GetAsync("/legacy/ping"); + + Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); + } } From 289eb127758f521edc216411119f884607c61bee Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 27 Apr 2026 21:55:15 -0700 Subject: [PATCH 03/18] Fix endpoint Order closure and surface invalid rewrite regex clearly - RedirectsFeature and NavigationFallbackExclusionsFeature captured the loop variable 'i' in the .Add() callback. Endpoint convention callbacks run after the loop completes, so all endpoints were getting the same final Order value. Hoist Order into a per-iteration local. Existing tests didn't catch this because each rule has a unique path, so Order ties weren't observable. - Wrap RewriteOptions.AddRewrite in try/catch so an invalid Regex pattern surfaces as 'Rewrite rule at index N has an invalid Regex pattern "..."' instead of a generic ArgumentException from the Regex constructor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NavigationFallbackExclusionsFeature.cs | 3 ++- src/Application/Features/RedirectsFeature.cs | 3 ++- src/Application/Features/RewritesFeature.cs | 10 +++++++++- test/Application.Tests/SpaFallbackTests.cs | 15 +++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Application/Features/NavigationFallbackExclusionsFeature.cs b/src/Application/Features/NavigationFallbackExclusionsFeature.cs index 02bdc091e..2513a0fcb 100644 --- a/src/Application/Features/NavigationFallbackExclusionsFeature.cs +++ b/src/Application/Features/NavigationFallbackExclusionsFeature.cs @@ -20,6 +20,7 @@ public static WebApplication MapNavigationFallbackExclusions(this WebApplication for (var i = 0; i < config.NavigationFallback.Exclude.Count; i++) { var path = RequestMatchEvaluator.ValidatePath(config.NavigationFallback.Exclude[i], "NavigationFallback exclusion"); + var order = int.MaxValue - 1000 + i; app.MapFallback( path, context => @@ -32,7 +33,7 @@ public static WebApplication MapNavigationFallbackExclusions(this WebApplication endpointBuilder.DisplayName = $"Fallback exclusion {path}"; // Keep exclusions just ahead of the SPA fallback while still letting proxy // routes and other normal endpoints win when they match. - ((RouteEndpointBuilder)endpointBuilder).Order = int.MaxValue - 1000 + i; + ((RouteEndpointBuilder)endpointBuilder).Order = order; }); } diff --git a/src/Application/Features/RedirectsFeature.cs b/src/Application/Features/RedirectsFeature.cs index a96f82732..7ef9e2d4c 100644 --- a/src/Application/Features/RedirectsFeature.cs +++ b/src/Application/Features/RedirectsFeature.cs @@ -20,6 +20,7 @@ public static WebApplication MapRedirects(this WebApplication app, YarpAppConfig for (var i = 0; i < config.Redirects.Count; i++) { var rule = new CompiledRedirectRule(config.Redirects[i]); + var order = -1000 + i; app.Map( rule.Path, context => @@ -34,7 +35,7 @@ public static WebApplication MapRedirects(this WebApplication app, YarpAppConfig .Add(endpointBuilder => { endpointBuilder.DisplayName = $"Redirect {rule.Path}"; - ((RouteEndpointBuilder)endpointBuilder).Order = -1000 + i; + ((RouteEndpointBuilder)endpointBuilder).Order = order; }); } diff --git a/src/Application/Features/RewritesFeature.cs b/src/Application/Features/RewritesFeature.cs index dfe4b822a..44c695a14 100644 --- a/src/Application/Features/RewritesFeature.cs +++ b/src/Application/Features/RewritesFeature.cs @@ -38,7 +38,15 @@ public static WebApplication UseRewrites(this WebApplication app, YarpAppConfig $"Rewrite rule '{rule.Regex}' requires a Replacement."); } - options.AddRewrite(rule.Regex, rule.Replacement, rule.SkipRemainingRules); + try + { + options.AddRewrite(rule.Regex, rule.Replacement, rule.SkipRemainingRules); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException( + $"Rewrite rule at index {i} has an invalid Regex pattern '{rule.Regex}': {ex.Message}", ex); + } } app.UseRewriter(options); diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index 050500a8b..f7b750cb7 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -721,4 +721,19 @@ public async Task ObjectModel_Rewrites_AffectReverseProxyRouting() Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); } + + [Fact] + public void ObjectModel_Rewrites_InvalidRegex_ThrowsClearError() + { + using var app = CreateApp(new YarpAppConfig + { + Rewrites = + { + new RewriteRule { Regex = "[unterminated(", Replacement = "/x" } + } + }); + + var ex = Assert.ThrowsAny(() => app.CreateClient()); + Assert.Contains("invalid Regex pattern", ex.Message); + } } From ead3a5e81782f3ed805e8c4c3b1724e1f4b388b7 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 27 Apr 2026 22:01:00 -0700 Subject: [PATCH 04/18] Document request pipeline order and the two-syntax model Adds a 'Request pipeline' section to the application README that lays out the eight stages (rewrites -> routing match -> redirects -> static files -> headers -> reverse proxy -> fallback exclusions -> SPA fallback) as the central mental model, plus a 'Match syntax' note explaining why routed features use route templates while Rewrites use regex (delegation to the standard rewrite middleware). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Application/README.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/Application/README.md b/src/Application/README.md index 591b8edad..d42985c5a 100644 --- a/src/Application/README.md +++ b/src/Application/README.md @@ -75,7 +75,43 @@ Simple toggles work as environment variables. Complex config (proxy routes, etc. All configuration goes through `IConfiguration` — JSON files, environment variables, or any other provider. See [`yarp-config.schema.json`](yarp-config.schema.json) for IDE autocomplete and validation. -Route-like features use ASP.NET route pattern syntax: `Headers`, `Redirects`, and `Rewrites` match on `Match.Path`, and fallback exclusions use the same syntax in `Exclude[].Path`. +### Request pipeline + +Every request flows through the pipeline below in this fixed order. Knowing the order is usually enough to reason about which feature wins for a given URL. + +```text +┌─────────────────────────────────────────┐ +│ 1. Rewrites (pre-routing) │ Regex-based path rewrite. Mutates Request.Path. +├─────────────────────────────────────────┤ +│ 2. Routing match (endpoint chosen) │ ASP.NET selects an endpoint, but doesn't run it yet. +├─────────────────────────────────────────┤ +│ 3. Redirects (short-circuit) │ If a redirect endpoint matched, send 30x and stop. +├─────────────────────────────────────────┤ +│ 4. Static files (special) │ If a file at Request.Path exists in wwwroot, serve it. +├─────────────────────────────────────────┤ +│ 5. Headers (response phase) │ Apply Header rules to static-file & SPA-fallback responses. +├─────────────────────────────────────────┤ +│ 6. Reverse proxy (endpoint) │ YARP routes that matched in step 2 run here. +├─────────────────────────────────────────┤ +│ 7. Fallback exclude (endpoint) │ Listed paths return 404 instead of falling back. +├─────────────────────────────────────────┤ +│ 8. SPA fallback (endpoint) │ Otherwise serve NavigationFallback.Path (e.g. index.html). +└─────────────────────────────────────────┘ +``` + +A few consequences worth internalizing: + +- **Rewrites run first**, so every later stage sees the rewritten path. Use them to canonicalize URLs before anything else makes a decision. +- **Redirects beat static files and the proxy.** If a redirect rule matches, the response is a 30x — the file or upstream is never consulted. +- **Static files beat fallback exclusions and the SPA fallback.** A real file in `wwwroot` always wins over routed fallback endpoints, even though those fallbacks were chosen by `UseRouting` first. (This is preserved by clearing/restoring the selected endpoint around `UseFileServer`.) +- **`Headers` only apply to static-file and SPA-fallback responses** — not to redirects, not to proxy responses. Use YARP response transforms for proxy headers. +- **Fallback exclusions and the SPA fallback are real routed endpoints**, so reverse-proxy routes (and any `MapGet`/`MapPost` registered earlier) can still claim a path before either fires. + +### Match syntax + +Routed features (`Headers`, `Redirects`, `NavigationFallback.Exclude`) match using ASP.NET route templates — `/blog/{slug}`, `/api/{**catch-all}`. The same engine that powers `MapGet`. Captures from `Match.Path` are available in `Destination` as `{name}` substitutions. + +`Rewrites` use a different syntax — regex with `$n` capture groups — because they delegate to the standard ASP.NET [URL rewrite middleware](https://learn.microsoft.com/aspnet/core/fundamentals/url-rewriting). This is intentional: routed features are routes (so they use route-template syntax), and rewrites are rewrites (so they use the existing rewrite syntax). No new syntax is introduced. ### `StaticFiles` From 1cc6c2ce9091c12d94ee8dbeaeef925ece3c0363 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 27 Apr 2026 22:16:29 -0700 Subject: [PATCH 05/18] Add ErrorPages: per-code custom error pages with class wildcards Maps HTTP status codes to custom response files via re-execute. Supports exact codes ("404") and class wildcards ("5xx"); exact wins over wildcard. Slots between Rewrites and Routing in the request pipeline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Configuration/YarpAppConfig.cs | 1 + .../Configuration/YarpAppConfigBinder.cs | 1 + src/Application/Features/ErrorPagesFeature.cs | 168 ++++++++++++++++++ src/Application/Program.cs | 1 + src/Application/README.md | 41 ++++- src/Application/yarp-config.schema.json | 9 + test/Application.Tests/SpaFallbackTests.cs | 92 ++++++++++ test/Application.Tests/wwwroot/404.html | 2 + .../wwwroot/server-error.html | 2 + 9 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 src/Application/Features/ErrorPagesFeature.cs create mode 100644 test/Application.Tests/wwwroot/404.html create mode 100644 test/Application.Tests/wwwroot/server-error.html diff --git a/src/Application/Configuration/YarpAppConfig.cs b/src/Application/Configuration/YarpAppConfig.cs index ae5cd8900..c386aa995 100644 --- a/src/Application/Configuration/YarpAppConfig.cs +++ b/src/Application/Configuration/YarpAppConfig.cs @@ -14,5 +14,6 @@ public sealed class YarpAppConfig public List Headers { get; set; } = []; public List Redirects { get; set; } = []; public List Rewrites { get; set; } = []; + public Dictionary ErrorPages { get; set; } = new(StringComparer.OrdinalIgnoreCase); public TelemetryOptions Telemetry { get; set; } = new(); } diff --git a/src/Application/Configuration/YarpAppConfigBinder.cs b/src/Application/Configuration/YarpAppConfigBinder.cs index e7f5f957c..dd5f271fc 100644 --- a/src/Application/Configuration/YarpAppConfigBinder.cs +++ b/src/Application/Configuration/YarpAppConfigBinder.cs @@ -20,6 +20,7 @@ public static YarpAppConfig Bind(IConfiguration configuration) configuration.GetSection(nameof(config.Headers)).Bind(config.Headers); configuration.GetSection(nameof(config.Redirects)).Bind(config.Redirects); configuration.GetSection(nameof(config.Rewrites)).Bind(config.Rewrites); + configuration.GetSection(nameof(config.ErrorPages)).Bind(config.ErrorPages); configuration.GetSection(nameof(config.Telemetry)).Bind(config.Telemetry); // Legacy env var support diff --git a/src/Application/Features/ErrorPagesFeature.cs b/src/Application/Features/ErrorPagesFeature.cs new file mode 100644 index 000000000..95a2f0142 --- /dev/null +++ b/src/Application/Features/ErrorPagesFeature.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing; +using Yarp.Application.Configuration; + +namespace Yarp.Application.Features; + +public static class ErrorPagesFeature +{ + /// + /// Re-executes the request against a configured page when an empty 4xx/5xx response is + /// produced downstream. The original status code is preserved (the browser still sees + /// 404, 500, etc.). Keys are 3-digit status codes ("404") or class wildcards + /// ("4xx", "5xx"); exact codes win over wildcards. + /// + public static WebApplication UseErrorPages(this WebApplication app, YarpAppConfig config) + { + if (config.ErrorPages.Count == 0) + { + return app; + } + + var rules = ErrorPageRules.Compile(config.ErrorPages); + + app.UseStatusCodePages(async context => + { + var http = context.HttpContext; + var path = rules.Resolve(http.Response.StatusCode); + if (path is null) + { + return; + } + + // Mirror the re-execute pattern from + // Microsoft.AspNetCore.Diagnostics.StatusCodePagesExtensions.UseStatusCodePagesWithReExecute, + // adapted to look up the destination per status code instead of using a single template. + var originalPath = http.Request.Path; + var originalQueryString = http.Request.QueryString; + + http.Features.Set(new StatusCodeReExecuteFeature + { + OriginalPathBase = http.Request.PathBase.Value!, + OriginalPath = originalPath.Value!, + OriginalQueryString = originalQueryString.HasValue ? originalQueryString.Value : null, + }); + + // Clear the chosen endpoint and route values so the re-executed request can be + // matched fresh against routing/static-files. + http.SetEndpoint(null); + http.Features.Get()?.RouteValues?.Clear(); + + http.Request.Path = path; + http.Request.QueryString = QueryString.Empty; + try + { + await context.Next(http); + } + finally + { + http.Request.QueryString = originalQueryString; + http.Request.Path = originalPath; + http.Features.Set(null); + } + }); + + return app; + } + + private sealed class ErrorPageRules + { + private readonly Dictionary _exact; + private readonly Dictionary _classes; + + private ErrorPageRules(Dictionary exact, Dictionary classes) + { + _exact = exact; + _classes = classes; + } + + public string? Resolve(int statusCode) + { + if (_exact.TryGetValue(statusCode, out var path)) + { + return path; + } + + if (_classes.TryGetValue(statusCode / 100, out path)) + { + return path; + } + + return null; + } + + public static ErrorPageRules Compile(IDictionary source) + { + var exact = new Dictionary(); + var classes = new Dictionary(); + + foreach (var (key, value) in source) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"ErrorPages entry '{key}' must have a non-empty path."); + } + + if (TryParseExactCode(key, out var code)) + { + exact[code] = value; + } + else if (TryParseClassWildcard(key, out var hundreds)) + { + classes[hundreds] = value; + } + else + { + throw new InvalidOperationException( + $"ErrorPages key '{key}' must be a 3-digit status code (e.g. '404') or a class wildcard (e.g. '5xx')."); + } + } + + return new ErrorPageRules(exact, classes); + } + + private static bool TryParseExactCode(string key, out int code) + { + code = 0; + if (key.Length != 3) + { + return false; + } + + for (var i = 0; i < 3; i++) + { + if (!char.IsDigit(key[i])) + { + return false; + } + } + + code = int.Parse(key, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + + private static bool TryParseClassWildcard(string key, out int hundreds) + { + hundreds = 0; + if (key.Length != 3 || !char.IsDigit(key[0])) + { + return false; + } + + if ((key[1] != 'x' && key[1] != 'X') || (key[2] != 'x' && key[2] != 'X')) + { + return false; + } + + hundreds = key[0] - '0'; + return true; + } + } +} diff --git a/src/Application/Program.cs b/src/Application/Program.cs index b888f4b79..f14eb8b5a 100644 --- a/src/Application/Program.cs +++ b/src/Application/Program.cs @@ -66,6 +66,7 @@ // Middleware pipeline — order matters app.UseRewrites(config); +app.UseErrorPages(config); app.UseRouting(); app.UseStaticFiles(config); app.UseStaticHostHeaders(config); diff --git a/src/Application/README.md b/src/Application/README.md index d42985c5a..4052a8a0a 100644 --- a/src/Application/README.md +++ b/src/Application/README.md @@ -83,19 +83,21 @@ Every request flows through the pipeline below in this fixed order. Knowing the ┌─────────────────────────────────────────┐ │ 1. Rewrites (pre-routing) │ Regex-based path rewrite. Mutates Request.Path. ├─────────────────────────────────────────┤ -│ 2. Routing match (endpoint chosen) │ ASP.NET selects an endpoint, but doesn't run it yet. +│ 2. Error pages (wraps everything)│ Re-execute against a configured page on 4xx/5xx. ├─────────────────────────────────────────┤ -│ 3. Redirects (short-circuit) │ If a redirect endpoint matched, send 30x and stop. +│ 3. Routing match (endpoint chosen) │ ASP.NET selects an endpoint, but doesn't run it yet. ├─────────────────────────────────────────┤ -│ 4. Static files (special) │ If a file at Request.Path exists in wwwroot, serve it. +│ 4. Redirects (short-circuit) │ If a redirect endpoint matched, send 30x and stop. ├─────────────────────────────────────────┤ -│ 5. Headers (response phase) │ Apply Header rules to static-file & SPA-fallback responses. +│ 5. Static files (special) │ If a file at Request.Path exists in wwwroot, serve it. ├─────────────────────────────────────────┤ -│ 6. Reverse proxy (endpoint) │ YARP routes that matched in step 2 run here. +│ 6. Headers (response phase) │ Apply Header rules to static-file & SPA-fallback responses. ├─────────────────────────────────────────┤ -│ 7. Fallback exclude (endpoint) │ Listed paths return 404 instead of falling back. +│ 7. Reverse proxy (endpoint) │ YARP routes that matched in step 3 run here. ├─────────────────────────────────────────┤ -│ 8. SPA fallback (endpoint) │ Otherwise serve NavigationFallback.Path (e.g. index.html). +│ 8. Fallback exclude (endpoint) │ Listed paths return 404 instead of falling back. +├─────────────────────────────────────────┤ +│ 9. SPA fallback (endpoint) │ Otherwise serve NavigationFallback.Path (e.g. index.html). └─────────────────────────────────────────┘ ``` @@ -212,6 +214,30 @@ URL rewrites applied **before routing**, so every downstream stage (routing, sta Set `SkipRemainingRules: false` to allow subsequent rules to also evaluate against the rewritten path. +### `ErrorPages` + +Custom error pages for 4xx/5xx responses. The request is internally re-executed against the configured path while the original status code is preserved (the browser still sees `404`, `500`, etc.). Built on top of ASP.NET's [`UseStatusCodePages`](https://learn.microsoft.com/aspnet/core/fundamentals/error-handling#usestatuscodepageswithreexecute) — only fires when the response has not started and the body is empty. + +```json +{ + "ErrorPages": { + "404": "/404.html", + "503": "/maintenance.html", + "4xx": "/client-error.html", + "5xx": "/server-error.html" + } +} +``` + +Keys are either a 3-digit HTTP status code (`"404"`) or a class wildcard (`"4xx"`, `"5xx"`). **Exact codes win over wildcards**, so in the example above: + +| Status | Page | +|---|---| +| 400, 403 | `/client-error.html` | +| 404 | `/404.html` | +| 500, 502 | `/server-error.html` | +| 503 | `/maintenance.html` | + ### `ReverseProxy` YARP reverse proxy routes and clusters. See the [YARP configuration docs](https://learn.microsoft.com/aspnet/core/fundamentals/servers/yarp/config-files) for the full reference. @@ -266,6 +292,7 @@ Features/ Per-feature extension methods NavigationFallbackExclusionsFeature.cs RedirectsFeature.cs RewritesFeature.cs + ErrorPagesFeature.cs StaticHostHeadersFeature.cs ReverseProxyFeature.cs LoggingFeature.cs diff --git a/src/Application/yarp-config.schema.json b/src/Application/yarp-config.schema.json index aafb8fa3e..06d628334 100644 --- a/src/Application/yarp-config.schema.json +++ b/src/Application/yarp-config.schema.json @@ -117,6 +117,15 @@ "additionalProperties": false } }, + "ErrorPages": { + "type": "object", + "description": "Custom error pages, re-executed against the configured path while preserving the original status code. Keys are 3-digit status codes (e.g. '404') or class wildcards (e.g. '5xx'); exact codes win over wildcards.", + "patternProperties": { + "^[0-9]{3}$": { "type": "string" }, + "^[0-9][xX][xX]$": { "type": "string" } + }, + "additionalProperties": false + }, "Telemetry": { "type": "object", "description": "Telemetry configuration. OTLP export uses standard OTEL_* env vars.", diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index f7b750cb7..c4b42cabd 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -736,4 +736,96 @@ public void ObjectModel_Rewrites_InvalidRegex_ThrowsClearError() var ex = Assert.ThrowsAny(() => app.CreateClient()); Assert.Contains("invalid Regex pattern", ex.Message); } + + [Fact] + public async Task ObjectModel_ErrorPages_ExactCode_ServesPageWithOriginalStatus() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + ErrorPages = { ["404"] = "/404.html" } + }); + using var client = app.CreateClient(); + + // Unknown path → 404 (no SPA fallback configured) → re-execute against /404.html + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Custom 404", content); + } + + [Fact] + public async Task ObjectModel_ErrorPages_ClassWildcard_MatchesAllCodesInClass() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + }, + ErrorPages = { ["4xx"] = "/404.html" } + }); + using var client = app.CreateClient(); + + // The exclusion produces a 404 → wildcard '4xx' rule fires. + var response = await client.GetAsync("/api/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Custom 404", content); + } + + [Fact] + public async Task ObjectModel_ErrorPages_ExactBeatsWildcard() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + ErrorPages = + { + ["404"] = "/404.html", + ["4xx"] = "/server-error.html" + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Custom 404", content); + } + + [Fact] + public async Task ObjectModel_ErrorPages_NoMatch_PassesThrough() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + // Only configure 5xx; a 404 should pass through unchanged. + ErrorPages = { ["5xx"] = "/server-error.html" } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("Server error", content); + } + + [Fact] + public void ObjectModel_ErrorPages_InvalidKey_ThrowsClearError() + { + using var app = CreateApp(new YarpAppConfig + { + ErrorPages = { ["nope"] = "/x.html" } + }); + + var ex = Assert.ThrowsAny(() => app.CreateClient()); + Assert.Contains("3-digit status code", ex.Message); + } } diff --git a/test/Application.Tests/wwwroot/404.html b/test/Application.Tests/wwwroot/404.html new file mode 100644 index 000000000..3fda41b18 --- /dev/null +++ b/test/Application.Tests/wwwroot/404.html @@ -0,0 +1,2 @@ + +

Custom 404

diff --git a/test/Application.Tests/wwwroot/server-error.html b/test/Application.Tests/wwwroot/server-error.html new file mode 100644 index 000000000..0c2186029 --- /dev/null +++ b/test/Application.Tests/wwwroot/server-error.html @@ -0,0 +1,2 @@ + +

Server error

From cf53225c279243aa49e2ce512e0ec7407652ea64 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 27 Apr 2026 22:53:26 -0700 Subject: [PATCH 06/18] Preserve status during custom error page re-execute Clear the response before re-executing a configured error page so routed and proxied targets can run normally, then restore the original status code before the response is sent. Add coverage for a proxied error page target that writes 200 OK while the client still receives the original 404. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Application/Features/ErrorPagesFeature.cs | 41 ++++++++++++++- test/Application.Tests/SpaFallbackTests.cs | 52 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/Application/Features/ErrorPagesFeature.cs b/src/Application/Features/ErrorPagesFeature.cs index 95a2f0142..a47d9c0eb 100644 --- a/src/Application/Features/ErrorPagesFeature.cs +++ b/src/Application/Features/ErrorPagesFeature.cs @@ -41,18 +41,34 @@ public static WebApplication UseErrorPages(this WebApplication app, YarpAppConfi // adapted to look up the destination per status code instead of using a single template. var originalPath = http.Request.Path; var originalQueryString = http.Request.QueryString; - - http.Features.Set(new StatusCodeReExecuteFeature + var originalStatusCode = http.Response.StatusCode; + var originalEndpoint = http.GetEndpoint(); + var routeValuesFeature = http.Features.Get(); + var originalRouteValues = routeValuesFeature?.RouteValues is { } routeValues + ? new RouteValueDictionary(routeValues) + : null; + + http.Features.Set(new ErrorPageReExecuteFeature { OriginalPathBase = http.Request.PathBase.Value!, OriginalPath = originalPath.Value!, OriginalQueryString = originalQueryString.HasValue ? originalQueryString.Value : null, + OriginalStatusCode = originalStatusCode, + Endpoint = originalEndpoint, + RouteValues = originalRouteValues, }); // Clear the chosen endpoint and route values so the re-executed request can be // matched fresh against routing/static-files. http.SetEndpoint(null); http.Features.Get()?.RouteValues?.Clear(); + http.Response.Clear(); + http.Response.OnStarting(static state => + { + var (response, statusCode) = ((HttpResponse Response, int StatusCode))state; + response.StatusCode = statusCode; + return Task.CompletedTask; + }, (http.Response, originalStatusCode)); http.Request.Path = path; http.Request.QueryString = QueryString.Empty; @@ -62,8 +78,19 @@ public static WebApplication UseErrorPages(this WebApplication app, YarpAppConfi } finally { + if (!http.Response.HasStarted) + { + http.Response.StatusCode = originalStatusCode; + } + http.Request.QueryString = originalQueryString; http.Request.Path = originalPath; + http.SetEndpoint(originalEndpoint); + if (routeValuesFeature is not null) + { + routeValuesFeature.RouteValues = originalRouteValues ?? new RouteValueDictionary(); + } + http.Features.Set(null); } }); @@ -71,6 +98,16 @@ public static WebApplication UseErrorPages(this WebApplication app, YarpAppConfi return app; } + private sealed class ErrorPageReExecuteFeature : IStatusCodeReExecuteFeature + { + public string OriginalPathBase { get; set; } = string.Empty; + public string OriginalPath { get; set; } = string.Empty; + public string? OriginalQueryString { get; set; } + public int OriginalStatusCode { get; init; } + public Endpoint? Endpoint { get; set; } + public RouteValueDictionary? RouteValues { get; set; } + } + private sealed class ErrorPageRules { private readonly Dictionary _exact; diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index c4b42cabd..69575ab22 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -3,9 +3,14 @@ using System.Net; using System.Text.Json; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Xunit; using Yarp.Application.Configuration; @@ -81,6 +86,28 @@ private static HttpClient CreateNoRedirectClient(WebApplicationFactory AllowAutoRedirect = false }); + private static async Task CreateBackendAsync(RequestDelegate requestDelegate) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseKestrel().UseUrls("http://127.0.0.1:0"); + + var app = builder.Build(); + app.Run(requestDelegate); + await app.StartAsync(); + return app; + } + + private static string GetAddress(WebApplication app) + { + var addresses = app.Services.GetRequiredService() + .Features.Get()?.Addresses; + + var address = addresses?.Single() + ?? throw new InvalidOperationException("The test backend did not publish an address."); + + return address.EndsWith('/') ? address : $"{address}/"; + } + [Fact] public async Task SpaFallback_ReturnsIndexHtml_ForUnknownRoutes() { @@ -799,6 +826,31 @@ public async Task ObjectModel_ErrorPages_ExactBeatsWildcard() Assert.Contains("Custom 404", content); } + [Fact] + public async Task ObjectModel_ErrorPages_ProxiedPage_PreservesOriginalStatusWhenTargetWrites200() + { + await using var backend = await CreateBackendAsync(async context => + { + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync("Proxied error page"); + }); + + using var app = CreateApp(new Dictionary + { + ["ErrorPages:404"] = "/error-page", + ["ReverseProxy:Routes:error:ClusterId"] = "backend", + ["ReverseProxy:Routes:error:Match:Path"] = "/error-page", + ["ReverseProxy:Clusters:backend:Destinations:d1:Address"] = GetAddress(backend) + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Proxied error page", content); + } + [Fact] public async Task ObjectModel_ErrorPages_NoMatch_PassesThrough() { From a12143c2f40cf79f12bb8ac7c02886f3ade26e08 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 28 Apr 2026 00:13:38 -0700 Subject: [PATCH 07/18] Add YARP application sample apps Add five realistic config/static-file sample apps that demonstrate the YARP application static-hosting and routing-rule features: marketing site headers, docs redirects/rewrites, dashboard API proxying, custom commerce error pages, and a composed edge frontend. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../01-marketing-site/appsettings.json | 25 +++++++ .../01-marketing-site/wwwroot/assets/site.css | 12 +++ .../01-marketing-site/wwwroot/index.html | 16 ++++ .../02-docs-site/appsettings.json | 28 +++++++ .../02-docs-site/wwwroot/docs/intro.html | 8 ++ .../02-docs-site/wwwroot/docs/v2/intro.html | 8 ++ .../02-docs-site/wwwroot/index.html | 8 ++ .../03-dashboard-spa/appsettings.json | 42 +++++++++++ .../03-dashboard-spa/wwwroot/assets/site.css | 7 ++ .../03-dashboard-spa/wwwroot/index.html | 13 ++++ .../04-commerce-errors/appsettings.json | 33 +++++++++ .../wwwroot/errors/maintenance.html | 8 ++ .../wwwroot/errors/not-found.html | 8 ++ .../wwwroot/errors/server-error.html | 8 ++ .../04-commerce-errors/wwwroot/index.html | 8 ++ .../05-edge-composition/appsettings.json | 73 +++++++++++++++++++ .../wwwroot/assets/app.css | 6 ++ .../wwwroot/errors/not-found.html | 8 ++ .../wwwroot/errors/server-error.html | 8 ++ .../05-edge-composition/wwwroot/index.html | 13 ++++ samples/YarpApplication.SampleApps/README.md | 26 +++++++ 21 files changed, 366 insertions(+) create mode 100644 samples/YarpApplication.SampleApps/01-marketing-site/appsettings.json create mode 100644 samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/assets/site.css create mode 100644 samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/index.html create mode 100644 samples/YarpApplication.SampleApps/02-docs-site/appsettings.json create mode 100644 samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/intro.html create mode 100644 samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/v2/intro.html create mode 100644 samples/YarpApplication.SampleApps/02-docs-site/wwwroot/index.html create mode 100644 samples/YarpApplication.SampleApps/03-dashboard-spa/appsettings.json create mode 100644 samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/assets/site.css create mode 100644 samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/index.html create mode 100644 samples/YarpApplication.SampleApps/04-commerce-errors/appsettings.json create mode 100644 samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/maintenance.html create mode 100644 samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/not-found.html create mode 100644 samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/server-error.html create mode 100644 samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/index.html create mode 100644 samples/YarpApplication.SampleApps/05-edge-composition/appsettings.json create mode 100644 samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/assets/app.css create mode 100644 samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/not-found.html create mode 100644 samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/server-error.html create mode 100644 samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/index.html create mode 100644 samples/YarpApplication.SampleApps/README.md diff --git a/samples/YarpApplication.SampleApps/01-marketing-site/appsettings.json b/samples/YarpApplication.SampleApps/01-marketing-site/appsettings.json new file mode 100644 index 000000000..71fa05e36 --- /dev/null +++ b/samples/YarpApplication.SampleApps/01-marketing-site/appsettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "../../../src/Application/yarp-config.schema.json", + "StaticFiles": { + "Enabled": true + }, + "NavigationFallback": { + "Path": "/index.html" + }, + "Headers": [ + { + "Match": { "Path": "/{**catch-all}" }, + "Set": { + "X-Content-Type-Options": "nosniff" + } + }, + { + "Match": { "Path": "/assets/{**catch-all}" }, + "Set": { + "Cache-Control": "public,max-age=31536000,immutable", + "X-Site": "contoso-marketing" + } + } + ] +} + diff --git a/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/assets/site.css b/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/assets/site.css new file mode 100644 index 000000000..7f918ee5a --- /dev/null +++ b/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/assets/site.css @@ -0,0 +1,12 @@ +body { + color: #17324d; + font-family: system-ui, sans-serif; + margin: 4rem; +} + +.eyebrow { + color: #476f95; + letter-spacing: .08em; + text-transform: uppercase; +} + diff --git a/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/index.html b/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/index.html new file mode 100644 index 000000000..04579ea3a --- /dev/null +++ b/samples/YarpApplication.SampleApps/01-marketing-site/wwwroot/index.html @@ -0,0 +1,16 @@ + + + + + Contoso Launch + + + +
+

Contoso Cloud

+

Launch campaigns without shipping a custom server.

+

Static assets are served directly, campaign deep links fall back to this shell, and asset responses get cache headers from config.

+
+ + + diff --git a/samples/YarpApplication.SampleApps/02-docs-site/appsettings.json b/samples/YarpApplication.SampleApps/02-docs-site/appsettings.json new file mode 100644 index 000000000..d323e18dd --- /dev/null +++ b/samples/YarpApplication.SampleApps/02-docs-site/appsettings.json @@ -0,0 +1,28 @@ +{ + "$schema": "../../../src/Application/yarp-config.schema.json", + "StaticFiles": { + "Enabled": true + }, + "Redirects": [ + { + "Match": { "Path": "/learn/{slug}" }, + "Destination": "/docs/{slug}.html", + "StatusCode": 301 + } + ], + "Rewrites": [ + { + "Regex": "^docs/current/(.*)$", + "Replacement": "docs/v2/$1" + } + ], + "Headers": [ + { + "Match": { "Path": "/docs/{**catch-all}" }, + "Set": { + "X-Docs-Site": "contoso-docs" + } + } + ] +} + diff --git a/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/intro.html b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/intro.html new file mode 100644 index 000000000..5c3ae9f1f --- /dev/null +++ b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/intro.html @@ -0,0 +1,8 @@ + + + +

Intro docs

+

This is the canonical documentation URL after redirecting old /learn links.

+ + + diff --git a/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/v2/intro.html b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/v2/intro.html new file mode 100644 index 000000000..7c41a2407 --- /dev/null +++ b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/docs/v2/intro.html @@ -0,0 +1,8 @@ + + + +

Current intro docs

+

The /docs/current path is rewritten to this versioned file.

+ + + diff --git a/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/index.html b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/index.html new file mode 100644 index 000000000..b3015d706 --- /dev/null +++ b/samples/YarpApplication.SampleApps/02-docs-site/wwwroot/index.html @@ -0,0 +1,8 @@ + + + +

Contoso Docs

+

Use /learn/intro for a redirected legacy link, or /docs/current/intro.html for a rewritten versioned link.

+ + + diff --git a/samples/YarpApplication.SampleApps/03-dashboard-spa/appsettings.json b/samples/YarpApplication.SampleApps/03-dashboard-spa/appsettings.json new file mode 100644 index 000000000..2adc518fa --- /dev/null +++ b/samples/YarpApplication.SampleApps/03-dashboard-spa/appsettings.json @@ -0,0 +1,42 @@ +{ + "$schema": "../../../src/Application/yarp-config.schema.json", + "StaticFiles": { + "Enabled": true + }, + "NavigationFallback": { + "Path": "/index.html", + "Exclude": [ + { "Path": "/api/{**catch-all}" } + ] + }, + "Headers": [ + { + "Match": { "Path": "/{**catch-all}" }, + "Set": { + "X-Static-Host": "dashboard" + } + }, + { + "Match": { "Path": "/assets/{**catch-all}" }, + "Set": { + "Cache-Control": "public,max-age=3600" + } + } + ], + "ReverseProxy": { + "Routes": { + "api": { + "ClusterId": "catalog-api", + "Match": { "Path": "/api/{**catch-all}" } + } + }, + "Clusters": { + "catalog-api": { + "Destinations": { + "d1": { "Address": "http://catalog-api:8080/" } + } + } + } + } +} + diff --git a/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/assets/site.css b/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/assets/site.css new file mode 100644 index 000000000..dfa6274a7 --- /dev/null +++ b/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/assets/site.css @@ -0,0 +1,7 @@ +body { + background: #101827; + color: #f7fafc; + font-family: system-ui, sans-serif; + margin: 3rem; +} + diff --git a/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/index.html b/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/index.html new file mode 100644 index 000000000..c9141cf15 --- /dev/null +++ b/samples/YarpApplication.SampleApps/03-dashboard-spa/wwwroot/index.html @@ -0,0 +1,13 @@ + + + + + Operations Dashboard + + + +

Operations Dashboard

+

Client-side dashboard routes fall back here. Requests under /api are excluded from fallback and forwarded to the catalog API.

+ + + diff --git a/samples/YarpApplication.SampleApps/04-commerce-errors/appsettings.json b/samples/YarpApplication.SampleApps/04-commerce-errors/appsettings.json new file mode 100644 index 000000000..a65895a16 --- /dev/null +++ b/samples/YarpApplication.SampleApps/04-commerce-errors/appsettings.json @@ -0,0 +1,33 @@ +{ + "$schema": "../../../src/Application/yarp-config.schema.json", + "StaticFiles": { + "Enabled": true + }, + "NavigationFallback": { + "Path": "/index.html", + "Exclude": [ + { "Path": "/checkout/{**catch-all}" } + ] + }, + "ErrorPages": { + "404": "/errors/not-found.html", + "503": "/errors/maintenance.html", + "5xx": "/errors/server-error.html" + }, + "ReverseProxy": { + "Routes": { + "checkout": { + "ClusterId": "checkout", + "Match": { "Path": "/checkout/{**catch-all}" } + } + }, + "Clusters": { + "checkout": { + "Destinations": { + "d1": { "Address": "http://checkout-api:8080/" } + } + } + } + } +} + diff --git a/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/maintenance.html b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/maintenance.html new file mode 100644 index 000000000..9c0b6a31f --- /dev/null +++ b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/maintenance.html @@ -0,0 +1,8 @@ + + + +

Checkout is temporarily unavailable

+

This exact 503 page wins over the broader 5xx wildcard page.

+ + + diff --git a/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/not-found.html b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/not-found.html new file mode 100644 index 000000000..045f8edc2 --- /dev/null +++ b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/not-found.html @@ -0,0 +1,8 @@ + + + +

We could not find that product

+

Try browsing featured categories or search again.

+ + + diff --git a/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/server-error.html b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/server-error.html new file mode 100644 index 000000000..a045f54d9 --- /dev/null +++ b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/errors/server-error.html @@ -0,0 +1,8 @@ + + + +

Something went wrong

+

The store is still responding with the original 5xx status code.

+ + + diff --git a/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/index.html b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/index.html new file mode 100644 index 000000000..d3646a424 --- /dev/null +++ b/samples/YarpApplication.SampleApps/04-commerce-errors/wwwroot/index.html @@ -0,0 +1,8 @@ + + + +

Contoso Store

+

The storefront is static, checkout is proxied, and failures render branded error pages while preserving the original status code.

+ + + diff --git a/samples/YarpApplication.SampleApps/05-edge-composition/appsettings.json b/samples/YarpApplication.SampleApps/05-edge-composition/appsettings.json new file mode 100644 index 000000000..1f52c0cf4 --- /dev/null +++ b/samples/YarpApplication.SampleApps/05-edge-composition/appsettings.json @@ -0,0 +1,73 @@ +{ + "$schema": "../../../src/Application/yarp-config.schema.json", + "StaticFiles": { + "Enabled": true + }, + "NavigationFallback": { + "Path": "/index.html", + "Exclude": [ + { "Path": "/api/{**catch-all}" }, + { "Path": "/orders/{**catch-all}" } + ] + }, + "Headers": [ + { + "Match": { "Path": "/{**catch-all}" }, + "Set": { + "X-Static-Host": "edge-frontend" + } + }, + { + "Match": { "Path": "/assets/{**catch-all}" }, + "Set": { + "Cache-Control": "public,max-age=31536000,immutable" + } + } + ], + "Redirects": [ + { + "Match": { "Path": "/old-campaign/{slug}" }, + "Destination": "/campaigns/{slug}", + "StatusCode": 301 + } + ], + "Rewrites": [ + { + "Regex": "^legacy-static$", + "Replacement": "assets/app.css" + }, + { + "Regex": "^content/(.*)$", + "Replacement": "api/content/$1" + } + ], + "ErrorPages": { + "404": "/errors/not-found.html", + "5xx": "/errors/server-error.html" + }, + "ReverseProxy": { + "Routes": { + "content": { + "ClusterId": "content", + "Match": { "Path": "/api/content/{**catch-all}" } + }, + "orders": { + "ClusterId": "orders", + "Match": { "Path": "/orders/{**catch-all}" } + } + }, + "Clusters": { + "content": { + "Destinations": { + "d1": { "Address": "http://content-api:8080/" } + } + }, + "orders": { + "Destinations": { + "d1": { "Address": "http://orders-api:8080/" } + } + } + } + } +} + diff --git a/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/assets/app.css b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/assets/app.css new file mode 100644 index 000000000..e489d88d8 --- /dev/null +++ b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/assets/app.css @@ -0,0 +1,6 @@ +body { + color: #222; + font-family: system-ui, sans-serif; + margin: 3rem; +} + diff --git a/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/not-found.html b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/not-found.html new file mode 100644 index 000000000..831bdbaca --- /dev/null +++ b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/not-found.html @@ -0,0 +1,8 @@ + + + +

Page not found

+

The edge frontend served this branded 404 page.

+ + + diff --git a/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/server-error.html b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/server-error.html new file mode 100644 index 000000000..c6c419cb8 --- /dev/null +++ b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/errors/server-error.html @@ -0,0 +1,8 @@ + + + +

Service temporarily unavailable

+

The original 5xx status code is preserved.

+ + + diff --git a/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/index.html b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/index.html new file mode 100644 index 000000000..2d578697e --- /dev/null +++ b/samples/YarpApplication.SampleApps/05-edge-composition/wwwroot/index.html @@ -0,0 +1,13 @@ + + + + + Contoso Edge Frontend + + + +

Contoso Edge Frontend

+

This sample combines static hosting, route exclusions, redirects, rewrites, proxying, and custom error pages.

+ + + diff --git a/samples/YarpApplication.SampleApps/README.md b/samples/YarpApplication.SampleApps/README.md new file mode 100644 index 000000000..4a8f74a28 --- /dev/null +++ b/samples/YarpApplication.SampleApps/README.md @@ -0,0 +1,26 @@ +# YARP Application sample apps + +These samples are small, realistic app layouts for the YARP application static-hosting and routing-rule features. They are not .NET projects; each sample is just an `appsettings.json` file plus any static `wwwroot` assets it needs. + +Run any sample from the repository root: + +```bash +export DOTNET_ROOT="$PWD/.dotnet" +export DOTNET_MULTILEVEL_LOOKUP=0 +export PATH="$PWD/.dotnet:$PATH" + +ASPNETCORE_URLS=http://127.0.0.1:5000 \ + dotnet run --no-launch-profile --project src/Application/Yarp.Application.csproj -- \ + samples/YarpApplication.SampleApps/01-marketing-site/appsettings.json +``` + +Then open `http://127.0.0.1:5000`. Samples that include `ReverseProxy` use realistic placeholder backend addresses such as `http://catalog-api:8080/`; point those at your own local services when trying them. + +| Sample app | Demonstrates | +| --- | --- | +| `01-marketing-site` | A static marketing site with SPA-style campaign fallback and long-lived asset caching headers. | +| `02-docs-site` | A documentation site with old URL redirects, "current docs" rewrites, and docs-specific headers. | +| `03-dashboard-spa` | A dashboard SPA that falls back to `index.html` while forwarding `/api` traffic to a backend. | +| `04-commerce-errors` | A commerce frontend with branded exact and wildcard custom error pages. | +| `05-edge-composition` | A composed edge frontend using rewrites, redirects, static assets, proxy routes, SPA fallback exclusions, and custom error pages together. | + From efb23434841336a60d0781e2b982e2df17bb925d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 28 Apr 2026 01:01:40 -0700 Subject: [PATCH 08/18] Fix markdown lint issues Normalize table spacing and remove an extra trailing blank line so markdownlint passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/YarpApplication.SampleApps/README.md | 1 - src/Application/README.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/samples/YarpApplication.SampleApps/README.md b/samples/YarpApplication.SampleApps/README.md index 4a8f74a28..b44cbcf5d 100644 --- a/samples/YarpApplication.SampleApps/README.md +++ b/samples/YarpApplication.SampleApps/README.md @@ -23,4 +23,3 @@ Then open `http://127.0.0.1:5000`. Samples that include `ReverseProxy` use reali | `03-dashboard-spa` | A dashboard SPA that falls back to `index.html` while forwarding `/api` traffic to a backend. | | `04-commerce-errors` | A commerce frontend with branded exact and wildcard custom error pages. | | `05-edge-composition` | A composed edge frontend using rewrites, redirects, static assets, proxy routes, SPA fallback exclusions, and custom error pages together. | - diff --git a/src/Application/README.md b/src/Application/README.md index 4052a8a0a..6540fbd01 100644 --- a/src/Application/README.md +++ b/src/Application/README.md @@ -232,7 +232,7 @@ Custom error pages for 4xx/5xx responses. The request is internally re-executed Keys are either a 3-digit HTTP status code (`"404"`) or a class wildcard (`"4xx"`, `"5xx"`). **Exact codes win over wildcards**, so in the example above: | Status | Page | -|---|---| +| --- | --- | | 400, 403 | `/client-error.html` | | 404 | `/404.html` | | 500, 502 | `/server-error.html` | From 7ef3ded4419a67efc752362827a7c2465ff9b479 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 28 Apr 2026 01:10:05 -0700 Subject: [PATCH 09/18] Trigger CI rerun for transient functional test failure The previous Azure Ubuntu leg failed in ReverseProxy.FunctionalTests Expect100Continue coverage, unrelated to the YARP Application changes in this branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 1cee14920997764c990b3278a9906bcc89429729 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 28 Apr 2026 21:54:09 -0700 Subject: [PATCH 10/18] Document status code page re-execute behavior Add comments explaining why custom error pages clear response state, restore the original status code, and reset routing state during re-execute. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Application/Features/ErrorPagesFeature.cs | 27 +++++++++++++++---- src/Application/Program.cs | 2 ++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/Application/Features/ErrorPagesFeature.cs b/src/Application/Features/ErrorPagesFeature.cs index a47d9c0eb..a5d690ccf 100644 --- a/src/Application/Features/ErrorPagesFeature.cs +++ b/src/Application/Features/ErrorPagesFeature.cs @@ -36,9 +36,9 @@ public static WebApplication UseErrorPages(this WebApplication app, YarpAppConfi return; } - // Mirror the re-execute pattern from - // Microsoft.AspNetCore.Diagnostics.StatusCodePagesExtensions.UseStatusCodePagesWithReExecute, - // adapted to look up the destination per status code instead of using a single template. + // StatusCodePages runs after the downstream pipeline has produced an error response. + // Mirror UseStatusCodePagesWithReExecute, but choose the destination from the status + // code map instead of using one template for every status. var originalPath = http.Request.Path; var originalQueryString = http.Request.QueryString; var originalStatusCode = http.Response.StatusCode; @@ -48,6 +48,9 @@ public static WebApplication UseErrorPages(this WebApplication app, YarpAppConfi ? new RouteValueDictionary(routeValues) : null; + // StatusCodeReExecuteFeature has an internal/private setter for OriginalStatusCode + // in some target frameworks. Use our own feature so custom error endpoints can still + // inspect the original status if they need it. http.Features.Set(new ErrorPageReExecuteFeature { OriginalPathBase = http.Request.PathBase.Value!, @@ -58,11 +61,21 @@ public static WebApplication UseErrorPages(this WebApplication app, YarpAppConfi RouteValues = originalRouteValues, }); - // Clear the chosen endpoint and route values so the re-executed request can be - // matched fresh against routing/static-files. + // Routing has already selected an endpoint for the original request. Clear it so the + // re-executed path can be matched as a new request against redirects/static/proxy/ + // fallback endpoints. http.SetEndpoint(null); http.Features.Get()?.RouteValues?.Clear(); + + // The response currently contains the original error status (for example 404). Clear + // it before re-executing so the target can run normally. This is especially important + // for YARP proxy targets because the forwarder refuses to start when the response has + // already been set to a non-200 status. http.Response.Clear(); + + // Error page targets typically produce a 200 response (static files, proxy backends, + // etc.). Restore the original status immediately before headers are sent so the client + // sees the original 404/500 while receiving the custom page body. http.Response.OnStarting(static state => { var (response, statusCode) = ((HttpResponse Response, int StatusCode))state; @@ -78,11 +91,15 @@ public static WebApplication UseErrorPages(this WebApplication app, YarpAppConfi } finally { + // If the target did not start the response, OnStarting will not run. Preserve the + // same status-code guarantee for empty/not-started responses. if (!http.Response.HasStarted) { http.Response.StatusCode = originalStatusCode; } + // Restore request/routing state for anything later in the pipeline and for logging + // or diagnostics that observe the context after the re-execute completes. http.Request.QueryString = originalQueryString; http.Request.Path = originalPath; http.SetEndpoint(originalEndpoint); diff --git a/src/Application/Program.cs b/src/Application/Program.cs index f14eb8b5a..3d3d23d18 100644 --- a/src/Application/Program.cs +++ b/src/Application/Program.cs @@ -66,6 +66,8 @@ // Middleware pipeline — order matters app.UseRewrites(config); +// StatusCodePages wraps the rest of the pipeline, so register it before anything +// that can produce the 4xx/5xx response it may re-execute. app.UseErrorPages(config); app.UseRouting(); app.UseStaticFiles(config); From f35c8c4e0ed1c2e78c12a8feaea8f0778501f484 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 28 Apr 2026 21:55:33 -0700 Subject: [PATCH 11/18] Add parsing examples to application feature comments Document the route-template, rewrite regex, redirect destination, and error-page key parsing paths with concrete examples so the config syntax is easier to follow in code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Application/Features/ErrorPagesFeature.cs | 4 ++++ src/Application/Features/RequestMatchEvaluator.cs | 6 +++++- src/Application/Features/RewritesFeature.cs | 3 +++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Application/Features/ErrorPagesFeature.cs b/src/Application/Features/ErrorPagesFeature.cs index a5d690ccf..9abd5cd47 100644 --- a/src/Application/Features/ErrorPagesFeature.cs +++ b/src/Application/Features/ErrorPagesFeature.cs @@ -164,6 +164,8 @@ public static ErrorPageRules Compile(IDictionary source) $"ErrorPages entry '{key}' must have a non-empty path."); } + // Parse ErrorPages keys as either exact status codes ("404") or status classes + // ("5xx"). Exact matches are stored separately so "404" wins over "4xx". if (TryParseExactCode(key, out var code)) { exact[code] = value; @@ -185,6 +187,7 @@ public static ErrorPageRules Compile(IDictionary source) private static bool TryParseExactCode(string key, out int code) { code = 0; + // Parse exact status-code keys like "404" into the integer 404. if (key.Length != 3) { return false; @@ -205,6 +208,7 @@ private static bool TryParseExactCode(string key, out int code) private static bool TryParseClassWildcard(string key, out int hundreds) { hundreds = 0; + // Parse status-class wildcard keys like "5xx" or "5XX" into the hundreds digit 5. if (key.Length != 3 || !char.IsDigit(key[0])) { return false; diff --git a/src/Application/Features/RequestMatchEvaluator.cs b/src/Application/Features/RequestMatchEvaluator.cs index 72409f375..4b11b789c 100644 --- a/src/Application/Features/RequestMatchEvaluator.cs +++ b/src/Application/Features/RequestMatchEvaluator.cs @@ -16,6 +16,8 @@ public RequestMatchEvaluator(RequestMatch match, string ruleDisplayName) { var path = ValidatePath(match, ruleDisplayName); Path = path; + // Parse ASP.NET route-template syntax such as "/api/{**catch-all}" or + // "/docs/{slug}" so static-host header rules use the same path semantics as endpoints. _pathMatcher = new TemplateMatcher(TemplateParser.Parse(path), new RouteValueDictionary()); } @@ -60,7 +62,9 @@ public bool TryMatch(PathString path, RouteValueDictionary values) /// /// Substitutes {name} placeholders in with values from - /// . Missing or null values resolve to an empty string. + /// . For example, "/docs/{slug}" with slug + /// "intro" becomes "/docs/intro". Missing or null values resolve to an + /// empty string. /// public static string ExpandTemplate(string template, RouteValueDictionary values) { diff --git a/src/Application/Features/RewritesFeature.cs b/src/Application/Features/RewritesFeature.cs index 44c695a14..b94f6a4f7 100644 --- a/src/Application/Features/RewritesFeature.cs +++ b/src/Application/Features/RewritesFeature.cs @@ -40,6 +40,9 @@ public static WebApplication UseRewrites(this WebApplication app, YarpAppConfig try { + // Parse the ASP.NET rewrite-middleware regex/replacement pair. Example: + // Regex "^docs/current/(.*)$" and Replacement "docs/v2/$1" rewrites + // "/docs/current/intro.html" to "/docs/v2/intro.html". options.AddRewrite(rule.Regex, rule.Replacement, rule.SkipRemainingRules); } catch (ArgumentException ex) From d81ab38ce65d6bc5e0d6ea38a447d66f7028d441 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 28 Apr 2026 22:00:21 -0700 Subject: [PATCH 12/18] Document endpoint order ranges for app routing rules Move relative endpoint order values to named constants and explain how endpoint routing uses those ranges for redirects and SPA fallback exclusions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Features/NavigationFallbackExclusionsFeature.cs | 9 ++++++--- src/Application/Features/RedirectsFeature.cs | 7 ++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Application/Features/NavigationFallbackExclusionsFeature.cs b/src/Application/Features/NavigationFallbackExclusionsFeature.cs index 2513a0fcb..bd50f9193 100644 --- a/src/Application/Features/NavigationFallbackExclusionsFeature.cs +++ b/src/Application/Features/NavigationFallbackExclusionsFeature.cs @@ -10,6 +10,11 @@ namespace Yarp.Application.Features; public static class NavigationFallbackExclusionsFeature { + // Endpoint routing chooses the lowest Order first. Fallback endpoints sit at the end of + // the table, so place exclusions just before the SPA fallback while still letting normal + // endpoints (including proxy routes) win. Add the config index to preserve rule order. + private const int FallbackExclusionEndpointOrderBase = int.MaxValue - 1000; + public static WebApplication MapNavigationFallbackExclusions(this WebApplication app, YarpAppConfig config) { if (config.NavigationFallback.Path is null || config.NavigationFallback.Exclude.Count == 0) @@ -20,7 +25,7 @@ public static WebApplication MapNavigationFallbackExclusions(this WebApplication for (var i = 0; i < config.NavigationFallback.Exclude.Count; i++) { var path = RequestMatchEvaluator.ValidatePath(config.NavigationFallback.Exclude[i], "NavigationFallback exclusion"); - var order = int.MaxValue - 1000 + i; + var order = FallbackExclusionEndpointOrderBase + i; app.MapFallback( path, context => @@ -31,8 +36,6 @@ public static WebApplication MapNavigationFallbackExclusions(this WebApplication .Add(endpointBuilder => { endpointBuilder.DisplayName = $"Fallback exclusion {path}"; - // Keep exclusions just ahead of the SPA fallback while still letting proxy - // routes and other normal endpoints win when they match. ((RouteEndpointBuilder)endpointBuilder).Order = order; }); } diff --git a/src/Application/Features/RedirectsFeature.cs b/src/Application/Features/RedirectsFeature.cs index 7ef9e2d4c..0d780929d 100644 --- a/src/Application/Features/RedirectsFeature.cs +++ b/src/Application/Features/RedirectsFeature.cs @@ -10,6 +10,11 @@ namespace Yarp.Application.Features; public static class RedirectsFeature { + // Endpoint routing chooses the lowest Order first when multiple route patterns match. + // Put configured redirects in a negative range so "/old/{**path}" beats normal proxy/ + // fallback endpoints, and add the config index so earlier redirect rules win ties. + private const int RedirectEndpointOrderBase = -1000; + public static WebApplication MapRedirects(this WebApplication app, YarpAppConfig config) { if (config.Redirects.Count == 0) @@ -20,7 +25,7 @@ public static WebApplication MapRedirects(this WebApplication app, YarpAppConfig for (var i = 0; i < config.Redirects.Count; i++) { var rule = new CompiledRedirectRule(config.Redirects[i]); - var order = -1000 + i; + var order = RedirectEndpointOrderBase + i; app.Map( rule.Path, context => From 9481d27a331c10a5b50a2c2b8d9f4be8e19a3ee6 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 2 May 2026 22:32:02 -0700 Subject: [PATCH 13/18] Address app routing PR feedback Use regular endpoints for SPA fallback exclusions so dotted file-like paths can be excluded, and clear preserved static-file endpoint state so status-code re-execution cannot restore stale endpoints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NavigationFallbackExclusionsFeature.cs | 9 ++- .../Features/StaticFilesFeature.cs | 17 ++-- test/Application.Tests/SpaFallbackTests.cs | 78 +++++++++++++++++++ 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/src/Application/Features/NavigationFallbackExclusionsFeature.cs b/src/Application/Features/NavigationFallbackExclusionsFeature.cs index bd50f9193..99a346cdc 100644 --- a/src/Application/Features/NavigationFallbackExclusionsFeature.cs +++ b/src/Application/Features/NavigationFallbackExclusionsFeature.cs @@ -10,9 +10,10 @@ namespace Yarp.Application.Features; public static class NavigationFallbackExclusionsFeature { - // Endpoint routing chooses the lowest Order first. Fallback endpoints sit at the end of - // the table, so place exclusions just before the SPA fallback while still letting normal - // endpoints (including proxy routes) win. Add the config index to preserve rule order. + // Endpoint routing chooses the lowest Order first. Exclusions are regular endpoints, not + // MapFallback endpoints, so patterns like "/.well-known/{**catch-all}" also match dotted + // file-like paths. Place them just before the SPA fallback while still letting normal + // endpoints (including proxy routes) win, and add the config index to preserve rule order. private const int FallbackExclusionEndpointOrderBase = int.MaxValue - 1000; public static WebApplication MapNavigationFallbackExclusions(this WebApplication app, YarpAppConfig config) @@ -26,7 +27,7 @@ public static WebApplication MapNavigationFallbackExclusions(this WebApplication { var path = RequestMatchEvaluator.ValidatePath(config.NavigationFallback.Exclude[i], "NavigationFallback exclusion"); var order = FallbackExclusionEndpointOrderBase + i; - app.MapFallback( + app.Map( path, context => { diff --git a/src/Application/Features/StaticFilesFeature.cs b/src/Application/Features/StaticFilesFeature.cs index bceead258..df8292f08 100644 --- a/src/Application/Features/StaticFilesFeature.cs +++ b/src/Application/Features/StaticFilesFeature.cs @@ -23,6 +23,7 @@ public static WebApplication UseStaticFiles(this WebApplication app, YarpAppConf var endpoint = context.GetEndpoint(); if (endpoint?.RequestDelegate is null) { + context.Items.Remove(PreservedEndpointKey); return next(); } @@ -55,12 +56,18 @@ public static WebApplication UseStaticFiles(this WebApplication app, YarpAppConf app.Use((context, next) => { - if (!context.Response.HasStarted - && context.GetEndpoint() is null - && context.Items.TryGetValue(PreservedEndpointKey, out var endpoint) - && endpoint is Endpoint preservedEndpoint) + if (context.Items.TryGetValue(PreservedEndpointKey, out var endpoint)) { - context.SetEndpoint(preservedEndpoint); + // The same HttpContext can be re-executed by StatusCodePages. Once this saved + // endpoint has been considered, remove it so a re-executed error-page path cannot + // accidentally restore the original request's endpoint. + context.Items.Remove(PreservedEndpointKey); + if (!context.Response.HasStarted + && context.GetEndpoint() is null + && endpoint is Endpoint preservedEndpoint) + { + context.SetEndpoint(preservedEndpoint); + } } return next(); diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index 69575ab22..5fe127f0d 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -9,10 +9,12 @@ using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; using Yarp.Application.Configuration; +using Yarp.Application.Features; namespace Yarp.Application.Tests; @@ -358,6 +360,27 @@ public async Task ObjectModel_FallbackExclude_Returns404_ForExcludedPaths() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + [Fact] + public async Task ObjectModel_FallbackExclude_MatchesDottedFileLikePaths() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/.well-known/{**catch-all}" } } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/.well-known/assetlinks.json"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("SPA Index", content); + } + [Fact] public async Task ObjectModel_FallbackExclude_DoesNotAffectOtherSpaRoutes() { @@ -851,6 +874,26 @@ public async Task ObjectModel_ErrorPages_ProxiedPage_PreservesOriginalStatusWhen Assert.Contains("Proxied error page", content); } + [Fact] + public async Task ObjectModel_ErrorPages_MissingPageDoesNotRestoreOriginalEndpointDuringReExecute() + { + var webRoot = Directory.CreateTempSubdirectory(); + try + { + await using var app = await CreateAppWithOriginalEndpointAsync(webRoot.FullName); + using var client = app.GetTestClient(); + + var response = await client.GetAsync("/original"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(1, app.Services.GetRequiredService().Count); + } + finally + { + webRoot.Delete(recursive: true); + } + } + [Fact] public async Task ObjectModel_ErrorPages_NoMatch_PassesThrough() { @@ -880,4 +923,39 @@ public void ObjectModel_ErrorPages_InvalidKey_ThrowsClearError() var ex = Assert.ThrowsAny(() => app.CreateClient()); Assert.Contains("3-digit status code", ex.Message); } + + private static async Task CreateAppWithOriginalEndpointAsync(string webRoot) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + WebRootPath = webRoot + }); + builder.WebHost.UseTestServer(); + builder.Services.AddSingleton(); + + var config = new YarpAppConfig + { + StaticFiles = { Enabled = true }, + ErrorPages = { ["404"] = "/missing-error-page.html" } + }; + + var app = builder.Build(); + app.UseErrorPages(config); + app.UseRouting(); + app.UseStaticFiles(config); + app.MapGet("/original", context => + { + context.RequestServices.GetRequiredService().Count++; + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + }); + + await app.StartAsync(); + return app; + } + + private sealed class OriginalEndpointCounter + { + public int Count { get; set; } + } } From 1175a5d598574a3c11f5e2dc34ec2eab72ae3287 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 2 May 2026 22:35:11 -0700 Subject: [PATCH 14/18] Clarify app routing feedback regression tests Add comments explaining the dotted-path fallback exclusion case and stale endpoint re-execute case covered by the feedback regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/Application.Tests/SpaFallbackTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index 5fe127f0d..65b7dc837 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -374,6 +374,8 @@ public async Task ObjectModel_FallbackExclude_MatchesDottedFileLikePaths() }); using var client = app.CreateClient(); + // This looks like a file request, so MapFallback would not catch it. The exclusion must + // still win so /.well-known assets do not accidentally fall back to the SPA shell. var response = await client.GetAsync("/.well-known/assetlinks.json"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -883,6 +885,9 @@ public async Task ObjectModel_ErrorPages_MissingPageDoesNotRestoreOriginalEndpoi await using var app = await CreateAppWithOriginalEndpointAsync(webRoot.FullName); using var client = app.GetTestClient(); + // /original produces an empty 404, so ErrorPages re-executes /missing-error-page.html. + // If StaticFilesFeature restores the preserved /original endpoint during that second + // pass, this counter would be incremented twice. var response = await client.GetAsync("/original"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); From b34d61169fe7d3fc3b84df19488f70d7b9f6b02d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 2 May 2026 22:37:07 -0700 Subject: [PATCH 15/18] Add app routing composition tests Cover additional feature combinations: rewrites feeding static-host header matching, redirects winning over fallback exclusions, and static-file error pages receiving header rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/Application.Tests/SpaFallbackTests.cs | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index 65b7dc837..48521c7c7 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -511,6 +511,41 @@ public async Task HeaderRules_DoNotApplyToProxiedResponses() Assert.False(response.Headers.Contains("X-Test")); } + [Fact] + public async Task ObjectModel_Rewrites_AffectStaticHeaderMatching() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/style.css" }, + Set = { ["X-Rewritten-Static"] = "true" } + } + }, + Rewrites = + { + new RewriteRule + { + Regex = "^legacy-style$", + Replacement = "style.css" + } + } + }); + using var client = app.CreateClient(); + + // Rewrites run before static files and header matching, so the header rule sees + // /style.css rather than the original /legacy-style request path. + var response = await client.GetAsync("/legacy-style"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("true", response.Headers.GetValues("X-Rewritten-Static").Single()); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("color: red", content); + } + [Fact] public async Task ObjectModel_Redirects_RunBeforeStaticFiles() { @@ -555,6 +590,37 @@ public async Task Redirects_RunBeforeReverseProxy() Assert.Equal("/docs", response.Headers.Location?.ToString()); } + [Fact] + public async Task ObjectModel_Redirects_RunBeforeFallbackExclusions() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + }, + Redirects = + { + new RedirectRule + { + Match = new RequestMatch { Path = "/api/old" }, + Destination = "/api/new", + StatusCode = 302 + } + } + }); + using var client = CreateNoRedirectClient(app); + + // Both the redirect and exclusion match. Redirects use an earlier endpoint order, so + // the request redirects instead of becoming the exclusion's 404. + var response = await client.GetAsync("/api/old"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("/api/new", response.Headers.Location?.ToString()); + } + [Fact] public async Task ObjectModel_Redirects_CanUseRouteValuesInDestination() { @@ -807,6 +873,34 @@ public async Task ObjectModel_ErrorPages_ExactCode_ServesPageWithOriginalStatus( Assert.Contains("Custom 404", content); } + [Fact] + public async Task ObjectModel_ErrorPages_StaticFilePageReceivesHeaderRules() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + ErrorPages = { ["404"] = "/404.html" }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/404.html" }, + Set = { ["X-Error-Page"] = "static-file" } + } + } + }); + using var client = app.CreateClient(); + + // ErrorPages re-executes /404.html through static files, so static-host header rules + // still apply to the custom error page body while the original 404 status is preserved. + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("static-file", response.Headers.GetValues("X-Error-Page").Single()); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Custom 404", content); + } + [Fact] public async Task ObjectModel_ErrorPages_ClassWildcard_MatchesAllCodesInClass() { From 77b99ad1a325d8353444b974910b1b0b49a4de38 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 3 May 2026 00:46:11 -0700 Subject: [PATCH 16/18] Fix app config feedback issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Configuration/YarpAppConfigBinder.cs | 6 ++--- src/Application/Features/ErrorPagesFeature.cs | 27 ++++++++++++++----- src/Application/README.md | 2 +- src/Application/yarp-config.schema.json | 12 +++++++-- test/Application.Tests/SpaFallbackTests.cs | 12 +++++++++ .../YarpAppConfigBinderTests.cs | 19 ++++++++++--- 6 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/Application/Configuration/YarpAppConfigBinder.cs b/src/Application/Configuration/YarpAppConfigBinder.cs index dd5f271fc..ce3385a70 100644 --- a/src/Application/Configuration/YarpAppConfigBinder.cs +++ b/src/Application/Configuration/YarpAppConfigBinder.cs @@ -46,9 +46,9 @@ private static void MapLegacyKeys(IConfiguration configuration, YarpAppConfig co && !string.Equals(configuration["YARP_DISABLE_SPA_FALLBACK"], "true", StringComparison.OrdinalIgnoreCase)) { // Legacy behavior: SPA fallback was on by default when static files were enabled - // Only apply if using legacy keys (no explicit NavigationFallback section) - if (!configuration.GetSection(nameof(config.NavigationFallback)).Exists() - && string.Equals(configuration["YARP_ENABLE_STATIC_FILES"], "true", StringComparison.OrdinalIgnoreCase)) + // through YARP_ENABLE_STATIC_FILES. Adding new NavigationFallback children like Exclude + // should not disable that default unless Path itself was explicitly configured. + if (string.Equals(configuration["YARP_ENABLE_STATIC_FILES"], "true", StringComparison.OrdinalIgnoreCase)) { config.NavigationFallback.Path = "/index.html"; } diff --git a/src/Application/Features/ErrorPagesFeature.cs b/src/Application/Features/ErrorPagesFeature.cs index 9abd5cd47..5732f3fec 100644 --- a/src/Application/Features/ErrorPagesFeature.cs +++ b/src/Application/Features/ErrorPagesFeature.cs @@ -158,21 +158,17 @@ public static ErrorPageRules Compile(IDictionary source) foreach (var (key, value) in source) { - if (string.IsNullOrWhiteSpace(value)) - { - throw new InvalidOperationException( - $"ErrorPages entry '{key}' must have a non-empty path."); - } + var path = ValidatePath(key, value); // Parse ErrorPages keys as either exact status codes ("404") or status classes // ("5xx"). Exact matches are stored separately so "404" wins over "4xx". if (TryParseExactCode(key, out var code)) { - exact[code] = value; + exact[code] = path; } else if (TryParseClassWildcard(key, out var hundreds)) { - classes[hundreds] = value; + classes[hundreds] = path; } else { @@ -184,6 +180,23 @@ public static ErrorPageRules Compile(IDictionary source) return new ErrorPageRules(exact, classes); } + private static string ValidatePath(string key, string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new InvalidOperationException( + $"ErrorPages entry '{key}' must have a non-empty path."); + } + + if (path[0] != '/') + { + throw new InvalidOperationException( + $"ErrorPages entry '{key}' path '{path}' must start with '/'."); + } + + return path; + } + private static bool TryParseExactCode(string key, out int code) { code = 0; diff --git a/src/Application/README.md b/src/Application/README.md index 6540fbd01..7cc5fe83e 100644 --- a/src/Application/README.md +++ b/src/Application/README.md @@ -229,7 +229,7 @@ Custom error pages for 4xx/5xx responses. The request is internally re-executed } ``` -Keys are either a 3-digit HTTP status code (`"404"`) or a class wildcard (`"4xx"`, `"5xx"`). **Exact codes win over wildcards**, so in the example above: +Keys are either a 3-digit HTTP status code (`"404"`) or a class wildcard (`"4xx"`, `"5xx"`). Values must be rooted request paths that start with `/`. **Exact codes win over wildcards**, so in the example above: | Status | Page | | --- | --- | diff --git a/src/Application/yarp-config.schema.json b/src/Application/yarp-config.schema.json index 06d628334..ea5a83f1d 100644 --- a/src/Application/yarp-config.schema.json +++ b/src/Application/yarp-config.schema.json @@ -121,8 +121,16 @@ "type": "object", "description": "Custom error pages, re-executed against the configured path while preserving the original status code. Keys are 3-digit status codes (e.g. '404') or class wildcards (e.g. '5xx'); exact codes win over wildcards.", "patternProperties": { - "^[0-9]{3}$": { "type": "string" }, - "^[0-9][xX][xX]$": { "type": "string" } + "^[0-9]{3}$": { + "type": "string", + "pattern": "^/", + "description": "Rooted request path for the custom error page (e.g. /404.html)" + }, + "^[0-9][xX][xX]$": { + "type": "string", + "pattern": "^/", + "description": "Rooted request path for the custom error page (e.g. /server-error.html)" + } }, "additionalProperties": false }, diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index 48521c7c7..7573b5c2e 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -1023,6 +1023,18 @@ public void ObjectModel_ErrorPages_InvalidKey_ThrowsClearError() Assert.Contains("3-digit status code", ex.Message); } + [Fact] + public void ObjectModel_ErrorPages_RelativePath_ThrowsClearError() + { + using var app = CreateApp(new YarpAppConfig + { + ErrorPages = { ["404"] = "404.html" } + }); + + var ex = Assert.ThrowsAny(() => app.CreateClient()); + Assert.Contains("must start with '/'", ex.Message); + } + private static async Task CreateAppWithOriginalEndpointAsync(string webRoot) { var builder = WebApplication.CreateBuilder(new WebApplicationOptions diff --git a/test/Application.Tests/YarpAppConfigBinderTests.cs b/test/Application.Tests/YarpAppConfigBinderTests.cs index 5835e6d78..b2a112c61 100644 --- a/test/Application.Tests/YarpAppConfigBinderTests.cs +++ b/test/Application.Tests/YarpAppConfigBinderTests.cs @@ -123,6 +123,20 @@ public void Legacy_EnableStaticFiles_ImpliesFallback() Assert.Equal("/index.html", config.NavigationFallback.Path); } + [Fact] + public void Legacy_EnableStaticFiles_ImpliesFallback_WhenOnlyFallbackExclusionsConfigured() + { + var config = Bind(new() + { + ["YARP_ENABLE_STATIC_FILES"] = "true", + ["NavigationFallback:Exclude:0:Path"] = "/api/{**catch-all}" + }); + + Assert.Equal("/index.html", config.NavigationFallback.Path); + var exclusion = Assert.Single(config.NavigationFallback.Exclude); + Assert.Equal("/api/{**catch-all}", exclusion.Path); + } + [Fact] public void Legacy_DisableSpaFallback() { @@ -145,15 +159,14 @@ public void Legacy_UnsafeCert() // Precedence: new config wins over legacy [Fact] - public void Precedence_NewConfigSection_PreventsLegacyFallback() + public void Precedence_ExplicitFallbackPathWinsOverLegacyFallback() { - // When NavigationFallback section exists explicitly, legacy - // YARP_ENABLE_STATIC_FILES doesn't auto-set fallback path var config = Bind(new() { ["YARP_ENABLE_STATIC_FILES"] = "true", ["NavigationFallback:Path"] = "/custom.html" }); + Assert.Equal("/custom.html", config.NavigationFallback.Path); } From 00113f2d20251b78f027ab7ef5d2d962ac887277 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 3 May 2026 23:00:43 -0700 Subject: [PATCH 17/18] Expand app request matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Application/Configuration/RequestMatch.cs | 6 + src/Application/Extensions.cs | 5 + .../NavigationFallbackExclusionsFeature.cs | 4 +- src/Application/Features/RedirectsFeature.cs | 6 +- .../Features/RequestMatchEvaluator.cs | 122 ++++++++++++-- .../Features/RequestQueryParameterMatcher.cs | 159 ++++++++++++++++++ .../Features/StaticHostHeadersFeature.cs | 12 +- src/Application/README.md | 21 ++- src/Application/yarp-config.schema.json | 53 +++++- test/Application.Tests/SpaFallbackTests.cs | 142 ++++++++++++++++ .../YarpAppConfigBinderTests.cs | 39 +++++ 11 files changed, 548 insertions(+), 21 deletions(-) create mode 100644 src/Application/Features/RequestQueryParameterMatcher.cs diff --git a/src/Application/Configuration/RequestMatch.cs b/src/Application/Configuration/RequestMatch.cs index 10b6972f9..4f25a8e62 100644 --- a/src/Application/Configuration/RequestMatch.cs +++ b/src/Application/Configuration/RequestMatch.cs @@ -6,4 +6,10 @@ namespace Yarp.Application.Configuration; public sealed class RequestMatch { public string? Path { get; set; } + + public List Hosts { get; set; } = []; + + public List Methods { get; set; } = []; + + public List QueryParameters { get; set; } = []; } diff --git a/src/Application/Extensions.cs b/src/Application/Extensions.cs index cbaca4a97..debb96a9d 100644 --- a/src/Application/Extensions.cs +++ b/src/Application/Extensions.cs @@ -4,7 +4,10 @@ using HealthChecks.ApplicationStatus.DependencyInjection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using OpenTelemetry; using OpenTelemetry.Exporter; @@ -12,6 +15,7 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Trace; using Yarp.Application.Configuration; +using Yarp.Application.Features; namespace Microsoft.Extensions.Hosting; @@ -24,6 +28,7 @@ public static TBuilder AddServiceDefaults(this TBuilder builder, YarpA builder.AddDefaultHealthChecks(); builder.Services.AddServiceDiscovery(); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); return builder; } diff --git a/src/Application/Features/NavigationFallbackExclusionsFeature.cs b/src/Application/Features/NavigationFallbackExclusionsFeature.cs index 99a346cdc..dd6bd09ed 100644 --- a/src/Application/Features/NavigationFallbackExclusionsFeature.cs +++ b/src/Application/Features/NavigationFallbackExclusionsFeature.cs @@ -25,7 +25,8 @@ public static WebApplication MapNavigationFallbackExclusions(this WebApplication for (var i = 0; i < config.NavigationFallback.Exclude.Count; i++) { - var path = RequestMatchEvaluator.ValidatePath(config.NavigationFallback.Exclude[i], "NavigationFallback exclusion"); + var match = config.NavigationFallback.Exclude[i]; + var path = RequestMatchEvaluator.GetPathPattern(match, "NavigationFallback exclusion"); var order = FallbackExclusionEndpointOrderBase + i; app.Map( path, @@ -38,6 +39,7 @@ public static WebApplication MapNavigationFallbackExclusions(this WebApplication { endpointBuilder.DisplayName = $"Fallback exclusion {path}"; ((RouteEndpointBuilder)endpointBuilder).Order = order; + RequestMatchEvaluator.AddEndpointMetadata(endpointBuilder, match, "NavigationFallback exclusion"); }); } diff --git a/src/Application/Features/RedirectsFeature.cs b/src/Application/Features/RedirectsFeature.cs index 0d780929d..dc9ae82f5 100644 --- a/src/Application/Features/RedirectsFeature.cs +++ b/src/Application/Features/RedirectsFeature.cs @@ -41,6 +41,7 @@ public static WebApplication MapRedirects(this WebApplication app, YarpAppConfig { endpointBuilder.DisplayName = $"Redirect {rule.Path}"; ((RouteEndpointBuilder)endpointBuilder).Order = order; + RequestMatchEvaluator.AddEndpointMetadata(endpointBuilder, rule.Match, "Redirect rules"); }); } @@ -53,7 +54,8 @@ private sealed class CompiledRedirectRule public CompiledRedirectRule(RedirectRule rule) { - Path = RequestMatchEvaluator.ValidatePath(rule.Match, "Redirect rules"); + Match = rule.Match ?? throw new InvalidOperationException("Redirect rules requires a Match object."); + Path = RequestMatchEvaluator.GetPathPattern(Match, "Redirect rules"); if (string.IsNullOrWhiteSpace(rule.Destination)) { @@ -77,6 +79,8 @@ public CompiledRedirectRule(RedirectRule rule) public string Path { get; } + public RequestMatch Match { get; } + public string BuildDestination(RouteValueDictionary values) => RequestMatchEvaluator.ExpandTemplate(Destination, values); } diff --git a/src/Application/Features/RequestMatchEvaluator.cs b/src/Application/Features/RequestMatchEvaluator.cs index 4b11b789c..88b77b074 100644 --- a/src/Application/Features/RequestMatchEvaluator.cs +++ b/src/Application/Features/RequestMatchEvaluator.cs @@ -1,35 +1,45 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Template; +using Microsoft.Extensions.Primitives; using Yarp.Application.Configuration; +using Yarp.ReverseProxy.Configuration; namespace Yarp.Application.Features; internal sealed class RequestMatchEvaluator { + private const string CatchAllPathPattern = "/{**catch-all}"; + private readonly TemplateMatcher _pathMatcher; + private readonly StringSegment[] _hosts; + private readonly string[] _methods; + private readonly RequestQueryParameterMatcher[] _queryMatchers; public RequestMatchEvaluator(RequestMatch match, string ruleDisplayName) { - var path = ValidatePath(match, ruleDisplayName); + var path = GetPathPattern(match, ruleDisplayName); Path = path; // Parse ASP.NET route-template syntax such as "/api/{**catch-all}" or // "/docs/{slug}" so static-host header rules use the same path semantics as endpoints. _pathMatcher = new TemplateMatcher(TemplateParser.Parse(path), new RouteValueDictionary()); + _hosts = CompileHosts(match.Hosts, ruleDisplayName); + _methods = CompileMethods(match.Methods, ruleDisplayName); + _queryMatchers = CompileQueryParameters(match.QueryParameters, ruleDisplayName); } public string Path { get; } /// - /// Validates that has a non-empty - /// and returns it. Use this when only the path string is needed (e.g. when matching is - /// delegated to ASP.NET endpoint routing) and a full is - /// unnecessary. + /// Returns the route pattern for . Use this when path matching is + /// delegated to ASP.NET endpoint routing and a full is + /// unnecessary. Empty paths follow YARP route-match behavior and become a catch-all pattern. /// - public static string ValidatePath(RequestMatch match, string ruleDisplayName) + public static string GetPathPattern(RequestMatch match, string ruleDisplayName) { if (match is null) { @@ -38,26 +48,50 @@ public static string ValidatePath(RequestMatch match, string ruleDisplayName) if (string.IsNullOrWhiteSpace(match.Path)) { - throw new InvalidOperationException($"{ruleDisplayName} requires Match.Path to be set."); + return CatchAllPathPattern; } return match.Path; } + public static void AddEndpointMetadata(EndpointBuilder endpointBuilder, RequestMatch match, string ruleDisplayName) + { + if (match.Hosts.Count > 0) + { + endpointBuilder.Metadata.Add(new HostAttribute( + CompileHosts(match.Hosts, ruleDisplayName).Select(static host => host.Value!).ToArray())); + } + + if (match.Methods.Count > 0) + { + endpointBuilder.Metadata.Add(new HttpMethodMetadata(CompileMethods(match.Methods, ruleDisplayName))); + } + + if (match.QueryParameters.Count > 0) + { + endpointBuilder.Metadata.Add(new RequestQueryParameterMetadata( + CompileQueryParameters(match.QueryParameters, ruleDisplayName))); + } + } + public bool TryMatch(HttpContext context, RouteValueDictionary values) { ArgumentNullException.ThrowIfNull(context); - return TryMatch(context.Request.Path, values); + return TryMatch(context, context.Request.Path, values); } - public bool TryMatch(PathString path, RouteValueDictionary values) + public bool TryMatch(HttpContext context, PathString path, RouteValueDictionary values) { + ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(values); // Route matching expects a rooted request path. Normal requests already have one, but // normalize the empty-path case so "/" behaves consistently in tests and callbacks. path = string.IsNullOrEmpty(path.Value) ? new PathString("/") : path; - return _pathMatcher.TryMatch(path, values); + return _pathMatcher.TryMatch(path, values) + && MatchHost(context.Request.Host) + && MatchMethod(context.Request.Method) + && MatchQuery(context.Request.Query); } /// @@ -81,4 +115,72 @@ public static string ExpandTemplate(string template, RouteValueDictionary values return builder.ToString(); } + + private static StringSegment[] CompileHosts(List hosts, string ruleDisplayName) + { + var compiled = new StringSegment[hosts.Count]; + for (var i = 0; i < hosts.Count; i++) + { + if (string.IsNullOrWhiteSpace(hosts[i])) + { + throw new InvalidOperationException($"{ruleDisplayName} contains an empty Match.Hosts entry."); + } + + // Parse host patterns using endpoint routing's HostString matcher format. Examples: + // "example.com", "*.example.com", and "example.com:8443". + compiled[i] = new StringSegment(hosts[i]); + } + + return compiled; + } + + private static string[] CompileMethods(List methods, string ruleDisplayName) + { + var compiled = new string[methods.Count]; + for (var i = 0; i < methods.Count; i++) + { + if (string.IsNullOrWhiteSpace(methods[i])) + { + throw new InvalidOperationException($"{ruleDisplayName} contains an empty Match.Methods entry."); + } + + compiled[i] = methods[i]; + } + + return compiled; + } + + private static RequestQueryParameterMatcher[] CompileQueryParameters( + List queryParameters, + string ruleDisplayName) + { + var compiled = new RequestQueryParameterMatcher[queryParameters.Count]; + for (var i = 0; i < queryParameters.Count; i++) + { + // Parse YARP-style query match entries. Example: + // { Name: "preview", Values: [ "true" ], Mode: "Exact" } matches "?preview=true". + compiled[i] = new RequestQueryParameterMatcher(queryParameters[i], ruleDisplayName); + } + + return compiled; + } + + private bool MatchHost(HostString host) + => _hosts.Length == 0 || (host.HasValue && HostString.MatchesAny(new StringSegment(host.Value), _hosts)); + + private bool MatchMethod(string method) + => _methods.Length == 0 || _methods.Any(candidate => string.Equals(candidate, method, StringComparison.OrdinalIgnoreCase)); + + private bool MatchQuery(IQueryCollection query) + { + foreach (var matcher in _queryMatchers) + { + if (!matcher.Match(query)) + { + return false; + } + } + + return true; + } } diff --git a/src/Application/Features/RequestQueryParameterMatcher.cs b/src/Application/Features/RequestQueryParameterMatcher.cs new file mode 100644 index 000000000..8a92febf9 --- /dev/null +++ b/src/Application/Features/RequestQueryParameterMatcher.cs @@ -0,0 +1,159 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.Primitives; +using Yarp.ReverseProxy.Configuration; + +namespace Yarp.Application.Features; + +internal sealed class RequestQueryParameterMatcher +{ + public RequestQueryParameterMatcher(RouteQueryParameter queryParameter, string ruleDisplayName) + { + if (string.IsNullOrEmpty(queryParameter.Name)) + { + throw new InvalidOperationException($"{ruleDisplayName} query parameter match requires a non-empty Name."); + } + + if (queryParameter.Mode != QueryParameterMatchMode.Exists + && (queryParameter.Values is null || queryParameter.Values.Count == 0)) + { + throw new InvalidOperationException($"{ruleDisplayName} query parameter match '{queryParameter.Name}' requires at least one value."); + } + + if (queryParameter.Mode == QueryParameterMatchMode.Exists && queryParameter.Values?.Count > 0) + { + throw new InvalidOperationException($"{ruleDisplayName} query parameter match '{queryParameter.Name}' must not specify values when Mode is Exists."); + } + + if (queryParameter.Values is not null && queryParameter.Values.Any(string.IsNullOrEmpty)) + { + throw new InvalidOperationException($"{ruleDisplayName} query parameter match '{queryParameter.Name}' contains an empty value."); + } + + Name = queryParameter.Name; + Values = queryParameter.Values?.ToArray() ?? []; + Mode = queryParameter.Mode; + Comparison = queryParameter.IsCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + } + + public string Name { get; } + + public string[] Values { get; } + + public QueryParameterMatchMode Mode { get; } + + private StringComparison Comparison { get; } + + public bool Match(IQueryCollection query) + { + query.TryGetValue(Name, out var requestQueryParameterValues); + var valueIsEmpty = StringValues.IsNullOrEmpty(requestQueryParameterValues); + + return Mode switch + { + QueryParameterMatchMode.Exists => !valueIsEmpty, + QueryParameterMatchMode.Exact => !valueIsEmpty && TryMatch(requestQueryParameterValues), + QueryParameterMatchMode.Prefix => !valueIsEmpty && TryMatch(requestQueryParameterValues), + QueryParameterMatchMode.Contains => !valueIsEmpty && TryMatch(requestQueryParameterValues), + QueryParameterMatchMode.NotContains => valueIsEmpty || TryMatch(requestQueryParameterValues), + _ => false + }; + } + + private bool TryMatch(StringValues requestQueryParameterValues) + { + for (var i = 0; i < requestQueryParameterValues.Count; i++) + { + var requestValue = requestQueryParameterValues[i]; + if (requestValue is null) + { + continue; + } + + foreach (var expectedValue in Values) + { + if (TryMatch(requestValue, expectedValue)) + { + return Mode != QueryParameterMatchMode.NotContains; + } + } + } + + return Mode == QueryParameterMatchMode.NotContains; + } + + private bool TryMatch(string queryValue, string expectedValue) + => Mode switch + { + QueryParameterMatchMode.Exact => queryValue.Equals(expectedValue, Comparison), + QueryParameterMatchMode.Prefix => queryValue.StartsWith(expectedValue, Comparison), + _ => queryValue.Contains(expectedValue, Comparison) + }; +} + +internal sealed class RequestQueryParameterMetadata +{ + public RequestQueryParameterMetadata(RequestQueryParameterMatcher[] matchers) + { + Matchers = matchers; + } + + public RequestQueryParameterMatcher[] Matchers { get; } +} + +internal sealed class RequestQueryParameterMatcherPolicy : MatcherPolicy, IEndpointComparerPolicy, IEndpointSelectorPolicy +{ + public override int Order => -25; + + public IComparer Comparer { get; } = Comparer.Create(static (x, y) => + (y.Metadata.GetMetadata()?.Matchers.Length ?? 0) + .CompareTo(x.Metadata.GetMetadata()?.Matchers.Length ?? 0)); + + bool IEndpointSelectorPolicy.AppliesToEndpoints(IReadOnlyList endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + if (ContainsDynamicEndpoints(endpoints)) + { + return true; + } + + return endpoints.Any(static endpoint => + endpoint.Metadata.GetMetadata()?.Matchers.Length > 0); + } + + public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(candidates); + + for (var i = 0; i < candidates.Count; i++) + { + if (!candidates.IsValidCandidate(i)) + { + continue; + } + + var matchers = candidates[i].Endpoint.Metadata.GetMetadata()?.Matchers; + if (matchers is null) + { + continue; + } + + foreach (var matcher in matchers) + { + if (!matcher.Match(httpContext.Request.Query)) + { + candidates.SetValidity(i, false); + break; + } + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Application/Features/StaticHostHeadersFeature.cs b/src/Application/Features/StaticHostHeadersFeature.cs index 450e43161..c705d7862 100644 --- a/src/Application/Features/StaticHostHeadersFeature.cs +++ b/src/Application/Features/StaticHostHeadersFeature.cs @@ -33,7 +33,7 @@ public static WebApplication UseStaticHostHeaders(this WebApplication app, YarpA static state => { var (httpContext, originalPath, rules) = ((HttpContext, PathString, CompiledHeaderRule[]))state; - ApplyHeaders(originalPath, httpContext.Response.Headers, rules); + ApplyHeaders(httpContext, originalPath, httpContext.Response.Headers, rules); return Task.CompletedTask; }, (context, requestPath, headerRules)); @@ -54,17 +54,17 @@ public static WebApplication UseStaticHostHeaders(this WebApplication app, YarpA // StaticFileMiddleware doesn't create endpoints, so apply the same header rules through // OnPrepareResponse to keep static files and SPA fallback behavior aligned. - return context => ApplyHeaders(context.Context.Request.Path, context.Context.Response.Headers, headerRules); + return context => ApplyHeaders(context.Context, context.Context.Request.Path, context.Context.Response.Headers, headerRules); } private static CompiledHeaderRule[] CompileHeaderRules(YarpAppConfig config) => config.Headers.Select(rule => new CompiledHeaderRule(rule)).ToArray(); - private static void ApplyHeaders(PathString requestPath, IHeaderDictionary headers, CompiledHeaderRule[] headerRules) + private static void ApplyHeaders(HttpContext context, PathString requestPath, IHeaderDictionary headers, CompiledHeaderRule[] headerRules) { foreach (var headerRule in headerRules) { - headerRule.Apply(requestPath, headers); + headerRule.Apply(context, requestPath, headers); } } @@ -102,9 +102,9 @@ public CompiledHeaderRule(HeaderRule rule) .ToArray(); } - public void Apply(PathString requestPath, IHeaderDictionary headers) + public void Apply(HttpContext context, PathString requestPath, IHeaderDictionary headers) { - if (!_matcher.TryMatch(requestPath, new())) + if (!_matcher.TryMatch(context, requestPath, new())) { return; } diff --git a/src/Application/README.md b/src/Application/README.md index 7cc5fe83e..f09a567d5 100644 --- a/src/Application/README.md +++ b/src/Application/README.md @@ -111,7 +111,26 @@ A few consequences worth internalizing: ### Match syntax -Routed features (`Headers`, `Redirects`, `NavigationFallback.Exclude`) match using ASP.NET route templates — `/blog/{slug}`, `/api/{**catch-all}`. The same engine that powers `MapGet`. Captures from `Match.Path` are available in `Destination` as `{name}` substitutions. +Routed features (`Headers`, `Redirects`, `NavigationFallback.Exclude`) match using YARP-style route criteria. `Match.Path` uses ASP.NET route templates — `/blog/{slug}`, `/api/{**catch-all}` — the same engine that powers `MapGet`. Omit `Path` to match all paths. Captures from `Match.Path` are available in `Destination` as `{name}` substitutions. + +`Match.Hosts`, `Match.Methods`, and `Match.QueryParameters` narrow the same rule. Hosts use endpoint-routing host patterns (`example.com`, `*.example.com`, `example.com:8443`). Query parameters use YARP's route-match modes: `Exact`, `Contains`, `NotContains`, `Prefix`, and `Exists`. + +```json +{ + "Match": { + "Path": "/docs/{slug}", + "Hosts": [ "example.com", "*.example.com" ], + "Methods": [ "GET", "HEAD" ], + "QueryParameters": [ + { + "Name": "preview", + "Values": [ "true" ], + "Mode": "Exact" + } + ] + } +} +``` `Rewrites` use a different syntax — regex with `$n` capture groups — because they delegate to the standard ASP.NET [URL rewrite middleware](https://learn.microsoft.com/aspnet/core/fundamentals/url-rewriting). This is intentional: routed features are routes (so they use route-template syntax), and rewrites are rewrites (so they use the existing rewrite syntax). No new syntax is introduced. diff --git a/src/Application/yarp-config.schema.json b/src/Application/yarp-config.schema.json index ea5a83f1d..b457c4032 100644 --- a/src/Application/yarp-config.schema.json +++ b/src/Application/yarp-config.schema.json @@ -6,14 +6,63 @@ "definitions": { "requestMatch": { "type": "object", - "description": "Request match criteria. Path uses ASP.NET route pattern syntax.", + "description": "Request match criteria aligned with YARP route matching. Path uses ASP.NET route pattern syntax; omitted Path matches all paths.", "properties": { "Path": { "type": "string", "description": "Request path route pattern to match" + }, + "Hosts": { + "type": "array", + "description": "Host patterns to match. Supports endpoint-routing host patterns such as example.com, *.example.com, and example.com:8443.", + "items": { + "type": "string" + } + }, + "Methods": { + "type": "array", + "description": "HTTP methods to match, such as GET, HEAD, or POST.", + "items": { + "type": "string" + } + }, + "QueryParameters": { + "type": "array", + "description": "YARP-style query parameter matchers. All configured query parameters must match.", + "items": { + "$ref": "#/definitions/queryParameterMatch" + } + } + }, + "additionalProperties": false + }, + "queryParameterMatch": { + "type": "object", + "properties": { + "Name": { + "type": "string", + "description": "Query parameter name to match" + }, + "Values": { + "type": "array", + "description": "Allowed values. Required unless Mode is Exists.", + "items": { + "type": "string" + } + }, + "Mode": { + "type": "string", + "enum": ["Exact", "Contains", "NotContains", "Prefix", "Exists"], + "default": "Exact", + "description": "How query parameter values are compared" + }, + "IsCaseSensitive": { + "type": "boolean", + "default": false, + "description": "Whether value matching is case-sensitive" } }, - "required": ["Path"], + "required": ["Name"], "additionalProperties": false } }, diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index 7573b5c2e..b2d569939 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -15,6 +15,7 @@ using Xunit; using Yarp.Application.Configuration; using Yarp.Application.Features; +using Yarp.ReverseProxy.Configuration; namespace Yarp.Application.Tests; @@ -444,6 +445,53 @@ public async Task ObjectModel_FallbackExclude_DoesNotAffectReverseProxyRoutes() Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); } + [Fact] + public async Task ObjectModel_FallbackExclude_CanMatchHostMethodAndQuery() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = + { + new RequestMatch + { + Path = "/api/{**catch-all}", + Hosts = { "example.com" }, + Methods = { "GET" }, + QueryParameters = + { + new RouteQueryParameter + { + Name = "skipFallback", + Mode = QueryParameterMatchMode.Exists + } + } + } + } + } + }); + using var client = app.CreateClient(); + + using var matchingRequest = new HttpRequestMessage(HttpMethod.Get, "/api/users?skipFallback=1"); + matchingRequest.Headers.Host = "example.com"; + var matchingResponse = await client.SendAsync(matchingRequest); + + using var missingQueryRequest = new HttpRequestMessage(HttpMethod.Get, "/api/users"); + missingQueryRequest.Headers.Host = "example.com"; + var missingQueryResponse = await client.SendAsync(missingQueryRequest); + + using var wrongHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/users?skipFallback=1"); + wrongHostRequest.Headers.Host = "other.example.com"; + var wrongHostResponse = await client.SendAsync(wrongHostRequest); + + Assert.Equal(HttpStatusCode.NotFound, matchingResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, missingQueryResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, wrongHostResponse.StatusCode); + } + [Fact] public async Task ObjectModel_HeaderRules_ApplyToStaticFiles() { @@ -467,6 +515,51 @@ public async Task ObjectModel_HeaderRules_ApplyToStaticFiles() Assert.Equal("applied", response.Headers.GetValues("X-Test").Single()); } + [Fact] + public async Task ObjectModel_HeaderRules_CanMatchHostMethodAndQuery() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch + { + Path = "/style.css", + Hosts = { "example.com" }, + Methods = { "GET" }, + QueryParameters = + { + new RouteQueryParameter + { + Name = "preview", + Values = ["true"], + Mode = QueryParameterMatchMode.Exact + } + } + }, + Set = { ["X-Matched"] = "yes" } + } + } + }); + using var client = app.CreateClient(); + + using var matchingRequest = new HttpRequestMessage(HttpMethod.Get, "/style.css?preview=true"); + matchingRequest.Headers.Host = "example.com"; + var matchingResponse = await client.SendAsync(matchingRequest); + + using var wrongQueryRequest = new HttpRequestMessage(HttpMethod.Get, "/style.css?preview=false"); + wrongQueryRequest.Headers.Host = "example.com"; + var wrongQueryResponse = await client.SendAsync(wrongQueryRequest); + + Assert.Equal(HttpStatusCode.OK, matchingResponse.StatusCode); + Assert.Equal("yes", matchingResponse.Headers.GetValues("X-Matched").Single()); + Assert.Equal(HttpStatusCode.OK, wrongQueryResponse.StatusCode); + Assert.False(wrongQueryResponse.Headers.Contains("X-Matched")); + } + [Fact] public async Task ObjectModel_HeaderRules_ApplyToFallbackResponses() { @@ -644,6 +737,55 @@ public async Task ObjectModel_Redirects_CanUseRouteValuesInDestination() Assert.Equal("/articles/getting-started/install", response.Headers.Location?.ToString()); } + [Fact] + public async Task ObjectModel_Redirects_CanMatchHostMethodAndQuery() + { + using var app = CreateApp(new YarpAppConfig + { + Redirects = + { + new RedirectRule + { + Match = new RequestMatch + { + Path = "/old/{slug}", + Hosts = { "example.com" }, + Methods = { "POST" }, + QueryParameters = + { + new RouteQueryParameter + { + Name = "preview", + Values = ["true"], + Mode = QueryParameterMatchMode.Exact + } + } + }, + Destination = "/new/{slug}", + StatusCode = 302 + } + } + }); + using var client = CreateNoRedirectClient(app); + + using var getRequest = new HttpRequestMessage(HttpMethod.Get, "/old/page?preview=true"); + getRequest.Headers.Host = "example.com"; + var getResponse = await client.SendAsync(getRequest); + + using var wrongQueryRequest = new HttpRequestMessage(HttpMethod.Post, "/old/page?preview=false"); + wrongQueryRequest.Headers.Host = "example.com"; + var wrongQueryResponse = await client.SendAsync(wrongQueryRequest); + + using var matchingRequest = new HttpRequestMessage(HttpMethod.Post, "/old/page?preview=true"); + matchingRequest.Headers.Host = "example.com"; + var matchingResponse = await client.SendAsync(matchingRequest); + + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, wrongQueryResponse.StatusCode); + Assert.Equal(HttpStatusCode.Found, matchingResponse.StatusCode); + Assert.Equal("/new/page", matchingResponse.Headers.Location?.ToString()); + } + [Fact] public async Task ObjectModel_EverythingDisabled() { diff --git a/test/Application.Tests/YarpAppConfigBinderTests.cs b/test/Application.Tests/YarpAppConfigBinderTests.cs index b2a112c61..062045866 100644 --- a/test/Application.Tests/YarpAppConfigBinderTests.cs +++ b/test/Application.Tests/YarpAppConfigBinderTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration; using Xunit; using Yarp.Application.Configuration; +using Yarp.ReverseProxy.Configuration; namespace Yarp.Application.Tests; @@ -88,6 +89,44 @@ public void Bind_RedirectRules() Assert.Equal(302, rule.StatusCode); } + [Fact] + public void Bind_RequestMatch_HostsMethodsAndQueryParameters() + { + var config = Bind(new() + { + ["Redirects:0:Match:Path"] = "/docs/{slug}", + ["Redirects:0:Match:Hosts:0"] = "example.com", + ["Redirects:0:Match:Hosts:1"] = "*.example.com", + ["Redirects:0:Match:Methods:0"] = "GET", + ["Redirects:0:Match:Methods:1"] = "HEAD", + ["Redirects:0:Match:QueryParameters:0:Name"] = "preview", + ["Redirects:0:Match:QueryParameters:0:Values:0"] = "true", + ["Redirects:0:Match:QueryParameters:0:Mode"] = "Exact", + ["Redirects:0:Match:QueryParameters:1:Name"] = "tenant", + ["Redirects:0:Match:QueryParameters:1:Mode"] = "Exists", + ["Redirects:0:Destination"] = "/new-docs/{slug}" + }); + + var match = Assert.Single(config.Redirects).Match; + Assert.Equal("/docs/{slug}", match.Path); + Assert.Equal(["example.com", "*.example.com"], match.Hosts); + Assert.Equal(["GET", "HEAD"], match.Methods); + Assert.Collection( + match.QueryParameters, + query => + { + Assert.Equal("preview", query.Name); + Assert.Equal(["true"], query.Values); + Assert.Equal(QueryParameterMatchMode.Exact, query.Mode); + }, + query => + { + Assert.Equal("tenant", query.Name); + Assert.Null(query.Values); + Assert.Equal(QueryParameterMatchMode.Exists, query.Mode); + }); + } + [Fact] public void Bind_TelemetryUnsafeCert() { From 17db4d5bfdbf24dc1087bcaaa02761f5b6cd242e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 3 May 2026 23:12:55 -0700 Subject: [PATCH 18/18] Generalize app response headers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Application/Features/RedirectsFeature.cs | 2 + ...rsFeature.cs => ResponseHeadersFeature.cs} | 31 ++++--- .../Features/StaticFilesFeature.cs | 2 +- src/Application/Program.cs | 2 +- src/Application/README.md | 8 +- src/Application/yarp-config.schema.json | 2 +- test/Application.Tests/SpaFallbackTests.cs | 90 ++++++++++++++++++- 7 files changed, 119 insertions(+), 18 deletions(-) rename src/Application/Features/{StaticHostHeadersFeature.cs => ResponseHeadersFeature.cs} (73%) diff --git a/src/Application/Features/RedirectsFeature.cs b/src/Application/Features/RedirectsFeature.cs index dc9ae82f5..a3ae98c06 100644 --- a/src/Application/Features/RedirectsFeature.cs +++ b/src/Application/Features/RedirectsFeature.cs @@ -22,6 +22,7 @@ public static WebApplication MapRedirects(this WebApplication app, YarpAppConfig return app; } + var headerRules = ResponseHeadersFeature.CompileHeaderRules(config); for (var i = 0; i < config.Redirects.Count; i++) { var rule = new CompiledRedirectRule(config.Redirects[i]); @@ -32,6 +33,7 @@ public static WebApplication MapRedirects(this WebApplication app, YarpAppConfig { context.Response.StatusCode = rule.StatusCode; context.Response.Headers.Location = rule.BuildDestination(context.Request.RouteValues); + ResponseHeadersFeature.ApplyHeaders(context, context.Request.Path, context.Response.Headers, headerRules); return Task.CompletedTask; }) // Redirects need to run ahead of static files, so execute them directly from diff --git a/src/Application/Features/StaticHostHeadersFeature.cs b/src/Application/Features/ResponseHeadersFeature.cs similarity index 73% rename from src/Application/Features/StaticHostHeadersFeature.cs rename to src/Application/Features/ResponseHeadersFeature.cs index c705d7862..bc226656d 100644 --- a/src/Application/Features/StaticHostHeadersFeature.cs +++ b/src/Application/Features/ResponseHeadersFeature.cs @@ -2,15 +2,17 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.StaticFiles; using Yarp.Application.Configuration; +using Yarp.ReverseProxy.Model; namespace Yarp.Application.Features; -public static class StaticHostHeadersFeature +public static class ResponseHeadersFeature { - public static WebApplication UseStaticHostHeaders(this WebApplication app, YarpAppConfig config) + public static WebApplication UseResponseHeaders(this WebApplication app, YarpAppConfig config) { var headerRules = CompileHeaderRules(config); if (headerRules.Length == 0) @@ -20,9 +22,7 @@ public static WebApplication UseStaticHostHeaders(this WebApplication app, YarpA app.Use((context, next) => { - // This middleware runs after UseRouting. The SPA fallback endpoint carries explicit - // metadata, while static files use OnPrepareResponse because they are not endpoints. - if (context.GetEndpoint()?.Metadata.GetMetadata() is null) + if (IsProxyEndpoint(context)) { return next(); } @@ -33,7 +33,11 @@ public static WebApplication UseStaticHostHeaders(this WebApplication app, YarpA static state => { var (httpContext, originalPath, rules) = ((HttpContext, PathString, CompiledHeaderRule[]))state; - ApplyHeaders(httpContext, originalPath, httpContext.Response.Headers, rules); + if (!IsSupersededByStatusCodeReExecute(httpContext, originalPath)) + { + ApplyHeaders(httpContext, originalPath, httpContext.Response.Headers, rules); + } + return Task.CompletedTask; }, (context, requestPath, headerRules)); @@ -53,14 +57,14 @@ public static WebApplication UseStaticHostHeaders(this WebApplication app, YarpA } // StaticFileMiddleware doesn't create endpoints, so apply the same header rules through - // OnPrepareResponse to keep static files and SPA fallback behavior aligned. + // OnPrepareResponse to keep static files aligned with routed app-generated responses. return context => ApplyHeaders(context.Context, context.Context.Request.Path, context.Context.Response.Headers, headerRules); } - private static CompiledHeaderRule[] CompileHeaderRules(YarpAppConfig config) + internal static CompiledHeaderRule[] CompileHeaderRules(YarpAppConfig config) => config.Headers.Select(rule => new CompiledHeaderRule(rule)).ToArray(); - private static void ApplyHeaders(HttpContext context, PathString requestPath, IHeaderDictionary headers, CompiledHeaderRule[] headerRules) + internal static void ApplyHeaders(HttpContext context, PathString requestPath, IHeaderDictionary headers, CompiledHeaderRule[] headerRules) { foreach (var headerRule in headerRules) { @@ -68,7 +72,14 @@ private static void ApplyHeaders(HttpContext context, PathString requestPath, IH } } - private sealed class CompiledHeaderRule + private static bool IsProxyEndpoint(HttpContext context) + => context.GetEndpoint()?.Metadata.GetMetadata() is not null; + + private static bool IsSupersededByStatusCodeReExecute(HttpContext context, PathString registeredPath) + => context.Features.Get() is not null + && !string.Equals(context.Request.Path.Value, registeredPath.Value, StringComparison.Ordinal); + + internal sealed class CompiledHeaderRule { private readonly RequestMatchEvaluator _matcher; private readonly KeyValuePair[] _headers; diff --git a/src/Application/Features/StaticFilesFeature.cs b/src/Application/Features/StaticFilesFeature.cs index df8292f08..4b1c48608 100644 --- a/src/Application/Features/StaticFilesFeature.cs +++ b/src/Application/Features/StaticFilesFeature.cs @@ -36,7 +36,7 @@ public static WebApplication UseStaticFiles(this WebApplication app, YarpAppConf return next(); }); - var onPrepareResponse = StaticHostHeadersFeature.CreateStaticFileHeaderCallback(config); + var onPrepareResponse = ResponseHeadersFeature.CreateStaticFileHeaderCallback(config); if (onPrepareResponse is null) { app.UseFileServer(); diff --git a/src/Application/Program.cs b/src/Application/Program.cs index 3d3d23d18..dc564e197 100644 --- a/src/Application/Program.cs +++ b/src/Application/Program.cs @@ -71,7 +71,7 @@ app.UseErrorPages(config); app.UseRouting(); app.UseStaticFiles(config); -app.UseStaticHostHeaders(config); +app.UseResponseHeaders(config); app.MapRedirects(config); app.MapReverseProxy(); app.MapNavigationFallbackExclusions(config); diff --git a/src/Application/README.md b/src/Application/README.md index f09a567d5..97b1335d2 100644 --- a/src/Application/README.md +++ b/src/Application/README.md @@ -91,7 +91,7 @@ Every request flows through the pipeline below in this fixed order. Knowing the ├─────────────────────────────────────────┤ │ 5. Static files (special) │ If a file at Request.Path exists in wwwroot, serve it. ├─────────────────────────────────────────┤ -│ 6. Headers (response phase) │ Apply Header rules to static-file & SPA-fallback responses. +│ 6. Headers (response phase) │ Apply Header rules to non-proxy app responses. ├─────────────────────────────────────────┤ │ 7. Reverse proxy (endpoint) │ YARP routes that matched in step 3 run here. ├─────────────────────────────────────────┤ @@ -106,7 +106,7 @@ A few consequences worth internalizing: - **Rewrites run first**, so every later stage sees the rewritten path. Use them to canonicalize URLs before anything else makes a decision. - **Redirects beat static files and the proxy.** If a redirect rule matches, the response is a 30x — the file or upstream is never consulted. - **Static files beat fallback exclusions and the SPA fallback.** A real file in `wwwroot` always wins over routed fallback endpoints, even though those fallbacks were chosen by `UseRouting` first. (This is preserved by clearing/restoring the selected endpoint around `UseFileServer`.) -- **`Headers` only apply to static-file and SPA-fallback responses** — not to redirects, not to proxy responses. Use YARP response transforms for proxy headers. +- **`Headers` apply to app-generated responses** — static files, redirects, fallback exclusions, SPA fallback, and custom error pages. They do not apply to proxy responses; use YARP response transforms for proxy headers. - **Fallback exclusions and the SPA fallback are real routed endpoints**, so reverse-proxy routes (and any `MapGet`/`MapPost` registered earlier) can still claim a path before either fires. ### Match syntax @@ -160,7 +160,7 @@ SPA fallback — serve a file (typically `index.html`) for unmatched routes so c ### `Headers` -Response header rules for static-file and SPA-fallback responses. All matching rules are applied. +Response header rules for app-generated responses. All matching rules are applied. Proxy responses are intentionally excluded; use YARP response transforms for proxy headers. ```json { @@ -312,7 +312,7 @@ Features/ Per-feature extension methods RedirectsFeature.cs RewritesFeature.cs ErrorPagesFeature.cs - StaticHostHeadersFeature.cs + ResponseHeadersFeature.cs ReverseProxyFeature.cs LoggingFeature.cs Program.cs Pipeline ordering diff --git a/src/Application/yarp-config.schema.json b/src/Application/yarp-config.schema.json index b457c4032..39e4fc763 100644 --- a/src/Application/yarp-config.schema.json +++ b/src/Application/yarp-config.schema.json @@ -99,7 +99,7 @@ }, "Headers": { "type": "array", - "description": "Response header rules for static-host responses only. All matching rules are applied.", + "description": "Response header rules for app-generated responses. Proxy responses are excluded; use YARP response transforms for proxy headers. All matching rules are applied.", "items": { "type": "object", "properties": { diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs index b2d569939..7e97cc86f 100644 --- a/test/Application.Tests/SpaFallbackTests.cs +++ b/test/Application.Tests/SpaFallbackTests.cs @@ -604,6 +604,65 @@ public async Task HeaderRules_DoNotApplyToProxiedResponses() Assert.False(response.Headers.Contains("X-Test")); } + [Fact] + public async Task ObjectModel_HeaderRules_ApplyToRedirectResponses() + { + using var app = CreateApp(new YarpAppConfig + { + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/old" }, + Set = { ["X-App-Header"] = "redirect" } + } + }, + Redirects = + { + new RedirectRule + { + Match = new RequestMatch { Path = "/old" }, + Destination = "/new", + StatusCode = 302 + } + } + }); + using var client = CreateNoRedirectClient(app); + + var response = await client.GetAsync("/old"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("redirect", response.Headers.GetValues("X-App-Header").Single()); + } + + [Fact] + public async Task ObjectModel_HeaderRules_ApplyToFallbackExclusionResponses() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + NavigationFallback = + { + Path = "/index.html", + Exclude = { new RequestMatch { Path = "/api/{**catch-all}" } } + }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/api/{**catch-all}" }, + Set = { ["X-App-Header"] = "excluded" } + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/api/users"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("excluded", response.Headers.GetValues("X-App-Header").Single()); + } + [Fact] public async Task ObjectModel_Rewrites_AffectStaticHeaderMatching() { @@ -1033,7 +1092,7 @@ public async Task ObjectModel_ErrorPages_StaticFilePageReceivesHeaderRules() }); using var client = app.CreateClient(); - // ErrorPages re-executes /404.html through static files, so static-host header rules + // ErrorPages re-executes /404.html through static files, so app header rules // still apply to the custom error page body while the original 404 status is preserved. var response = await client.GetAsync("/missing"); @@ -1043,6 +1102,35 @@ public async Task ObjectModel_ErrorPages_StaticFilePageReceivesHeaderRules() Assert.Contains("Custom 404", content); } + [Fact] + public async Task ObjectModel_ErrorPages_HeadersUseReExecutedPath() + { + using var app = CreateApp(new YarpAppConfig + { + StaticFiles = { Enabled = true }, + ErrorPages = { ["404"] = "/404.html" }, + Headers = + { + new HeaderRule + { + Match = new RequestMatch { Path = "/missing" }, + Set = { ["X-Error-Page"] = "original" } + }, + new HeaderRule + { + Match = new RequestMatch { Path = "/404.html" }, + Set = { ["X-Error-Page"] = "reexecuted" } + } + } + }); + using var client = app.CreateClient(); + + var response = await client.GetAsync("/missing"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("reexecuted", response.Headers.GetValues("X-Error-Page").Single()); + } + [Fact] public async Task ObjectModel_ErrorPages_ClassWildcard_MatchesAllCodesInClass() {