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..b44cbcf5d
--- /dev/null
+++ b/samples/YarpApplication.SampleApps/README.md
@@ -0,0 +1,25 @@
+# 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. |
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..4f25a8e62
--- /dev/null
+++ b/src/Application/Configuration/RequestMatch.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.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/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 c55e0439f..c386aa995 100644
--- a/src/Application/Configuration/YarpAppConfig.cs
+++ b/src/Application/Configuration/YarpAppConfig.cs
@@ -11,5 +11,9 @@ 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 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 5ecffec4e..ce3385a70 100644
--- a/src/Application/Configuration/YarpAppConfigBinder.cs
+++ b/src/Application/Configuration/YarpAppConfigBinder.cs
@@ -17,6 +17,10 @@ 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.Rewrites)).Bind(config.Rewrites);
+ configuration.GetSection(nameof(config.ErrorPages)).Bind(config.ErrorPages);
configuration.GetSection(nameof(config.Telemetry)).Bind(config.Telemetry);
// Legacy env var support
@@ -42,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/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/ErrorPagesFeature.cs b/src/Application/Features/ErrorPagesFeature.cs
new file mode 100644
index 000000000..5732f3fec
--- /dev/null
+++ b/src/Application/Features/ErrorPagesFeature.cs
@@ -0,0 +1,239 @@
+// 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;
+ }
+
+ // 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;
+ var originalEndpoint = http.GetEndpoint();
+ var routeValuesFeature = http.Features.Get();
+ var originalRouteValues = routeValuesFeature?.RouteValues is { } routeValues
+ ? 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!,
+ OriginalPath = originalPath.Value!,
+ OriginalQueryString = originalQueryString.HasValue ? originalQueryString.Value : null,
+ OriginalStatusCode = originalStatusCode,
+ Endpoint = originalEndpoint,
+ RouteValues = originalRouteValues,
+ });
+
+ // 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;
+ response.StatusCode = statusCode;
+ return Task.CompletedTask;
+ }, (http.Response, originalStatusCode));
+
+ http.Request.Path = path;
+ http.Request.QueryString = QueryString.Empty;
+ try
+ {
+ await context.Next(http);
+ }
+ 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);
+ if (routeValuesFeature is not null)
+ {
+ routeValuesFeature.RouteValues = originalRouteValues ?? new RouteValueDictionary();
+ }
+
+ http.Features.Set(null);
+ }
+ });
+
+ 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;
+ 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)
+ {
+ 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] = path;
+ }
+ else if (TryParseClassWildcard(key, out var hundreds))
+ {
+ classes[hundreds] = path;
+ }
+ 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 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;
+ // Parse exact status-code keys like "404" into the integer 404.
+ 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;
+ // Parse status-class wildcard keys like "5xx" or "5XX" into the hundreds digit 5.
+ 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/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..dd6bd09ed
--- /dev/null
+++ b/src/Application/Features/NavigationFallbackExclusionsFeature.cs
@@ -0,0 +1,48 @@
+// 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
+{
+ // 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)
+ {
+ if (config.NavigationFallback.Path is null || config.NavigationFallback.Exclude.Count == 0)
+ {
+ return app;
+ }
+
+ for (var i = 0; i < config.NavigationFallback.Exclude.Count; i++)
+ {
+ var match = config.NavigationFallback.Exclude[i];
+ var path = RequestMatchEvaluator.GetPathPattern(match, "NavigationFallback exclusion");
+ var order = FallbackExclusionEndpointOrderBase + i;
+ app.Map(
+ path,
+ context =>
+ {
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ return Task.CompletedTask;
+ })
+ .Add(endpointBuilder =>
+ {
+ endpointBuilder.DisplayName = $"Fallback exclusion {path}";
+ ((RouteEndpointBuilder)endpointBuilder).Order = order;
+ RequestMatchEvaluator.AddEndpointMetadata(endpointBuilder, match, "NavigationFallback exclusion");
+ });
+ }
+
+ 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..a3ae98c06
--- /dev/null
+++ b/src/Application/Features/RedirectsFeature.cs
@@ -0,0 +1,89 @@
+// 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
+{
+ // 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)
+ {
+ return app;
+ }
+
+ var headerRules = ResponseHeadersFeature.CompileHeaderRules(config);
+ for (var i = 0; i < config.Redirects.Count; i++)
+ {
+ var rule = new CompiledRedirectRule(config.Redirects[i]);
+ var order = RedirectEndpointOrderBase + i;
+ app.Map(
+ rule.Path,
+ context =>
+ {
+ 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
+ // endpoint routing instead of waiting for the normal endpoint middleware.
+ .ShortCircuit()
+ .Add(endpointBuilder =>
+ {
+ endpointBuilder.DisplayName = $"Redirect {rule.Path}";
+ ((RouteEndpointBuilder)endpointBuilder).Order = order;
+ RequestMatchEvaluator.AddEndpointMetadata(endpointBuilder, rule.Match, "Redirect rules");
+ });
+ }
+
+ return app;
+ }
+
+ private sealed class CompiledRedirectRule
+ {
+ private static readonly HashSet AllowedStatusCodes = [301, 302, 307, 308];
+
+ public CompiledRedirectRule(RedirectRule rule)
+ {
+ Match = rule.Match ?? throw new InvalidOperationException("Redirect rules requires a Match object.");
+ Path = RequestMatchEvaluator.GetPathPattern(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 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
new file mode 100644
index 000000000..88b77b074
--- /dev/null
+++ b/src/Application/Features/RequestMatchEvaluator.cs
@@ -0,0 +1,186 @@
+// 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 = 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; }
+
+ ///
+ /// 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 GetPathPattern(RequestMatch match, string ruleDisplayName)
+ {
+ if (match is null)
+ {
+ throw new InvalidOperationException($"{ruleDisplayName} requires a Match object.");
+ }
+
+ if (string.IsNullOrWhiteSpace(match.Path))
+ {
+ 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, context.Request.Path, 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)
+ && MatchHost(context.Request.Host)
+ && MatchMethod(context.Request.Method)
+ && MatchQuery(context.Request.Query);
+ }
+
+ ///
+ /// Substitutes {name} placeholders in with values from
+ /// . 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)
+ {
+ 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();
+ }
+
+ 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/ResponseHeadersFeature.cs b/src/Application/Features/ResponseHeadersFeature.cs
new file mode 100644
index 000000000..bc226656d
--- /dev/null
+++ b/src/Application/Features/ResponseHeadersFeature.cs
@@ -0,0 +1,131 @@
+// 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.StaticFiles;
+using Yarp.Application.Configuration;
+using Yarp.ReverseProxy.Model;
+
+namespace Yarp.Application.Features;
+
+public static class ResponseHeadersFeature
+{
+ public static WebApplication UseResponseHeaders(this WebApplication app, YarpAppConfig config)
+ {
+ var headerRules = CompileHeaderRules(config);
+ if (headerRules.Length == 0)
+ {
+ return app;
+ }
+
+ app.Use((context, next) =>
+ {
+ if (IsProxyEndpoint(context))
+ {
+ return next();
+ }
+
+ var requestPath = context.Request.Path;
+
+ context.Response.OnStarting(
+ static state =>
+ {
+ var (httpContext, originalPath, rules) = ((HttpContext, PathString, CompiledHeaderRule[]))state;
+ if (!IsSupersededByStatusCodeReExecute(httpContext, originalPath))
+ {
+ ApplyHeaders(httpContext, 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 aligned with routed app-generated responses.
+ return context => ApplyHeaders(context.Context, context.Context.Request.Path, context.Context.Response.Headers, headerRules);
+ }
+
+ internal static CompiledHeaderRule[] CompileHeaderRules(YarpAppConfig config)
+ => config.Headers.Select(rule => new CompiledHeaderRule(rule)).ToArray();
+
+ internal static void ApplyHeaders(HttpContext context, PathString requestPath, IHeaderDictionary headers, CompiledHeaderRule[] headerRules)
+ {
+ foreach (var headerRule in headerRules)
+ {
+ headerRule.Apply(context, requestPath, headers);
+ }
+ }
+
+ 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;
+
+ 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(HttpContext context, PathString requestPath, IHeaderDictionary headers)
+ {
+ if (!_matcher.TryMatch(context, 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/Features/RewritesFeature.cs b/src/Application/Features/RewritesFeature.cs
new file mode 100644
index 000000000..b94f6a4f7
--- /dev/null
+++ b/src/Application/Features/RewritesFeature.cs
@@ -0,0 +1,58 @@
+// 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.");
+ }
+
+ 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)
+ {
+ throw new InvalidOperationException(
+ $"Rewrite rule at index {i} has an invalid Regex pattern '{rule.Regex}': {ex.Message}", ex);
+ }
+ }
+
+ app.UseRewriter(options);
+ return app;
+ }
+}
diff --git a/src/Application/Features/StaticFilesFeature.cs b/src/Application/Features/StaticFilesFeature.cs
index 32dca2a31..4b1c48608 100644
--- a/src/Application/Features/StaticFilesFeature.cs
+++ b/src/Application/Features/StaticFilesFeature.cs
@@ -2,18 +2,76 @@
// 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)
+ {
+ context.Items.Remove(PreservedEndpointKey);
+ 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 = ResponseHeadersFeature.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.Items.TryGetValue(PreservedEndpointKey, out var endpoint))
+ {
+ // 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();
+ });
return app;
}
diff --git a/src/Application/Program.cs b/src/Application/Program.cs
index ead454944..dc564e197 100644
--- a/src/Application/Program.cs
+++ b/src/Application/Program.cs
@@ -65,9 +65,16 @@
LoggingFeature.PrintBanner(config, configFilePath, app);
// Middleware pipeline — order matters
-app.UseStaticFiles(config);
+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);
+app.UseResponseHeaders(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..97b1335d2 100644
--- a/src/Application/README.md
+++ b/src/Application/README.md
@@ -75,6 +75,65 @@ 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.
+### 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. Error pages (wraps everything)│ Re-execute against a configured page on 4xx/5xx.
+├─────────────────────────────────────────┤
+│ 3. Routing match (endpoint chosen) │ ASP.NET selects an endpoint, but doesn't run it yet.
+├─────────────────────────────────────────┤
+│ 4. Redirects (short-circuit) │ If a redirect endpoint matched, send 30x and stop.
+├─────────────────────────────────────────┤
+│ 5. Static files (special) │ If a file at Request.Path exists in wwwroot, serve it.
+├─────────────────────────────────────────┤
+│ 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.
+├─────────────────────────────────────────┤
+│ 8. Fallback exclude (endpoint) │ Listed paths return 404 instead of falling back.
+├─────────────────────────────────────────┤
+│ 9. 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` 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
+
+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.
+
### `StaticFiles`
Serve static files from `wwwroot/`.
@@ -88,9 +147,116 @@ 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 app-generated responses. All matching rules are applied. Proxy responses are intentionally excluded; use YARP response transforms for proxy headers.
+
+```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}`.
+
+### `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.
+
+### `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"`). Values must be rooted request paths that start with `/`. **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.
@@ -131,12 +297,22 @@ 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
+ RewriteRule.cs
TelemetryOptions.cs
Features/ Per-feature extension methods
+ RequestMatchEvaluator.cs Route-template-based match evaluation
StaticFilesFeature.cs
NavigationFallbackFeature.cs
+ NavigationFallbackExclusionsFeature.cs
+ RedirectsFeature.cs
+ RewritesFeature.cs
+ ErrorPagesFeature.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 35a561486..39e4fc763 100644
--- a/src/Application/yarp-config.schema.json
+++ b/src/Application/yarp-config.schema.json
@@ -3,6 +3,69 @@
"title": "YARP Container Configuration",
"description": "Configuration schema for the YARP container application",
"type": "object",
+ "definitions": {
+ "requestMatch": {
+ "type": "object",
+ "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": ["Name"],
+ "additionalProperties": false
+ }
+ },
"properties": {
"StaticFiles": {
"type": "object",
@@ -23,6 +86,99 @@
"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 app-generated responses. Proxy responses are excluded; use YARP response transforms for proxy headers. 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
+ }
+ },
+ "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
+ }
+ },
+ "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",
+ "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 9d432a813..7e97cc86f 100644
--- a/test/Application.Tests/SpaFallbackTests.cs
+++ b/test/Application.Tests/SpaFallbackTests.cs
@@ -3,11 +3,19 @@
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.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Yarp.Application.Configuration;
+using Yarp.Application.Features;
+using Yarp.ReverseProxy.Configuration;
namespace Yarp.Application.Tests;
@@ -75,6 +83,34 @@ private static YarpTestApp CreateApp(Dictionary? config = null)
["YARP_ENABLE_STATIC_FILES"] = "true"
};
+ private static HttpClient CreateNoRedirectClient(WebApplicationFactory factory)
+ => factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ 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()
{
@@ -234,6 +270,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 +342,509 @@ 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_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();
+
+ // 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);
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.DoesNotContain("SPA Index", content);
+ }
+
+ [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_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()
+ {
+ 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_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()
+ {
+ 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_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()
+ {
+ 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()
+ {
+ 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_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()
+ {
+ 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_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()
{
@@ -299,4 +857,446 @@ 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);
+ }
+
+ [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);
+ }
+
+ [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_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 app 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_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()
+ {
+ 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_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_MissingPageDoesNotRestoreOriginalEndpointDuringReExecute()
+ {
+ var webRoot = Directory.CreateTempSubdirectory();
+ try
+ {
+ 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);
+ Assert.Equal(1, app.Services.GetRequiredService().Count);
+ }
+ finally
+ {
+ webRoot.Delete(recursive: true);
+ }
+ }
+
+ [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);
+ }
+
+ [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
+ {
+ 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; }
+ }
}
diff --git a/test/Application.Tests/YarpAppConfigBinderTests.cs b/test/Application.Tests/YarpAppConfigBinderTests.cs
index c798fa801..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;
@@ -33,6 +34,99 @@ 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_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()
{
@@ -46,6 +140,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);
}
@@ -65,6 +162,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()
{
@@ -87,15 +198,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);
}
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