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..6eb9ab933 100644
--- a/src/Twilio/Security/RequestValidator.cs
+++ b/src/Twilio/Security/RequestValidator.cs
@@ -1,4 +1,5 @@
using System;
+using System.Linq;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Security.Cryptography;
@@ -45,16 +46,63 @@ public bool Validate(string url, NameValueCollection parameters, string expected
/// 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);
- }
-
+ if (string.IsNullOrEmpty(url))
+ throw new ArgumentException("Parameter 'url' cannot be null or empty.");
+ if (string.IsNullOrEmpty(expected))
+ throw new ArgumentException("Parameter 'expected' cannot be null or empty.");
+
+ if(parameters == null || parameters.Count == 0)
+ {
+ var signature = GetValidationSignature(url);
+ 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);
+ if (SecureCompare(signature, expected)) return true;
+ return false;
+ }
+ else
+ {
+ var parameterStringBuilder = GetJoinedParametersStringBuilder(parameters);
+ parameterStringBuilder.Insert(0, url);
+ var signature = GetValidationSignature(parameterStringBuilder.ToString());
+ 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());
+ 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)
{
- var paramString = new UriBuilder(url).Query.TrimStart('?');
+ if (string.IsNullOrEmpty(url))
+ throw new ArgumentException("Parameter 'url' cannot be null or empty.");
+ if (string.IsNullOrEmpty(expected))
+ throw new ArgumentException("Parameter 'expected' cannot be null or empty.");
+
+ var paramString = new Uri(url, UriKind.Absolute).Query.TrimStart('?');
var bodyHash = "";
foreach (var param in paramString.Split('&'))
{
@@ -65,13 +113,13 @@ public bool Validate(string url, string body, string expected)
}
}
- return Validate(url, new Dictionary(), expected) && ValidateBody(body, bodyHash);
+ return Validate(url, (IDictionary) null, 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);
+ return SecureCompare(BitConverter.ToString(signature).Replace("-", "").ToLower(), expected);
}
private static IDictionary ToDictionary(NameValueCollection col)
@@ -81,27 +129,16 @@ private static IDictionary ToDictionary(NameValueCollection col)
{
dict.Add(k, col[k]);
}
+
return dict;
}
- private string GetValidationSignature(string url, IDictionary parameters)
+ private string GetValidationSignature(string urlWithParameters)
{
- 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()));
+ byte[] hash = _hmac.ComputeHash(Encoding.UTF8.GetBytes(urlWithParameters));
return Convert.ToBase64String(hash);
- }
-
+ }
+
private static bool SecureCompare(string a, string b)
{
if (a == null || b == null)
@@ -124,41 +161,55 @@ private static bool SecureCompare(string a, string b)
return mismatch == 0;
}
- private string RemovePort(string url)
+ ///
+ /// Returns URL without port if given URL has port, returns URL with port if given URL has no port
+ ///
+ ///
+ ///
+ private string GetUriVariation(string url)
{
- return SetPort(url, -1);
- }
+ 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);
+ }
- private string AddPort(string url)
- {
- var uri = new UriBuilder(url);
- return SetPort(url, uri.Port);
+ return SetPort(url, uriBuilder, -1);
}
- private string SetPort(string url, int port)
+ private string SetPort(string url, UriBuilder uri, int newPort)
{
- var uri = new UriBuilder(url);
- uri.Host = PreserveCase(url, uri.Host);
- if (port == -1)
+ if (newPort == -1)
{
- uri.Port = port;
+ uri.Port = newPort;
}
- else if ((port != 443) && (port != 80))
+ else if (newPort != 443 && newPort != 80)
{
- uri.Port = port;
+ 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);
- return uri.Uri.OriginalString.Replace(uri.Scheme, scheme);
- }
+ uriStringBuilder.Replace(uri.Scheme, scheme);
+ return uriStringBuilder.ToString();
+ }
+
private 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/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..9395a7500
--- /dev/null
+++ b/test/Twilio.Benchmark/RequestValidatorOriginal.cs
@@ -0,0 +1,164 @@
+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 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);
+ }
+
+ 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
+
+
+
+
+
+
+
+
+
+