diff --git a/.gitignore b/.gitignore index 4f477152a..8267acd64 100644 --- a/.gitignore +++ b/.gitignore @@ -351,4 +351,5 @@ MigrationBackup/ # Intell-J Rider etc. .idea/* -.vscode/* \ No newline at end of file +.vscode/* +**/.DS_Store diff --git a/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs b/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs index af7e4172b..0e8b8084a 100644 --- a/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs +++ b/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Dynamic; +using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -240,8 +241,7 @@ public async Task GetTokenAsync(ResourceOwnerTokenRequest r body.AddIfNotEmpty("realm", request.Realm); body.Add("grant_type", String.IsNullOrEmpty(request.Realm) ? "password" : "http://auth0.com/oauth/grant-type/password-realm"); - var headers = String.IsNullOrEmpty(request.ForwardedForIp) ? null - : new Dictionary { { "auth0-forwarded-for", request.ForwardedForIp } }; + var headers = BuildForwardedForHeaders(request.ForwardedForIp); var response = await connection.SendAsync( HttpMethod.Post, @@ -300,8 +300,7 @@ public async Task GetTokenAsync(PasswordlessSmsTokenRequest ApplyClientAuthentication(request, body); - var headers = String.IsNullOrEmpty(request.ForwardedForIp) ? null - : new Dictionary { { "auth0-forwarded-for", request.ForwardedForIp } }; + var headers = BuildForwardedForHeaders(request.ForwardedForIp); var response = await connection.SendAsync( HttpMethod.Post, @@ -421,8 +420,7 @@ public Task StartPasswordlessSmsFlowAsync(PasswordlessS phone_number = request.PhoneNumber }; - var headers = String.IsNullOrEmpty(request.ForwardedForIp) ? null - : new Dictionary { { "auth0-forwarded-for", request.ForwardedForIp } }; + var headers = BuildForwardedForHeaders(request.ForwardedForIp); return connection.SendAsync( HttpMethod.Post, @@ -726,6 +724,19 @@ private IDictionary BuildHeaders(string accessToken) return new Dictionary { { "Authorization", "Bearer " + accessToken } }; } + internal static IDictionary? BuildForwardedForHeaders(string? forwardedForIp) + { + if (String.IsNullOrEmpty(forwardedForIp)) + return null; + + if (!IPAddress.TryParse(forwardedForIp, out _)) + throw new ArgumentException( + $"ForwardedForIp must be a valid IPv4 or IPv6 address, got: '{forwardedForIp}'.", + nameof(forwardedForIp)); + + return new Dictionary { { "auth0-forwarded-for", forwardedForIp! } }; + } + private void ApplyClientAuthentication(IClientAuthentication request, Dictionary body, bool requireSecret = false) { if (requireSecret) diff --git a/src/Auth0.Core/Extensions.cs b/src/Auth0.Core/Extensions.cs index fcd6a16e8..5d6b7f407 100644 --- a/src/Auth0.Core/Extensions.cs +++ b/src/Auth0.Core/Extensions.cs @@ -92,16 +92,44 @@ public static class Extensions if (string.IsNullOrEmpty(headerValue)) return null; - var kvp = headerValue - .Split(';') - .Select(x => x.Split('=')) - .ToDictionary(keyValue => keyValue[0], keyValue => keyValue[1]); - bucket = kvp["b"]; + var kvp = new Dictionary(); + foreach (var segment in headerValue.Split(';')) + { + var parts = segment.Split(['='], 2); + if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length == 0) + return null; + + var key = parts[0]; + var value = parts[1]; + + // Duplicate keys indicate a malformed header + if (kvp.ContainsKey(key)) + return null; + + kvp[key] = value; + } + + if (!kvp.TryGetValue("b", out var bucketValue) + || !kvp.TryGetValue("q", out var quota) + || !kvp.TryGetValue("r", out var remaining) + || !kvp.TryGetValue("t", out var resetAfter)) + { + return null; + } + + if (!int.TryParse(quota, out var quotaInt) + || !int.TryParse(remaining, out var remainingInt) + || !int.TryParse(resetAfter, out var resetAfterInt)) + { + return null; + } + + bucket = bucketValue; return new QuotaLimit { - Quota = int.Parse(kvp["q"]), - Remaining = int.Parse(kvp["r"]), - ResetAfter = int.Parse(kvp["t"]) + Quota = quotaInt, + Remaining = remainingInt, + ResetAfter = resetAfterInt }; } -} \ No newline at end of file +} diff --git a/src/Auth0.Core/Utils.cs b/src/Auth0.Core/Utils.cs index 1d251cfef..4eb4d99bc 100644 --- a/src/Auth0.Core/Utils.cs +++ b/src/Auth0.Core/Utils.cs @@ -27,9 +27,9 @@ internal static byte[] Base64UrlDecode(string input) internal static string Base64UrlEncode(byte[] input) { var output = Convert.ToBase64String(input); - output = output.Replace('-', '+'); - output = output.Replace('_', '/'); - output = output.PadRight(output.Length + (4 - output.Length % 4) % 4, '='); + output = output.Replace('+', '-'); + output = output.Replace('/', '_'); + output = output.TrimEnd('='); return output; } diff --git a/tests/Auth0.AuthenticationApi.IntegrationTests/AuthenticationApiClientTests.cs b/tests/Auth0.AuthenticationApi.IntegrationTests/AuthenticationApiClientTests.cs index 9d44d751a..891e3732a 100644 --- a/tests/Auth0.AuthenticationApi.IntegrationTests/AuthenticationApiClientTests.cs +++ b/tests/Auth0.AuthenticationApi.IntegrationTests/AuthenticationApiClientTests.cs @@ -1,4 +1,5 @@ using Auth0.Tests.Shared; +using FluentAssertions; using System; using System.Collections.Generic; using System.Net.Http; @@ -46,4 +47,60 @@ public Task SendAsync(HttpMethod method, Uri uri, object body, IDictionary return Task.FromResult(default(T)); } } + + [Fact] + public void BuildForwardedForHeaders_WithNull_ReturnsNull() + { + var result = AuthenticationApiClient.BuildForwardedForHeaders(null); + result.Should().BeNull(); + } + + [Fact] + public void BuildForwardedForHeaders_WithEmptyString_ReturnsNull() + { + var result = AuthenticationApiClient.BuildForwardedForHeaders(string.Empty); + result.Should().BeNull(); + } + + [Fact] + public void BuildForwardedForHeaders_WithValidIPv4_ReturnsHeader() + { + var result = AuthenticationApiClient.BuildForwardedForHeaders("192.168.1.1"); + result.Should().NotBeNull(); + result.Should().ContainKey("auth0-forwarded-for"); + result["auth0-forwarded-for"].Should().Be("192.168.1.1"); + } + + [Fact] + public void BuildForwardedForHeaders_WithValidIPv6_ReturnsHeader() + { + var result = AuthenticationApiClient.BuildForwardedForHeaders("2001:db8::1"); + result.Should().NotBeNull(); + result.Should().ContainKey("auth0-forwarded-for"); + result["auth0-forwarded-for"].Should().Be("2001:db8::1"); + } + + [Fact] + public void BuildForwardedForHeaders_ValidIPv4_HeaderValueMatchesInput() + { + var ip = "10.0.0.255"; + var result = AuthenticationApiClient.BuildForwardedForHeaders(ip); + result["auth0-forwarded-for"].Should().Be(ip); + } + + [Fact] + public void BuildForwardedForHeaders_WithHostname_ThrowsArgumentException() + { + Action act = () => AuthenticationApiClient.BuildForwardedForHeaders("example.com"); + act.Should().Throw() + .And.ParamName.Should().Be("forwardedForIp"); + } + + [Fact] + public void BuildForwardedForHeaders_WithArbitraryString_ThrowsArgumentException() + { + Action act = () => AuthenticationApiClient.BuildForwardedForHeaders("not-an-ip"); + act.Should().Throw() + .And.ParamName.Should().Be("forwardedForIp"); + } } \ No newline at end of file diff --git a/tests/Auth0.Core.UnitTests/ExtensionTests.cs b/tests/Auth0.Core.UnitTests/ExtensionTests.cs index f31f6b4e3..0679bd948 100644 --- a/tests/Auth0.Core.UnitTests/ExtensionTests.cs +++ b/tests/Auth0.Core.UnitTests/ExtensionTests.cs @@ -5,15 +5,17 @@ namespace Auth0.Core.UnitTests; -public class ExtensionTests +public class ExtensionTests { [Theory] [InlineData("b=per_hour;q=2;r=1;t=3452", "per_hour", 2, 1, 3452)] [InlineData("b=per_day;q=100;r=99;t=3524", "per_day", 100, 99, 3524)] - public async void ParseQuotaLimit_Parses_Successfully_For_Valid_Values( + [InlineData("b=per_hour;q=100;r=50;t=3600;x=extra", "per_hour", 100, 50, 3600)] + [InlineData("b=per=hour;q=100;r=50;t=3600", "per=hour", 100, 50, 3600)] + public void ParseQuotaLimit_Parses_Successfully_For_Valid_Values( string input, string bucket, int q, int r, int t) { - var quotaLimit = Extensions.ParseQuotaLimit(input, out string actualBucket); + var quotaLimit = Extensions.ParseQuotaLimit(input, out var actualBucket); quotaLimit.Should().NotBeNull(); quotaLimit.Quota.Should().Be(q); @@ -21,18 +23,34 @@ public async void ParseQuotaLimit_Parses_Successfully_For_Valid_Values( quotaLimit.ResetAfter.Should().Be(t); actualBucket.Should().Be(bucket); } - + [Fact] - public async void ParseQuotaLimit_Should_Return_NULL_For_NULL_Inupt() + public void ParseQuotaLimit_Should_Return_NULL_For_NULL_Input() { - var quotaLimit = Extensions.ParseQuotaLimit(null, out string actualBucket); + var quotaLimit = Extensions.ParseQuotaLimit(null, out var actualBucket); quotaLimit.Should().BeNull(); } - + + [Theory] + [InlineData("b=per_hour;q=100;r=50", "Missing field")] + [InlineData("b=per_hour;b=per_day;q=100;r=50;t=3600", "Duplicate key")] + [InlineData("b=per_hour;;q=100;r=50;t=3600", "Empty segment")] + [InlineData("b=per_hour;q100;r=50;t=3600", "No equals sign")] + [InlineData("=per_hour;q=100;r=50;t=3600", "Empty key")] + [InlineData("b=;q=100;r=50;t=3600", "Empty value")] + [InlineData("b=per_hour;q=abc;r=50;t=3600", "Non-numeric quota")] + [InlineData("b=per_hour;q=999999999999999999999;r=50;t=3600", "Overflow")] + public void ParseQuotaLimit_Should_Return_NULL_For_Invalid_Input( + string input, string scenario) + { + var quotaLimit = Extensions.ParseQuotaLimit(input, out _); + quotaLimit.Should().BeNull(because: scenario); + } + [Theory] [InlineData("b=per_hour;q=2;r=1;t=924", "per_hour", 2, 1, 924)] [InlineData("b=per_day;q=2;r=1;t=924", "per_day", 2, 1, 924)] - public async void ParseClientQuotaLimit_Parses_Successfully_When_Either_Value_Is_Missing( + public void ParseClientQuotaLimit_Parses_Successfully_When_Either_Value_Is_Missing( string input, string bucket, int q, int r, int t) { var clientLimit = Extensions.ParseClientLimit(input); @@ -44,7 +62,7 @@ public async void ParseClientQuotaLimit_Parses_Successfully_When_Either_Value_Is clientLimit.PerHour.Remaining.Should().Be(r); clientLimit.PerHour.ResetAfter.Should().Be(t); - clientLimit.PerDay.Should().BeNull(); + clientLimit.PerDay.Should().BeNull(); } else if(bucket == "per_day") { @@ -53,12 +71,12 @@ public async void ParseClientQuotaLimit_Parses_Successfully_When_Either_Value_Is clientLimit.PerDay.Remaining.Should().Be(r); clientLimit.PerDay.ResetAfter.Should().Be(t); - clientLimit.PerHour.Should().BeNull(); + clientLimit.PerHour.Should().BeNull(); } } - + [Fact] - public async void ParseClientQuotaLimit_Parses_Successfully_When_Both_Values_Are_Present_And_Valid() + public void ParseClientQuotaLimit_Parses_Successfully_When_Both_Values_Are_Present_And_Valid() { var headerValue = "b=per_hour;q=10;r=9;t=924,b=per_day;q=100;r=99;t=924"; var clientQuota = Extensions.ParseClientLimit(headerValue); @@ -66,16 +84,16 @@ public async void ParseClientQuotaLimit_Parses_Successfully_When_Both_Values_Are clientQuota.PerDay.Quota.Should().Be(100); clientQuota.PerDay.Remaining.Should().Be(99); clientQuota.PerDay.ResetAfter.Should().Be(924); - + clientQuota.PerHour.Quota.Should().Be(10); clientQuota.PerHour.Remaining.Should().Be(9); clientQuota.PerHour.ResetAfter.Should().Be(924); } - + [Theory] [InlineData("b=per_hour;q=2;r=1;t=924", "per_hour", 2, 1, 924)] [InlineData("b=per_day;q=2;r=1;t=924", "per_day", 2, 1, 924)] - public async void ParseOrganisationQuotaLimit_Parses_Successfully_When_Either_Value_Is_Missing( + public void ParseOrganisationQuotaLimit_Parses_Successfully_When_Either_Value_Is_Missing( string input, string bucket, int q, int r, int t) { var organizationLimit = Extensions.ParseOrganizationLimit(input); @@ -87,7 +105,7 @@ public async void ParseOrganisationQuotaLimit_Parses_Successfully_When_Either_Va organizationLimit.PerHour.Remaining.Should().Be(r); organizationLimit.PerHour.ResetAfter.Should().Be(t); - organizationLimit.PerDay.Should().BeNull(); + organizationLimit.PerDay.Should().BeNull(); } else if(bucket == "per_day") { @@ -96,12 +114,12 @@ public async void ParseOrganisationQuotaLimit_Parses_Successfully_When_Either_Va organizationLimit.PerDay.Remaining.Should().Be(r); organizationLimit.PerDay.ResetAfter.Should().Be(t); - organizationLimit.PerHour.Should().BeNull(); + organizationLimit.PerHour.Should().BeNull(); } } - + [Fact] - public async void ParseOrganisationQuotaLimit_Parses_Successfully_When_Both_Values_Are_Present_And_Valid() + public void ParseOrganisationQuotaLimit_Parses_Successfully_When_Both_Values_Are_Present_And_Valid() { var headerValue = "b=per_hour;q=10;r=9;t=924,b=per_day;q=100;r=99;t=924"; var organisationQuota = Extensions.ParseOrganizationLimit(headerValue); @@ -109,28 +127,28 @@ public async void ParseOrganisationQuotaLimit_Parses_Successfully_When_Both_Valu organisationQuota.PerDay.Quota.Should().Be(100); organisationQuota.PerDay.Remaining.Should().Be(99); organisationQuota.PerDay.ResetAfter.Should().Be(924); - + organisationQuota.PerHour.Quota.Should().Be(10); organisationQuota.PerHour.Remaining.Should().Be(9); organisationQuota.PerHour.ResetAfter.Should().Be(924); } - + [Fact] - public async void ParseOrganisationQuotaLimit_Parses_Successfully_When_Header_Is_NULL() + public void ParseOrganisationQuotaLimit_Parses_Successfully_When_Header_Is_NULL() { var organisationQuota = Extensions.ParseOrganizationLimit(null); organisationQuota.Should().BeNull(); } - + [Fact] - public async void ParseClientQuotaLimit_Parses_Successfully_When_Header_Is_NULL() + public void ParseClientQuotaLimit_Parses_Successfully_When_Header_Is_NULL() { var clientQuota = Extensions.ParseClientLimit(null); clientQuota.Should().BeNull(); } - + [Fact] - public async void GetRawHeaders_Returns_Valid_Headers() + public void GetRawHeaders_Returns_Valid_Headers() { var headers = new Dictionary> { @@ -145,16 +163,16 @@ public async void GetRawHeaders_Returns_Valid_Headers() var rawHeaders = Extensions.GetRawHeaders(headers, "Auth0-Client-Quota-Limit"); rawHeaders.Should().Be("b=per_hour;q=2;r=1;t=924"); } - + [Fact] - public async void GetRawHeaders_Returns_NULL_When_Headers_Is_NULL() + public void GetRawHeaders_Returns_NULL_When_Headers_Is_NULL() { var rawHeaders = Extensions.GetRawHeaders(null, "Auth0-Client-Quota-Limit"); rawHeaders.Should().BeNull(); } - + [Fact] - public async void GetClientQuotaLimit_Returns_Valid_Quota() + public void GetClientQuotaLimit_Returns_Valid_Quota() { var headers = new Dictionary> { @@ -167,22 +185,22 @@ public async void GetClientQuotaLimit_Returns_Valid_Quota() { "Auth0-Organization-Quota-Limit", ["b=per_hour;q=2;r=1;t=924,b=per_day;q=20;r=10;t=924"] } }; var clientQuotaLimit = headers.GetClientQuotaLimit(); - + clientQuotaLimit.Should().NotBeNull(); clientQuotaLimit.PerDay.Should().NotBeNull(); clientQuotaLimit.PerHour.Should().NotBeNull(); - + clientQuotaLimit.PerDay.Quota.Should().Be(20); clientQuotaLimit.PerDay.Remaining.Should().Be(10); clientQuotaLimit.PerDay.ResetAfter.Should().Be(924); - + clientQuotaLimit.PerHour.Quota.Should().Be(2); clientQuotaLimit.PerHour.Remaining.Should().Be(1); clientQuotaLimit.PerHour.ResetAfter.Should().Be(924); } - + [Fact] - public async void GetOrganizationQuotaLimit_Returns_Valid_Quota() + public void GetOrganizationQuotaLimit_Returns_Valid_Quota() { var headers = new Dictionary> { @@ -195,17 +213,17 @@ public async void GetOrganizationQuotaLimit_Returns_Valid_Quota() { "Auth0-Organization-Quota-Limit", ["b=per_hour;q=2;r=1;t=924,b=per_day;q=20;r=10;t=924"] } }; var organizationQuotaLimit = headers.GetOrganizationQuotaLimit(); - + organizationQuotaLimit.Should().NotBeNull(); organizationQuotaLimit.PerDay.Should().NotBeNull(); organizationQuotaLimit.PerHour.Should().NotBeNull(); - + organizationQuotaLimit.PerDay.Quota.Should().Be(20); organizationQuotaLimit.PerDay.Remaining.Should().Be(10); organizationQuotaLimit.PerDay.ResetAfter.Should().Be(924); - + organizationQuotaLimit.PerHour.Quota.Should().Be(2); organizationQuotaLimit.PerHour.Remaining.Should().Be(1); organizationQuotaLimit.PerHour.ResetAfter.Should().Be(924); } -} \ No newline at end of file +} diff --git a/tests/Auth0.Core.UnitTests/UtilsTests.cs b/tests/Auth0.Core.UnitTests/UtilsTests.cs index f9220fdf0..08bb475ed 100644 --- a/tests/Auth0.Core.UnitTests/UtilsTests.cs +++ b/tests/Auth0.Core.UnitTests/UtilsTests.cs @@ -107,7 +107,7 @@ public void Base64UrlEncode_WithSingleByte_ReturnsCorrectBase64UrlString() { var input = new byte[] { 72 }; var result = Utils.Base64UrlEncode(input); - result.Should().Be("SA=="); + result.Should().Be("SA"); } [Fact] @@ -115,7 +115,7 @@ public void Base64UrlEncode_WithMultipleBytes_ReturnsCorrectBase64UrlString() { var input = Encoding.UTF8.GetBytes("Hello"); var result = Utils.Base64UrlEncode(input); - result.Should().Be("SGVsbG8="); + result.Should().Be("SGVsbG8"); } [Fact] @@ -127,19 +127,19 @@ public void Base64UrlEncode_WithBytesRequiringNoPadding_ReturnsStringWithoutPadd } [Fact] - public void Base64UrlEncode_WithBytesRequiringOnePaddingChar_ReturnsStringWithOnePaddingChar() + public void Base64UrlEncode_WithBytesRequiringOnePaddingChar_ReturnsStringWithoutPaddingChar() { var input = Encoding.UTF8.GetBytes("Ma"); var result = Utils.Base64UrlEncode(input); - result.Should().Be("TWE="); + result.Should().Be("TWE"); } [Fact] - public void Base64UrlEncode_WithBytesRequiringTwoPaddingChars_ReturnsStringWithTwoPaddingChars() + public void Base64UrlEncode_WithBytesRequiringTwoPaddingChars_ReturnsStringWithoutPaddingChars() { var input = Encoding.UTF8.GetBytes("M"); var result = Utils.Base64UrlEncode(input); - result.Should().Be("TQ=="); + result.Should().Be("TQ"); } [Fact] @@ -147,7 +147,7 @@ public void Base64UrlEncode_WithAllZeroBytes_ReturnsCorrectBase64UrlString() { var input = new byte[] { 0, 0, 0, 0 }; var result = Utils.Base64UrlEncode(input); - result.Should().Be("AAAAAA=="); + result.Should().Be("AAAAAA"); } [Fact] @@ -155,7 +155,7 @@ public void Base64UrlEncode_WithAllMaxValueBytes_ReturnsCorrectBase64UrlString() { var input = new byte[] { 255, 255, 255, 255 }; var result = Utils.Base64UrlEncode(input); - result.Should().Be("/////w=="); + result.Should().Be("_____w"); } [Fact]