diff --git a/src/Core/Utilities/EncryptedStringAttribute.cs b/src/Core/Utilities/EncryptedStringAttribute.cs index 9c59287df6a2..6f57737a5da1 100644 --- a/src/Core/Utilities/EncryptedStringAttribute.cs +++ b/src/Core/Utilities/EncryptedStringAttribute.cs @@ -111,7 +111,7 @@ private static bool ValidatePieces(ReadOnlySpan encryptionPart, int requir if (requiredPieces == 1) { // Only one more part is needed so don't split and check the chunk - if (rest.IsEmpty || !Base64.IsValid(rest)) + if (rest.IsEmpty || !IsValidBase64Permissive(rest)) { return false; } @@ -128,7 +128,7 @@ private static bool ValidatePieces(ReadOnlySpan encryptionPart, int requir } // Is the required chunk valid base 64? - if (chunk.IsEmpty || !Base64.IsValid(chunk)) + if (chunk.IsEmpty || !IsValidBase64Permissive(chunk)) { return false; } @@ -141,4 +141,57 @@ private static bool ValidatePieces(ReadOnlySpan encryptionPart, int requir // No more parts are required, so check there are no extra parts return rest.IndexOf('|') == -1; } + + private const string _base64Chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + private static int Base64CharValue(char c) => c switch + { + >= 'A' and <= 'Z' => c - 'A', + >= 'a' and <= 'z' => c - 'a' + 26, + >= '0' and <= '9' => c - '0' + 52, + '+' => 62, + '/' => 63, + _ => -1, + }; + + /// + /// Validates base64 permissively by accepting non-zero padding bits. + /// + private static bool IsValidBase64Permissive(ReadOnlySpan value) + { + // Obviously not base64 + if (value.IsEmpty || value.Length % 4 != 0) + return false; + + // If there isn't any padding, there's nothing to be permissive about. + var padCount = 0; + if (value[^1] == '=') { padCount++; if (value[^2] == '=') padCount++; } + if (padCount == 0) + return Base64.IsValid(value); + + // Get the last non-padding char. Ensure it's in the base64 alphabet. + var lastDataIdx = value.Length - padCount - 1; + var charVal = Base64CharValue(value[lastDataIdx]); + if (charVal < 0) + return false; + + // Compute the correct char. If the original char is already valid, + // test the full string. + var dataBitMask = padCount == 2 ? 0b110000 : 0b111100; + var newCharVal = charVal & dataBitMask; + if (newCharVal == charVal) + return Base64.IsValid(value); + + // Validate all but the last block, to minimize allocation in the next + // section. + if (value.Length > 4 && !Base64.IsValid(value[..^4])) + return false; + + // Apply the correct char and validate the last block + Span canonical = stackalloc char[4]; + value[^4..].CopyTo(canonical); + canonical[4 - padCount - 1] = _base64Chars[newCharVal]; + return Base64.IsValid(canonical); + } } diff --git a/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs b/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs index eeccb3be3f3f..b7e1abd24d84 100644 --- a/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs +++ b/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs @@ -25,6 +25,11 @@ public class EncryptedStringAttributeTests [InlineData("Rsa2048_OaepSha256_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha256_HmacSha256_B64 as a string [InlineData("6.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha1_HmacSha256_B64 as a number [InlineData("Rsa2048_OaepSha1_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] + [InlineData("0.AAAA|Y3Q=")] // Unpadded IV with padded CT + [InlineData("lGD=|lGD=")] // Non-canonical = padding on both pieces (headerless) + [InlineData("lB==|Y3Q=|AAAA")] // Non-canonical == padding headerless (3 pieces) + [InlineData("2.lGD=|lGD=|lGD=")] // Non-canonical = padding on all three pieces + [InlineData("0.AAAA|QmFzZTY0UGFydB==")] // Unpadded IV, non-canonical == in longer piece (exercises prefix validation) public void IsValid_ReturnsTrue_WhenValid(string? input) { var sut = new EncryptedStringAttribute(); @@ -65,6 +70,9 @@ public void IsValid_ReturnsTrue_WhenValid(string? input) [InlineData("Rsa2048_OaepSha256_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha256_HmacSha256_B64 as a string [InlineData("6.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha1_HmacSha256_B64 as a number [InlineData("Rsa2048_OaepSha1_HmacSha256_B64.QmFzZTY0UGFydA==")] // Invalid Rsa2048_OaepSha1_HmacSha256_B64 as a string + [InlineData("0.AA!!AB==|Y3Q=")] // Invalid char in prefix with non-canonical last char + [InlineData("0.AAAAB==|Y3Q=")] // Piece length not multiple of 4 + [InlineData("0.====|Y3Q=")] // Padding-only piece public void IsValid_ReturnsFalse_WhenInvalid(string input) { var sut = new EncryptedStringAttribute();