Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Microsoft.Identity.Web/AuthorizeForScopesAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ public override void OnException(ExceptionContext context)
: httpRequest.Query[Constants.XReturnUrl];

UrlHelper urlHelper = new UrlHelper(context);
if (urlHelper.IsLocalUrl(redirectUri))
if (urlHelper.IsLocalUrl(redirectUri)
Comment thread
cpp11nullptr marked this conversation as resolved.
&& !RedirectUriHelper.HasPercentEncodedSlashPrefix(redirectUri!))
{
properties.RedirectUri = redirectUri;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,22 +154,6 @@ private static void WarnIfAntiforgeryMissing(IServiceProvider? serviceProvider)
internal static AuthenticationProperties GetAuthProperties(string? returnUrl)
{
const string pathBase = "/";
return new AuthenticationProperties { RedirectUri = IsLocalUrl(returnUrl) ? returnUrl! : pathBase };
}

private static bool IsLocalUrl(string? url)
{
if (string.IsNullOrEmpty(url))
{
return false;
}

// "/foo" is local, but not "//foo" (protocol-relative) and not "/\foo" (slash-backslash).
if (url[0] == '/')
{
return url.Length == 1 || (url[1] != '/' && url[1] != '\\');
}

return false;
return new AuthenticationProperties { RedirectUri = RedirectUriHelper.IsLocalUrl(returnUrl) ? returnUrl! : pathBase };
}
}
47 changes: 47 additions & 0 deletions src/Microsoft.Identity.Web/Internal/RedirectUriHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;

namespace Microsoft.Identity.Web;

/// <summary>
/// Shared redirect-URI sanitization helpers for consistent local-URL validation
/// across login/logout endpoints and authorization attributes.
/// </summary>
internal static class RedirectUriHelper
{
/// <summary>
/// Returns <c>true</c> when <paramref name="url"/> is a strictly local path
/// (starts with a single "/" that is not followed by another "/" or "\")
/// and does not begin with a percent-encoded slash or backslash sequence.
/// </summary>
internal static bool IsLocalUrl(string? url)
{
if (string.IsNullOrEmpty(url))
{
return false;
}

if (HasPercentEncodedSlashPrefix(url!))
{
return false;
}

// "/foo" is local, but not "//foo" (protocol-relative) and not "/\foo" (slash-backslash).
if (url![0] == '/')
{
return url.Length == 1 || (url[1] != '/' && url[1] != '\\');
}

return false;
}

/// <summary>
/// Returns <c>true</c> when <paramref name="path"/> starts with a percent-encoded
/// forward slash (<c>%2f</c>) or backslash (<c>%5c</c>).
/// </summary>
internal static bool HasPercentEncodedSlashPrefix(string path) =>
path.StartsWith("/%2f", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/%5c", StringComparison.OrdinalIgnoreCase);
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ public class LoginLogoutEndpointRouteBuilderExtensionsTests
// Bare hostnames / non-slash-prefixed — blocked.
[InlineData("evil.example", "/")]
[InlineData("home", "/")]
// Percent-encoded slash/backslash — blocked (reverse proxies may decode these).
Comment thread
cpp11nullptr marked this conversation as resolved.
[InlineData("/%2Fsome.example", "/")]
[InlineData("/%2fsome.example", "/")]
[InlineData("/%5Csome.example", "/")]
[InlineData("/%5csome.example", "/")]
[InlineData("/%2f%2fsome.example/x", "/")]
[InlineData("/%2F%5Csome.example", "/")]
public void GetAuthProperties_CoercesNonLocalReturnUrls(string? input, string expected)
{
var props = LoginLogoutEndpointRouteBuilderExtensions.GetAuthProperties(input);
Expand Down
59 changes: 59 additions & 0 deletions tests/Microsoft.Identity.Web.Test/RedirectUriHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Xunit;

namespace Microsoft.Identity.Web.Test
{
public class RedirectUriHelperTests
{
[Theory]
// Local paths — accepted.
[InlineData("/", true)]
[InlineData("/home", true)]
[InlineData("/home?query=1", true)]
[InlineData("/a/b/c", true)]
// Null/empty — rejected.
[InlineData(null, false)]
[InlineData("", false)]
// Protocol-relative — rejected.
[InlineData("//some.example", false)]
[InlineData("//some.example/path", false)]
// Slash-backslash — rejected.
[InlineData("/\\some.example", false)]
[InlineData("/\\\\some.example", false)]
// Absolute URLs — rejected.
[InlineData("https://some.example/", false)]
[InlineData("http://some.example/", false)]
[InlineData("javascript:alert(1)", false)]
// Bare hostnames / non-slash-prefixed — rejected.
[InlineData("some.example", false)]
[InlineData("home", false)]
// Percent-encoded slash/backslash — rejected (reverse proxies may decode these).
[InlineData("/%2Fsome.example", false)]
[InlineData("/%2fsome.example", false)]
[InlineData("/%5Csome.example", false)]
[InlineData("/%5csome.example", false)]
[InlineData("/%2f%2fsome.example/x", false)]
[InlineData("/%2F%5Csome.example", false)]
public void IsLocalUrl_ValidatesCorrectly(string? input, bool expected)
{
Assert.Equal(expected, RedirectUriHelper.IsLocalUrl(input));
}

[Theory]
[InlineData("/%2Fsome.example", true)]
[InlineData("/%2fsome.example", true)]
[InlineData("/%5Csome.example", true)]
[InlineData("/%5csome.example", true)]
[InlineData("/%2f%2fsome.example/x", true)]
[InlineData("/%2F%5Csome.example", true)]
[InlineData("/home", false)]
[InlineData("/a/b/c", false)]
[InlineData("/", false)]
public void HasPercentEncodedSlashPrefix_DetectsEncodedSlashes(string input, bool expected)
{
Assert.Equal(expected, RedirectUriHelper.HasPercentEncodedSlashPrefix(input));
}
}
}
Loading