Skip to content

Commit 813a64b

Browse files
committed
Optimize request validator but keep backwards compatible
1 parent 25bdab1 commit 813a64b

File tree

6 files changed

+385
-45
lines changed

6 files changed

+385
-45
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,4 @@ html/
235235

236236
# sonar cloud stuff
237237
.sonarqube
238+
/test/Twilio.Benchmark/BenchmarkDotNet.Artifacts

Twilio.sln

+17-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
Microsoft Visual Studio Solution File, Format Version 12.00
3-
# Visual Studio 15
4-
VisualStudioVersion = 15.0.26206.0
3+
# Visual Studio Version 17
4+
VisualStudioVersion = 17.4.33205.214
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{36585F38-8C30-49A9-BDA1-9A0DC61C288B}"
77
EndProject
@@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Twilio", "src\Twilio\Twilio
1111
EndProject
1212
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Twilio.Test", "test\Twilio.Test\Twilio.Test.csproj", "{DC35107A-F987-47A3-B0BC-7110BA15943C}"
1313
EndProject
14+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Twilio.Benchmark", "test\Twilio.Benchmark\Twilio.Benchmark.csproj", "{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}"
15+
EndProject
1416
Global
1517
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1618
Debug|Any CPU = Debug|Any CPU
@@ -45,13 +47,26 @@ Global
4547
{DC35107A-F987-47A3-B0BC-7110BA15943C}.Release|x64.Build.0 = Release|Any CPU
4648
{DC35107A-F987-47A3-B0BC-7110BA15943C}.Release|x86.ActiveCfg = Release|Any CPU
4749
{DC35107A-F987-47A3-B0BC-7110BA15943C}.Release|x86.Build.0 = Release|Any CPU
50+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
51+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|Any CPU.Build.0 = Debug|Any CPU
52+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|x64.ActiveCfg = Debug|Any CPU
53+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|x64.Build.0 = Debug|Any CPU
54+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|x86.ActiveCfg = Debug|Any CPU
55+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Debug|x86.Build.0 = Debug|Any CPU
56+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|Any CPU.ActiveCfg = Release|Any CPU
57+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|Any CPU.Build.0 = Release|Any CPU
58+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|x64.ActiveCfg = Release|Any CPU
59+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|x64.Build.0 = Release|Any CPU
60+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|x86.ActiveCfg = Release|Any CPU
61+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1}.Release|x86.Build.0 = Release|Any CPU
4862
EndGlobalSection
4963
GlobalSection(SolutionProperties) = preSolution
5064
HideSolutionNode = FALSE
5165
EndGlobalSection
5266
GlobalSection(NestedProjects) = preSolution
5367
{62BB8FE9-99DD-475D-80EB-D2E53C380754} = {36585F38-8C30-49A9-BDA1-9A0DC61C288B}
5468
{DC35107A-F987-47A3-B0BC-7110BA15943C} = {FE04FB2E-73FB-4E45-AAEE-EE04754A5E9C}
69+
{80DEE3E3-5F8E-40C2-A68C-15463C4CFAB1} = {FE04FB2E-73FB-4E45-AAEE-EE04754A5E9C}
5570
EndGlobalSection
5671
GlobalSection(ExtensibilityGlobals) = postSolution
5772
SolutionGuid = {75638FC3-0E0B-4D79-8BEB-8CC499BF98C5}

src/Twilio/Security/RequestValidator.cs

