diff --git a/.gitignore b/.gitignore index 57907c574..7dd56f4a2 100644 --- a/.gitignore +++ b/.gitignore @@ -235,3 +235,4 @@ html/ # sonar cloud stuff .sonarqube +/test/Twilio.Benchmark/BenchmarkDotNet.Artifacts diff --git a/Twilio.sln b/Twilio.sln index f75efdaab..f11fa4220 100644 --- a/Twilio.sln +++ b/Twilio.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26206.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33205.214 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{36585F38-8C30-49A9-BDA1-9A0DC61C288B}" EndProject @@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Twilio", "src\Twilio\Twilio EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Twilio.Test", "test\Twilio.Test\Twilio.Test.csproj", "{DC35107A-F987-47A3-B0BC-7110BA15943C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Twilio.Benchmark", "test\Twilio.Benchmark\Twilio.Benchmark.csproj", "{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,6 +47,18 @@ Global {DC35107A-F987-47A3-B0BC-7110BA15943C}.Release|x64.Build.0 = Release|Any CPU {DC35107A-F987-47A3-B0BC-7110BA15943C}.Release|x86.ActiveCfg = Release|Any CPU {DC35107A-F987-47A3-B0BC-7110BA15943C}.Release|x86.Build.0 = Release|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|x64.ActiveCfg = Debug|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|x64.Build.0 = Debug|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|x86.ActiveCfg = Debug|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|x86.Build.0 = Debug|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|Any CPU.Build.0 = Release|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|x64.ActiveCfg = Release|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|x64.Build.0 = Release|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|x86.ActiveCfg = Release|Any CPU + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -52,6 +66,7 @@ Global GlobalSection(NestedProjects) = preSolution {62BB8FE9-99DD-475D-80EB-D2E53C380754} = {36585F38-8C30-49A9-BDA1-9A0DC61C288B} {DC35107A-F987-47A3-B0BC-7110BA15943C} = {FE04FB2E-73FB-4E45-AAEE-EE04754A5E9C} + {80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1} = {FE04FB2E-73FB-4E45-AAEE-EE04754A5E9C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {75638FC3-0E0B-4D79-8BEB-8CC499BF98C5} diff --git a/src/Twilio/Security/RequestValidator.cs b/src/Twilio/Security/RequestValidator.cs index aa94ada48..8a1dcca1c 100644 --- a/src/Twilio/Security/RequestValidator.cs +++ b/src/Twilio/Security/RequestValidator.cs @@ -1,164 +1,233 @@ -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Security.Cryptography; -using System.Text; - -namespace Twilio.Security -{ - /// - /// Twilio request validator - /// - public class RequestValidator - { - private readonly HMACSHA1 _hmac; - private readonly SHA256 _sha; - - /// - /// Create a new RequestValidator - /// - /// Signing secret - public RequestValidator(string secret) - { - _hmac = new HMACSHA1(Encoding.UTF8.GetBytes(secret)); - _sha = SHA256.Create(); - } - - /// - /// Validate against a request - /// - /// Request URL - /// Request parameters - /// Expected result - /// true if the signature matches the result; false otherwise - public bool Validate(string url, NameValueCollection parameters, string expected) - { - return Validate(url, ToDictionary(parameters), expected); - } - - /// - /// Validate against a request - /// - /// Request URL - /// Request parameters - /// Expected result - /// true if the signature matches the result; false otherwise - public bool Validate(string url, IDictionary parameters, string expected) - { - // check signature of url with and without port, since sig generation on back end is inconsistent - var signatureWithoutPort = GetValidationSignature(RemovePort(url), parameters); - var signatureWithPort = GetValidationSignature(AddPort(url), parameters); - // If either url produces a valid signature, we accept the request as valid - return SecureCompare(signatureWithoutPort, expected) || SecureCompare(signatureWithPort, expected); - } - - public bool Validate(string url, string body, string expected) - { - var paramString = new UriBuilder(url).Query.TrimStart('?'); - var bodyHash = ""; - foreach (var param in paramString.Split('&')) - { - var split = param.Split('='); - if (split[0] == "bodySHA256") - { - bodyHash = Uri.UnescapeDataString(split[1]); - } - } - - return Validate(url, new Dictionary(), expected) && ValidateBody(body, bodyHash); - } - - public bool ValidateBody(string rawBody, string expected) - { - var signature = _sha.ComputeHash(Encoding.UTF8.GetBytes(rawBody)); - return SecureCompare(BitConverter.ToString(signature).Replace("-","").ToLower(), expected); - } - - private static IDictionary ToDictionary(NameValueCollection col) - { - var dict = new Dictionary(); - foreach (var k in col.AllKeys) - { - dict.Add(k, col[k]); - } - return dict; - } - - private string GetValidationSignature(string url, IDictionary parameters) - { - var b = new StringBuilder(url); - if (parameters != null) - { - var sortedKeys = new List(parameters.Keys); - sortedKeys.Sort(StringComparer.Ordinal); - - foreach (var key in sortedKeys) - { - b.Append(key).Append(parameters[key] ?? ""); - } - } - - var hash = _hmac.ComputeHash(Encoding.UTF8.GetBytes(b.ToString())); - return Convert.ToBase64String(hash); - } - - private static bool SecureCompare(string a, string b) - { - if (a == null || b == null) - { - return false; - } - - var n = a.Length; - if (n != b.Length) - { - return false; - } - - var mismatch = 0; - for (var i = 0; i < n; i++) - { - mismatch |= a[i] ^ b[i]; - } - - return mismatch == 0; - } - - private string RemovePort(string url) - { - return SetPort(url, -1); - } - - private string AddPort(string url) - { - var uri = new UriBuilder(url); - return SetPort(url, uri.Port); - } - - private string SetPort(string url, int port) - { - var uri = new UriBuilder(url); - uri.Host = PreserveCase(url, uri.Host); - if (port == -1) - { - uri.Port = port; - } - else if ((port != 443) && (port != 80)) - { - uri.Port = port; - } - else - { - uri.Port = uri.Scheme == "https" ? 443 : 80; - } - var scheme = PreserveCase(url, uri.Scheme); - return uri.Uri.OriginalString.Replace(uri.Scheme, scheme); - } - - private string PreserveCase(string url, string replacementString) - { - var startIndex = url.IndexOf(replacementString, StringComparison.OrdinalIgnoreCase); - return url.Substring(startIndex, replacementString.Length); - } - } -} +using System; +using System.Linq; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Security.Cryptography; +using System.Text; +using System.Runtime.CompilerServices; + +namespace Twilio.Security +{ + /// + /// Twilio request validator + /// + public class RequestValidator + { + private readonly byte[] _secret; + + /// + /// Create a new RequestValidator + /// + /// Signing secret + public RequestValidator(string secret) + { + _secret = Encoding.UTF8.GetBytes(secret); + } + + /// + /// Validate against a request + /// + /// Request URL + /// Request parameters + /// Expected result + /// true if the signature matches the result; false otherwise + public bool Validate(string url, NameValueCollection parameters, string expected) + { + return Validate(url, ToDictionary(parameters), expected); + } + + /// + /// Validate against a request + /// + /// Request URL + /// Request parameters + /// Expected result + /// true if the signature matches the result; false otherwise + public bool Validate(string url, IDictionary parameters, string expected) + { + if (string.IsNullOrEmpty(url)) + throw new ArgumentException("Parameter 'url' cannot be null or empty.", nameof(url)); + if (string.IsNullOrEmpty(expected)) + throw new ArgumentException("Parameter 'expected' cannot be null or empty.", nameof(url)); + +#if NET6_0_OR_GREATER + { + byte[] computeHash(byte[] buffer) => HMACSHA1.HashData(_secret, buffer); +#else + using (var hmac = new HMACSHA1(_secret)) + { + Func computeHash = hmac.ComputeHash; +#endif + if (parameters == null || parameters.Count == 0) + { + var signature = GetValidationSignature(url, computeHash); + if (SecureCompare(signature, expected)) return true; + + // check signature of url with and without port, since sig generation on back end is inconsistent + // If either url produces a valid signature, we accept the request as valid + url = GetUriVariation(url); + signature = GetValidationSignature(url, computeHash); + if (SecureCompare(signature, expected)) return true; + return false; + } + else + { + var parameterStringBuilder = GetJoinedParametersStringBuilder(parameters); + parameterStringBuilder.Insert(0, url); + var signature = GetValidationSignature(parameterStringBuilder.ToString(), computeHash); + if (SecureCompare(signature, expected)) return true; + parameterStringBuilder.Remove(0, url.Length); + + // check signature of url with and without port, since sig generation on back end is inconsistent + // If either url produces a valid signature, we accept the request as valid + url = GetUriVariation(url); + parameterStringBuilder.Insert(0, url); + signature = GetValidationSignature(parameterStringBuilder.ToString(), computeHash); + if (SecureCompare(signature, expected)) return true; + + return false; + } + } + } + + private StringBuilder GetJoinedParametersStringBuilder(IDictionary parameters) + { + var keys = parameters.Keys.ToArray(); + Array.Sort(keys, StringComparer.Ordinal); + + var b = new StringBuilder(); + foreach (var key in keys) + { + b.Append(key).Append(parameters[key] ?? ""); + } + + return b; + } + + public bool Validate(string url, string body, string expected) + { + if (string.IsNullOrEmpty(url)) + throw new ArgumentException("Parameter 'url' cannot be null or empty.", nameof(url)); + if (string.IsNullOrEmpty(expected)) + throw new ArgumentException("Parameter 'expected' cannot be null or empty.", nameof(expected)); + + var paramString = new Uri(url, UriKind.Absolute).Query.TrimStart('?'); + var bodyHash = ""; + foreach (var param in paramString.Split('&')) + { + var split = param.Split('='); + if (split[0] == "bodySHA256") + { + bodyHash = Uri.UnescapeDataString(split[1]); + } + } + + return Validate(url, (IDictionary)null, expected) && ValidateBody(body, bodyHash); + } + + public static bool ValidateBody(string rawBody, string expected) + { +#if NET6_0_OR_GREATER + { + var signature = SHA256.HashData(Encoding.UTF8.GetBytes(rawBody)); +#else + using (var sha = SHA256.Create()) + { + var signature = sha.ComputeHash(Encoding.UTF8.GetBytes(rawBody)); +#endif + return SecureCompare(BitConverter.ToString(signature).Replace("-", "").ToLower(), expected); + } + } + + private static IDictionary ToDictionary(NameValueCollection col) + { + var dict = new Dictionary(); + foreach (var k in col.AllKeys) + { + dict.Add(k, col[k]); + } + + return dict; + } + + private string GetValidationSignature(string urlWithParameters, Func computeHash) + { + var hash = computeHash(Encoding.UTF8.GetBytes(urlWithParameters)); + return Convert.ToBase64String(hash); + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static bool SecureCompare(string a, string b) + { + if (a == null || b == null) + { + return false; + } + + var n = a.Length; + if (n != b.Length) + { + return false; + } + + var mismatch = 0; + for (var i = 0; i < n; i++) + { + mismatch |= a[i] ^ b[i]; + } + + return mismatch == 0; + } + + /// + /// Returns URL without port if given URL has port, returns URL with port if given URL has no port + /// + /// + /// + private static string GetUriVariation(string url) + { + var uri = new Uri(url); + var uriBuilder = new UriBuilder(uri); + var port = uri.GetComponents(UriComponents.Port, UriFormat.UriEscaped); + // if port already removed + if (port == "") + { + return SetPort(url, uriBuilder, uriBuilder.Port); + } + + return SetPort(url, uriBuilder, -1); + } + + private static string SetPort(string url, UriBuilder uri, int newPort) + { + if (newPort == -1) + { + uri.Port = newPort; + } + else if (newPort != 443 && newPort != 80) + { + uri.Port = newPort; + } + else + { + uri.Port = uri.Scheme == "https" ? 443 : 80; + } + + var uriStringBuilder = new StringBuilder(uri.ToString()); + + var host = PreserveCase(url, uri.Host); + uriStringBuilder.Replace(uri.Host, host); + + var scheme = PreserveCase(url, uri.Scheme); + uriStringBuilder.Replace(uri.Scheme, scheme); + + return uriStringBuilder.ToString(); + } + + private static string PreserveCase(string url, string replacementString) + { + var startIndex = url.IndexOf(replacementString, StringComparison.OrdinalIgnoreCase); + return url.Substring(startIndex, replacementString.Length); + } + } +} \ No newline at end of file diff --git a/src/Twilio/Twilio.csproj b/src/Twilio/Twilio.csproj index 6d01d5bce..82e2476bc 100644 --- a/src/Twilio/Twilio.csproj +++ b/src/Twilio/Twilio.csproj @@ -1,59 +1,63 @@  - - netstandard1.4;netstandard2.0;net451;net35 - true - Twilio - Twilio REST API helper library - Copyright © Twilio - Twilio - en-US - 6.16.1 - - - Twilio - $(NoWarn);CS1591 - true - true - Twilio - REST;SMS;voice;telephony;phone;twilio;twiml;video;wireless;api - https://www.twilio.com/docs/static/company/img/logos/red/twilio-mark-red.898073bba.png - http://github.com/twilio/twilio-csharp - https://github.com/twilio/twilio-csharp/blob/HEAD/LICENSE - http://github.com/twilio/twilio-csharp - git - 1.6.1 - 2.0.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + net6.0;netstandard1.4;netstandard2.0;net451;net35 + true + Twilio + Twilio REST API helper library + Copyright © Twilio + Twilio + en-US + 6.6.1 + Twilio + $(NoWarn);CS1591 + true + true + Twilio + REST;SMS;voice;telephony;phone;twilio;twiml;video;wireless;api + https://www.twilio.com/docs/static/company/img/logos/red/twilio-mark-red.898073bba.png + http://github.com/twilio/twilio-csharp + https://github.com/twilio/twilio-csharp/blob/HEAD/LICENSE + http://github.com/twilio/twilio-csharp + git + 1.6.1 + 2.0.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Twilio.Benchmark/Program.cs b/test/Twilio.Benchmark/Program.cs new file mode 100644 index 000000000..8c8e2dcd2 --- /dev/null +++ b/test/Twilio.Benchmark/Program.cs @@ -0,0 +1,92 @@ +using System.Collections.Specialized; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Running; +using Twilio.Security; + +var summary = BenchmarkRunner.Run(); +Console.Write(summary); + +[MemoryDiagnoser] +public class RequestValidationBenchmark +{ + private const string Secret = "12345"; + private const string UnhappyPathUrl = "HTTP://MyCompany.com:8080/myapp.php?foo=1&bar=2"; + private const string UnhappyPathSignature = "eYYN9fMlxrQMXOsr7bIzoPTrbxA="; + private const string HappyPathUrl = "https://mycompany.com/myapp.php?foo=1&bar=2"; + private const string HappyPathSignature = "3LL3BFKOcn80artVM5inMPFpmtU="; + private static readonly NameValueCollection UnhappyPathParameters = new() + { + {"ToCountry", "US"}, + {"ToState", "OH"}, + {"SmsMessageSid", "SMcea2a3bd6f50296f8fab60f377db03eb"}, + {"NumMedia", "0"}, + {"ToCity", "UTICA"}, + {"FromZip", "20705"}, + {"SmsSid", "SMcea2a3bd6f50296f8fab60f377db03eb"}, + {"FromState", "DC"}, + {"SmsStatus", "received"}, + {"FromCity", "BELTSVILLE"}, + {"Body", "Ahoy!"}, + {"FromCountry", "US"}, + {"To", "+10123456789"}, + {"ToZip", "43037"}, + {"NumSegments", "1"}, + {"ReferralNumMedia", "0"}, + {"MessageSid", "SMcea2a3bd6f50296f8fab60f377db03eb"}, + {"AccountSid", "ACe718619887aac3ee5b21edafbvsdf6h7fgb"}, + {"From", "+10123456789"}, + {"ApiVersion", "2010-04-01"} + }; + private static readonly Dictionary HappyPathParameters = new() + { + {"ToCountry", "US"}, + {"ToState", "OH"}, + {"SmsMessageSid", "SMcea2a3bd6f50296f8fab60f377db03eb"}, + {"NumMedia", "0"}, + {"ToCity", "UTICA"}, + {"FromZip", "20705"}, + {"SmsSid", "SMcea2a3bd6f50296f8fab60f377db03eb"}, + {"FromState", "DC"}, + {"SmsStatus", "received"}, + {"FromCity", "BELTSVILLE"}, + {"Body", "Ahoy!"}, + {"FromCountry", "US"}, + {"To", "+10123456789"}, + {"ToZip", "43037"}, + {"NumSegments", "1"}, + {"ReferralNumMedia", "0"}, + {"MessageSid", "SMcea2a3bd6f50296f8fab60f377db03eb"}, + {"AccountSid", "ACe718619887aac3ee5b21edafbvsdf6h7fgb"}, + {"From", "+10123456789"}, + {"ApiVersion", "2010-04-01"} + }; + + + [Benchmark] + public void OriginalUnhappyPath() + { + var requestValidator = new RequestValidatorOriginal(Secret); + requestValidator.Validate(UnhappyPathUrl, UnhappyPathParameters, UnhappyPathSignature); + } + + [Benchmark] + public void CurrentUnhappyPath() + { + var requestValidator = new RequestValidator(Secret); + requestValidator.Validate(UnhappyPathUrl, UnhappyPathParameters, UnhappyPathSignature); + } + + [Benchmark] + public void OriginalHappyPath() + { + var requestValidator = new RequestValidatorOriginal(Secret); + requestValidator.Validate(HappyPathUrl, HappyPathParameters, HappyPathSignature); + } + + [Benchmark] + public void CurrentHappyPath() + { + var requestValidator = new RequestValidator(Secret); + requestValidator.Validate(HappyPathUrl, HappyPathParameters, HappyPathSignature); + } +} \ No newline at end of file diff --git a/test/Twilio.Benchmark/RequestValidatorOriginal.cs b/test/Twilio.Benchmark/RequestValidatorOriginal.cs new file mode 100644 index 000000000..12b1a9947 --- /dev/null +++ b/test/Twilio.Benchmark/RequestValidatorOriginal.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; + +namespace Twilio.Security +{ + /// + /// Twilio request validator + /// + public class RequestValidatorOriginal + { + private readonly HMACSHA1 _hmac; + private readonly SHA256 _sha; + + /// + /// Create a new RequestValidator + /// + /// Signing secret + public RequestValidatorOriginal(string secret) + { + _hmac = new HMACSHA1(Encoding.UTF8.GetBytes(secret)); + _sha = SHA256.Create(); + } + + /// + /// Validate against a request + /// + /// Request URL + /// Request parameters + /// Expected result + /// true if the signature matches the result; false otherwise + public bool Validate(string url, NameValueCollection parameters, string expected) + { + return Validate(url, ToDictionary(parameters), expected); + } + + /// + /// Validate against a request + /// + /// Request URL + /// Request parameters + /// Expected result + /// true if the signature matches the result; false otherwise + public bool Validate(string url, IDictionary parameters, string expected) + { + // check signature of url with and without port, since sig generation on back end is inconsistent + var signatureWithoutPort = GetValidationSignature(RemovePort(url), parameters); + var signatureWithPort = GetValidationSignature(AddPort(url), parameters); + // If either url produces a valid signature, we accept the request as valid + return SecureCompare(signatureWithoutPort, expected) || SecureCompare(signatureWithPort, expected); + } + + public bool Validate(string url, string body, string expected) + { + var paramString = new UriBuilder(url).Query.TrimStart('?'); + var bodyHash = ""; + foreach (var param in paramString.Split('&')) + { + var split = param.Split('='); + if (split[0] == "bodySHA256") + { + bodyHash = Uri.UnescapeDataString(split[1]); + } + } + + return Validate(url, new Dictionary(), expected) && ValidateBody(body, bodyHash); + } + + public bool ValidateBody(string rawBody, string expected) + { + var signature = _sha.ComputeHash(Encoding.UTF8.GetBytes(rawBody)); + return SecureCompare(BitConverter.ToString(signature).Replace("-", "").ToLower(), expected); + } + + private static IDictionary ToDictionary(NameValueCollection col) + { + var dict = new Dictionary(); + foreach (var k in col.AllKeys) + { + dict.Add(k, col[k]); + } + return dict; + } + + private string GetValidationSignature(string url, IDictionary parameters) + { + var b = new StringBuilder(url); + if (parameters != null) + { + var sortedKeys = new List(parameters.Keys); + sortedKeys.Sort(StringComparer.Ordinal); + + foreach (var key in sortedKeys) + { + b.Append(key).Append(parameters[key] ?? ""); + } + } + + var hash = _hmac.ComputeHash(Encoding.UTF8.GetBytes(b.ToString())); + return Convert.ToBase64String(hash); + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static bool SecureCompare(string a, string b) + { + if (a == null || b == null) + { + return false; + } + + var n = a.Length; + if (n != b.Length) + { + return false; + } + + var mismatch = 0; + for (var i = 0; i < n; i++) + { + mismatch |= a[i] ^ b[i]; + } + + return mismatch == 0; + } + + private string RemovePort(string url) + { + return SetPort(url, -1); + } + + private string AddPort(string url) + { + var uri = new UriBuilder(url); + return SetPort(url, uri.Port); + } + + private string SetPort(string url, int port) + { + var uri = new UriBuilder(url); + uri.Host = PreserveCase(url, uri.Host); + if (port == -1) + { + uri.Port = port; + } + else if ((port != 443) && (port != 80)) + { + uri.Port = port; + } + else + { + uri.Port = uri.Scheme == "https" ? 443 : 80; + } + var scheme = PreserveCase(url, uri.Scheme); + return uri.Uri.OriginalString.Replace(uri.Scheme, scheme); + } + + private string PreserveCase(string url, string replacementString) + { + var startIndex = url.IndexOf(replacementString, StringComparison.OrdinalIgnoreCase); + return url.Substring(startIndex, replacementString.Length); + } + } +} diff --git a/test/Twilio.Benchmark/Twilio.Benchmark.csproj b/test/Twilio.Benchmark/Twilio.Benchmark.csproj new file mode 100644 index 000000000..81b91b034 --- /dev/null +++ b/test/Twilio.Benchmark/Twilio.Benchmark.csproj @@ -0,0 +1,17 @@ + + + + Exe + net7.0 + enable + disable + + + + + + + + + + diff --git a/test/Twilio.Test/Security/RequestValidatorTest.cs b/test/Twilio.Test/Security/RequestValidatorTest.cs index 22dd894f3..0d3b8646d 100644 --- a/test/Twilio.Test/Security/RequestValidatorTest.cs +++ b/test/Twilio.Test/Security/RequestValidatorTest.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; +using System.Threading; using NUnit.Framework; using Twilio.Security; @@ -121,7 +123,7 @@ public void TestValidateCollection() [Test] public void TestValidateBody() { - Assert.IsTrue(_validator.ValidateBody(Body, BodyHash), "Request body validation failed"); + Assert.IsTrue(RequestValidator.ValidateBody(Body, BodyHash), "Request body validation failed"); } [Test] @@ -183,6 +185,31 @@ public void TestValidateAddsCustomPortHttp() { const string url = "http://mycompany.com:1234/myapp.php?foo=1&bar=2"; Assert.IsTrue(_validator.Validate(url, _parameters, "Zmvh+3yNM1Phv2jhDCwEM3q5ebU="), "Request does not match provided signature"); + } + + [Test] + public void TestIsThreadSafe() + { + var validator = new RequestValidator("secret"); + var thread1 = new Thread(Validate); + var thread2 = new Thread(Validate); + + Assert.DoesNotThrow(() => + { + thread1.Start(validator); + thread2.Start(validator); + thread1.Join(); + thread2.Join(); + }); + } + + private static void Validate(object obj) + { + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < 5000) + { + ((RequestValidator)obj).Validate("https://foo.com", "123", "foo"); + } } } } diff --git a/test/Twilio.Test/Twilio.Test.csproj b/test/Twilio.Test/Twilio.Test.csproj index 6189e62c0..c807826a0 100644 --- a/test/Twilio.Test/Twilio.Test.csproj +++ b/test/Twilio.Test/Twilio.Test.csproj @@ -1,4 +1,4 @@ - + Exe Twilio.Tests @@ -8,7 +8,7 @@ false - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -30,4 +30,4 @@ - + \ No newline at end of file