Skip to content

Commit fa44f13

Browse files
committed
Collapse scheme-relative leading slashes in Rewrite middleware redirect/rewrite targets
`AddRedirect`, IIS URL Rewrite, and Apache mod_rewrite rules let an attacker-controlled or misconfigured back-reference produce a target starting with `//`, `///`, or `/\` (and longer runs). When `PathBase` is empty, the resulting `Location` header is scheme-relative and resolves off-origin — an open redirect. This change adds a small internal helper `UrlNormalizer.CollapseLeadingSlashes` that mirrors the rejection predicate in `SharedUrlHelper.IsLocalUrl` but coerces instead of returning a boolean: any leading run of `/` and `\` is collapsed to a single `/`. The helper is applied at the three sinks that emit `Location` or mutate `Request.Path`: * `RedirectRule.ApplyRule` (`AddRedirect` surface) * `UrlActions.RedirectAction.ApplyAction` (IIS / Apache redirect import) * `UrlActions.RewriteAction.ApplyAction` (defense in depth for `Request.Path`) Adds regression theories on all three surfaces covering `//`, `///`, `//////`, `/\` shapes, both as literal replacements and as back-reference-synthesized captures. No public API surface change; `PublicAPI.Shipped.txt` / `PublicAPI.Unshipped.txt` unchanged.
1 parent d494c24 commit fa44f13

7 files changed

Lines changed: 151 additions & 1 deletion

File tree