+94-43
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Linq;
23
using System.Collections.Generic;
34
using System.Collections.Specialized;
45
using System.Security.Cryptography;
@@ -45,16 +46,63 @@ public bool Validate(string url, NameValueCollection parameters, string expected
4546
/// <returns>true if the signature matches the result; false otherwise</returns>
4647
public bool Validate(string url, IDictionary<string, string> parameters, string expected)
4748
{
48-
// check signature of url with and without port, since sig generation on back end is inconsistent
49-
var signatureWithoutPort = GetValidationSignature(RemovePort(url), parameters);
50-
var signatureWithPort = GetValidationSignature(AddPort(url), parameters);
51-
// If either url produces a valid signature, we accept the request as valid
52-
return SecureCompare(signatureWithoutPort, expected) || SecureCompare(signatureWithPort, expected);
53-
}
54-
49+
if (string.IsNullOrEmpty(url))
50+
throw new ArgumentException("Parameter 'url' cannot be null or empty.");
51+
if (string.IsNullOrEmpty(expected))
52+
throw new ArgumentException("Parameter 'expected' cannot be null or empty.");
53+
54+
if(parameters == null || parameters.Count == 0)
55+
{
56+
var signature = GetValidationSignature(url);
57+
if (SecureCompare(signature, expected)) return true;
58+
59+
// check signature of url with and without port, since sig generation on back end is inconsistent
60+
// If either url produces a valid signature, we accept the request as valid
61+
url = GetUriVariation(url);
62+
signature = GetValidationSignature(url);
63+
if (SecureCompare(signature, expected)) return true;
64+
return false;
65+
}
66+
else
67+
{
68+
var parameterStringBuilder = GetJoinedParametersStringBuilder(parameters);
69+
parameterStringBuilder.Insert(0, url);
70+
var signature = GetValidationSignature(parameterStringBuilder.ToString());
71+
if (SecureCompare(signature, expected)) return true;
72+
parameterStringBuilder.Remove(0, url.Length);
73+
74+
// check signature of url with and without port, since sig generation on back end is inconsistent
75+
// If either url produces a valid signature, we accept the request as valid
76+
url = GetUriVariation(url);
77+
parameterStringBuilder.Insert(0, url);
78+
signature = GetValidationSignature(parameterStringBuilder.ToString());
79+
if (SecureCompare(signature, expected)) return true;
80+
81+
return false;
82+
}
83+
}
84+
85+
private StringBuilder GetJoinedParametersStringBuilder(IDictionary<string, string> parameters)
86+
{
87+
var keys = parameters.Keys.ToArray();
88+
Array.Sort(keys, StringComparer.Ordinal);
89+
90+
var b = new StringBuilder();
91+
foreach (var key in keys)
92+
{
93+
b.Append(key).Append(parameters[key] ?? "");
94+
}
95+
return b;
96+
}
97+
5598
public bool Validate(string url, string body, string expected)
5699
{
57-
var paramString = new UriBuilder(url).Query.TrimStart('?');
100+
if (string.IsNullOrEmpty(url))
101+
throw new ArgumentException("Parameter 'url' cannot be null or empty.");
102+
if (string.IsNullOrEmpty(expected))
103+
throw new ArgumentException("Parameter 'expected' cannot be null or empty.");
104+
105+
var paramString = new Uri(url, UriKind.Absolute).Query.TrimStart('?');
58106
var bodyHash = "";
59107
foreach (var param in paramString.Split('&'))
60108
{
@@ -65,13 +113,13 @@ public bool Validate(string url, string body, string expected)
65113
}
66114
}
67115

68-
return Validate(url, new Dictionary<string, string>(), expected) && ValidateBody(body, bodyHash);
116+
return Validate(url, (IDictionary<string, string>) null, expected) && ValidateBody(body, bodyHash);
69117
}
70118

71119
public bool ValidateBody(string rawBody, string expected)
72120
{
73121
var signature = _sha.ComputeHash(Encoding.UTF8.GetBytes(rawBody));
74-
return SecureCompare(BitConverter.ToString(signature).Replace("-","").ToLower(), expected);
122+
return SecureCompare(BitConverter.ToString(signature).Replace("-", "").ToLower(), expected);
75123
}
76124

77125
private static IDictionary<string, string> ToDictionary(NameValueCollection col)
@@ -81,27 +129,16 @@ private static IDictionary<string, string> ToDictionary(NameValueCollection col)
81129
{
82130
dict.Add(k, col[k]);
83131
}
132+
84133
return dict;
85134
}
86135

87-
private string GetValidationSignature(string url, IDictionary<string, string> parameters)
136+
private string GetValidationSignature(string urlWithParameters)
88137
{
89-
var b = new StringBuilder(url);
90-
if (parameters != null)
91-
{
92-
var sortedKeys = new List<string>(parameters.Keys);
93-
sortedKeys.Sort(StringComparer.Ordinal);
94-
95-
foreach (var key in sortedKeys)
96-
{
97-
b.Append(key).Append(parameters[key] ?? "");
98-
}
99-
}
100-
101-
var hash = _hmac.ComputeHash(Encoding.UTF8.GetBytes(b.ToString()));
138+
byte[] hash = _hmac.ComputeHash(Encoding.UTF8.GetBytes(urlWithParameters));
102139
return Convert.ToBase64String(hash);
103-
}
104-
140+
}
141+
105142
private static bool SecureCompare(string a, string b)
106143
{
107144
if (a == null || b == null)
@@ -124,41 +161,55 @@ private static bool SecureCompare(string a, string b)
124161
return mismatch == 0;
125162
}
126163

127-
private string RemovePort(string url)
164+
/// <summary>
165+
/// Returns URL without port if given URL has port, returns URL with port if given URL has no port
166+
/// </summary>
167+
/// <param name="url"></param>
168+
/// <returns></returns>
169+
private string GetUriVariation(string url)
128170
{
129-
return SetPort(url, -1);
130-
}
171+
var uri = new Uri(url);
172+
var uriBuilder = new UriBuilder(uri);
173+
var port = uri.GetComponents(UriComponents.Port, UriFormat.UriEscaped);
174+
// if port already removed
175+
if (port == "")
176+
{
177+
return SetPort(url, uriBuilder, uriBuilder.Port);
178+
}
131179

132-
private string AddPort(string url)
133-
{
134-
var uri = new UriBuilder(url);
135-
return SetPort(url, uri.Port);
180+
return SetPort(url, uriBuilder, -1);
136181
}
137182

138-
private string SetPort(string url, int port)
183+
private string SetPort(string url, UriBuilder uri, int newPort)
139184
{
140-
var uri = new UriBuilder(url);
141-
uri.Host = PreserveCase(url, uri.Host);
142-
if (port == -1)
185+
if (newPort == -1)
143186
{
144-
uri.Port = port;
187+
uri.Port = newPort;
145188
}
146-
else if ((port != 443) && (port != 80))
189+
else if (newPort != 443 && newPort != 80)
147190
{
148-
uri.Port = port;
191+
uri.Port = newPort;
149192
}
150193
else
151194
{
152195
uri.Port = uri.Scheme == "https" ? 443 : 80;
153196
}
197+
198+
var uriStringBuilder = new StringBuilder(uri.ToString());
199+
200+
var host = PreserveCase(url, uri.Host);
201+
uriStringBuilder.Replace(uri.Host, host);
202+
154203
var scheme = PreserveCase(url, uri.Scheme);
155-
return uri.Uri.OriginalString.Replace(uri.Scheme, scheme);
156-
}
204+
uriStringBuilder.Replace(uri.Scheme, scheme);
157205

206+
return uriStringBuilder.ToString();
207+
}
208+
158209
private string PreserveCase(string url, string replacementString)
159210
{
160211
var startIndex = url.IndexOf(replacementString, StringComparison.OrdinalIgnoreCase);
161212
return url.Substring(startIndex, replacementString.Length);
162213
}
163214
}
164-
}
215+
}