src/Middleware/Rewrite/src/RedirectRule.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public void ApplyRule(RewriteContext context)
4343
if (initMatchResults.Success)
4444
{
4545
var newPath = initMatchResults.Result(Replacement);
46+
newPath = UrlNormalizer.CollapseLeadingSlashes(newPath);
4647
var response = context.HttpContext.Response;
4748

4849
response.StatusCode = StatusCode;

src/Middleware/Rewrite/src/UrlActions/RedirectAction.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public override void ApplyAction(RewriteContext context, BackReferenceCollection
4343
return;
4444
}
4545

46+
pattern = UrlNormalizer.CollapseLeadingSlashes(pattern);
47+
4648
if (!pattern.Contains(Uri.SchemeDelimiter, StringComparison.Ordinal) && pattern[0] != '/')
4749
{
4850
pattern = '/' + pattern;
@@ -51,7 +53,6 @@ public override void ApplyAction(RewriteContext context, BackReferenceCollection
5153

5254
// url can either contain the full url or the path and query
5355
// always add to location header.
54-
// TODO check for false positives
5556

5657
var split = pattern.IndexOf('?');
5758
if (split >= 0 && QueryStringAppend)

src/Middleware/Rewrite/src/UrlActions/RewriteAction.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public override void ApplyAction(RewriteContext context, BackReferenceCollection
5858
pattern = Uri.EscapeDataString(pattern);
5959
}
6060

61+
pattern = UrlNormalizer.CollapseLeadingSlashes(pattern);
62+
6163
// TODO PERF, substrings, object creation, etc.
6264
if (pattern.Contains(Uri.SchemeDelimiter, StringComparison.Ordinal))
6365
{
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Rewrite;
5+
6+
internal static class UrlNormalizer
7+
{
8+
// Collapses a leading run of '/' and '\' to a single '/' so a redirect/rewrite target cannot resolve as a scheme-relative authority. Mirrors the rejection predicate in SharedUrlHelper.IsLocalUrl.
9+
public static string CollapseLeadingSlashes(string url)
10+
{
11+
if (url.Length < 2 || url[0] != '/' || (url[1] != '/' && url[1] != '\\'))
12+
{
13+
return url;
14+
}
15+
16+
var i = 1;
17+
while (i < url.Length && (url[i] == '/' || url[i] == '\\'))
18+
{
19+
i++;
20+
}
21+
22+
return i == url.Length ? "/" : string.Concat("/", url.AsSpan(i));
23+
}
24+
}

src/Middleware/Rewrite/test/ApacheModRewrite/ModRewriteMiddlewareTest.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,62 @@ public async Task Invoke_RewritePathWhenMatching()
3737
Assert.Equal("/hello", response);
3838
}
3939

40+
[Theory]
41+
[InlineData("/legacy//example.com")]
42+
[InlineData("/legacy///example.com")]
43+
[InlineData(@"/legacy/\example.com")]
44+
public async Task Invoke_RedirectCollapsesSchemeRelativeBackReference(string requestPath)
45+
{
46+
var options = new RewriteOptions().AddApacheModRewrite(new StringReader("RewriteRule /legacy/(.*) /$1 [R=302,L]"));
47+
using var host = new HostBuilder()
48+
.ConfigureWebHost(webHostBuilder =>
49+
{
50+
webHostBuilder
51+
.UseTestServer()
52+
.Configure(app =>
53+
{
54+
app.UseRewriter(options);
55+
app.Run(context => context.Response.WriteAsync(context.Response.Headers.Location));
56+
});
57+
}).Build();
58+
59+
await host.StartAsync();
60+
61+
var server = host.GetTestServer();
62+
63+
var response = await server.CreateClient().GetAsync(requestPath);
64+
65+
Assert.Equal("/example.com", response.Headers.Location.OriginalString);
66+
}
67+
68+
[Theory]
69+
[InlineData("/legacy//example.com")]
70+
[InlineData("/legacy///example.com")]
71+
[InlineData(@"/legacy/\example.com")]
72+
public async Task Invoke_RewriteCollapsesSchemeRelativeBackReference(string requestPath)
73+
{
74+
var options = new RewriteOptions().AddApacheModRewrite(new StringReader("RewriteRule /legacy/(.*) /$1"));
75+
using var host = new HostBuilder()
76+
.ConfigureWebHost(webHostBuilder =>
77+
{
78+
webHostBuilder
79+
.UseTestServer()
80+
.Configure(app =>
81+
{
82+
app.UseRewriter(options);
83+
app.Run(context => context.Response.WriteAsync(context.Request.Path));
84+
});
85+
}).Build();
86+
87+
await host.StartAsync();
88+
89+
var server = host.GetTestServer();
90+
91+
var response = await server.CreateClient().GetStringAsync(requestPath);
92+
93+
Assert.Equal("/example.com", response);
94+
}
95+
4096
[Fact]
4197
public async Task Invoke_RewritePathTerminatesOnFirstSuccessOfRule()
4298
{

src/Middleware/Rewrite/test/IISUrlRewrite/MiddleWareTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,41 @@ public async Task Invoke_RedirectPathToPathAndQuery()
4747
Assert.Equal("/article.aspx?id=10&title=hey", response.Headers.Location.OriginalString);
4848
}
4949

50+
[Theory]
51+
[InlineData("legacy//example.com")]
52+
[InlineData("legacy///example.com")]
53+
[InlineData(@"legacy/\example.com")]
54+
public async Task Invoke_RedirectCollapsesSchemeRelativeBackReference(string requestPath)
55+
{
56+
var options = new RewriteOptions().AddIISUrlRewrite(new StringReader(@"<rewrite>
57+
<rules>
58+
<rule name=""Collapse"">
59+
<match url=""^legacy/(.*)"" />
60+
<action type=""Redirect"" url=""/{R:1}"" redirectType=""Found"" />
61+
</rule>
62+
</rules>
63+
</rewrite>"));
64+
using var host = new HostBuilder()
65+
.ConfigureWebHost(webHostBuilder =>
66+
{
67+
webHostBuilder
68+
.UseTestServer()
69+
.Configure(app =>
70+
{
71+
app.UseRewriter(options);
72+
app.Run(context => context.Response.WriteAsync(context.Response.Headers.Location));
73+
});
74+
}).Build();
75+
76+
await host.StartAsync();
77+
78+
var server = host.GetTestServer();
79+
80+
var response = await server.CreateClient().GetAsync(requestPath);
81+
82+
Assert.Equal("/example.com", response.Headers.Location.OriginalString);
83+
}
84+
5085
[Fact]
5186
public async Task Invoke_RewritePathToPathAndQuery()
5287
{

src/Middleware/Rewrite/test/MiddlewareTests.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,37 @@ public async Task CheckRedirectPath(string pattern, string replacement, string b
174174
Assert.Equal(expectedUrl, response.Headers.Location.OriginalString);
175175
}
176176

177+
[Theory]
178+
[InlineData("(.*)", "//example.com", "anything")]
179+
[InlineData("(.*)", "///example.com", "anything")]
180+
[InlineData("(.*)", @"/\example.com", "anything")]
181+
[InlineData("(.*)", "//////example.com", "anything")]
182+
[InlineData("legacy/(.*)", "/$1", "legacy//example.com")]
183+
[InlineData("legacy/(.*)", "/$1", "legacy///example.com")]
184+
[InlineData("legacy/(.*)", "/$1", @"legacy/\example.com")]
185+
public async Task CheckRedirect_CollapsesSchemeRelativeTarget(string pattern, string replacement, string requestUrl)
186+
{
187+
var options = new RewriteOptions().AddRedirect(pattern, replacement, statusCode: StatusCodes.Status302Found);
188+
using var host = new HostBuilder()
189+
.ConfigureWebHost(webHostBuilder =>
190+
{
191+
webHostBuilder
192+
.UseTestServer()
193+
.Configure(app =>
194+
{
195+
app.UseRewriter(options);
196+
});
197+
}).Build();
198+
199+
await host.StartAsync();
200+
201+
var server = host.GetTestServer();
202+
203+
var response = await server.CreateClient().GetAsync(requestUrl);
204+
205+
Assert.Equal("/example.com", response.Headers.Location.OriginalString);
206+
}
207+
177208
[Fact]
178209
public async Task RewriteRulesCanComeFromConfigureOptions()
179210
{

0 commit comments

Comments
 (0)