test/Twilio.Benchmark/Program.cs

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Collections.Specialized;
2+
using BenchmarkDotNet.Attributes;
3+
using BenchmarkDotNet.Running;
4+
using Twilio.Security;
5+
6+
var summary = BenchmarkRunner.Run<RequestValidationBenchmark>();
7+
Console.Write(summary);
8+
9+
[MemoryDiagnoser]
10+
public class RequestValidationBenchmark
11+
{
12+
private const string Secret = "12345";
13+
private const string UnhappyPathUrl = "HTTP://MyCompany.com:8080/myapp.php?foo=1&bar=2";
14+
private const string UnhappyPathSignature = "eYYN9fMlxrQMXOsr7bIzoPTrbxA=";
15+
private const string HappyPathUrl = "https://mycompany.com/myapp.php?foo=1&bar=2";
16+
private const string HappyPathSignature = "3LL3BFKOcn80artVM5inMPFpmtU=";
17+
private static readonly NameValueCollection UnhappyPathParameters = new()
18+
{
19+
{"ToCountry", "US"},
20+
{"ToState", "OH"},
21+
{"SmsMessageSid", "SMcea2a3bd6f50296f8fab60f377db03eb"},
22+
{"NumMedia", "0"},
23+
{"ToCity", "UTICA"},
24+
{"FromZip", "20705"},
25+
{"SmsSid", "SMcea2a3bd6f50296f8fab60f377db03eb"},
26+
{"FromState", "DC"},
27+
{"SmsStatus", "received"},
28+
{"FromCity", "BELTSVILLE"},
29+
{"Body", "Ahoy!"},
30+
{"FromCountry", "US"},
31+
{"To", "+10123456789"},
32+
{"ToZip", "43037"},
33+
{"NumSegments", "1"},
34+
{"ReferralNumMedia", "0"},
35+
{"MessageSid", "SMcea2a3bd6f50296f8fab60f377db03eb"},
36+
{"AccountSid", "ACe718619887aac3ee5b21edafbvsdf6h7fgb"},
37+
{"From", "+10123456789"},
38+
{"ApiVersion", "2010-04-01"}
39+
};
40+
private static readonly Dictionary<string, string> HappyPathParameters = new()
41+
{
42+
{"ToCountry", "US"},
43+
{"ToState", "OH"},
44+
{"SmsMessageSid", "SMcea2a3bd6f50296f8fab60f377db03eb"},
45+
{"NumMedia", "0"},
46+
{"ToCity", "UTICA"},
47+
{"FromZip", "20705"},
48+
{"SmsSid", "SMcea2a3bd6f50296f8fab60f377db03eb"},
49+
{"FromState", "DC"},
50+
{"SmsStatus", "received"},
51+
{"FromCity", "BELTSVILLE"},
52+
{"Body", "Ahoy!"},
53+
{"FromCountry", "US"},
54+
{"To", "+10123456789"},
55+
{"ToZip", "43037"},
56+
{"NumSegments", "1"},
57+
{"ReferralNumMedia", "0"},
58+
{"MessageSid", "SMcea2a3bd6f50296f8fab60f377db03eb"},
59+
{"AccountSid", "ACe718619887aac3ee5b21edafbvsdf6h7fgb"},
60+
{"From", "+10123456789"},
61+
{"ApiVersion", "2010-04-01"}
62+
};
63+
64+
65+
[Benchmark]
66+
public void OriginalUnhappyPath()
67+
{
68+
var requestValidator = new RequestValidatorOriginal(Secret);
69+
requestValidator.Validate(UnhappyPathUrl, UnhappyPathParameters, UnhappyPathSignature);
70+
}
71+
72+
[Benchmark]
73+
public void CurrentUnhappyPath()
74+
{
75+
var requestValidator = new RequestValidator(Secret);
76+
requestValidator.Validate(UnhappyPathUrl, UnhappyPathParameters, UnhappyPathSignature);
77+
}
78+
79+
[Benchmark]
80+
public void OriginalHappyPath()
81+
{
82+
var requestValidator = new RequestValidatorOriginal(Secret);
83+
requestValidator.Validate(HappyPathUrl, HappyPathParameters, HappyPathSignature);
84+
}
85+
86+
[Benchmark]
87+
public void CurrentHappyPath()
88+
{
89+
var requestValidator = new RequestValidator(Secret);
90+
requestValidator.Validate(HappyPathUrl, HappyPathParameters, HappyPathSignature);
91+
}
92+
}

0 commit comments

Comments
 (0)