-
Notifications
You must be signed in to change notification settings - Fork 41
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Twilio Signature Validation (#96)
Co-authored-by: Elise Shanholtz <[email protected]>
- Loading branch information
1 parent
2d7bdd3
commit 8d8fab8
Showing
2 changed files
with
229 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
package client | ||
|
||
import ( | ||
"crypto/hmac" | ||
"crypto/sha1" | ||
"crypto/sha256" | ||
"crypto/subtle" | ||
"encoding/base64" | ||
"fmt" | ||
urllib "net/url" | ||
"sort" | ||
"strings" | ||
) | ||
|
||
// RequestValidator is used to verify the Twilio Signature included with Twilio requests to webhooks. | ||
// This ensures the request is actually coming from Twilio and helps with securing your webhooks. | ||
type RequestValidator struct { | ||
signingKey []byte | ||
} | ||
|
||
// NewRequestValidator returns a new RequestValidator which uses the specified auth token when verifying | ||
// Twilio signatures. | ||
func NewRequestValidator(authToken string) RequestValidator { | ||
return RequestValidator{ | ||
signingKey: []byte(authToken), | ||
} | ||
} | ||
|
||
// Validate can be used for Twilio Signatures sent with webhooks configured for GET calls. It returns true | ||
// if the computed signature matches the expectedSignature. Params are a map of string to string containing | ||
// all the query params Twilio added to the configured webhook URL. | ||
func (rv *RequestValidator) Validate(url string, params map[string]string, expectedSignature string) bool { | ||
// turn the keys and values of the query params into a concatenated string which we will then sort | ||
var paramSlc []string | ||
for k, v := range params { | ||
paramSlc = append(paramSlc, fmt.Sprintf("%s%s", k, v)) | ||
} | ||
sort.Strings(paramSlc) | ||
|
||
// check signature of testURL with and without port, since sig generation on back-end is inconsistent | ||
signatureWithPort := rv.getValidationSignature(addPort(url), paramSlc) | ||
signatureWithoutPort := rv.getValidationSignature(removePort(url), paramSlc) | ||
return compare(signatureWithPort, expectedSignature) || | ||
compare(signatureWithoutPort, expectedSignature) | ||
} | ||
|
||
// ValidateBody can be used for Twilio Signatures sent with webhooks configured for POST calls. It returns true | ||
// if the computed signature matches the expectedSignature. Body is the HTTP request body from the webhook call | ||
// as a slice of bytes. | ||
func (rv *RequestValidator) ValidateBody(url string, body []byte, expectedSignature string) bool { | ||
parsed, err := urllib.Parse(url) | ||
if err != nil { | ||
return false | ||
} | ||
|
||
bodySHA256 := parsed.Query().Get("bodySHA256") | ||
if len(bodySHA256) == 0 { | ||
return false | ||
} | ||
|
||
return rv.Validate(url, map[string]string{}, expectedSignature) && | ||
rv.validateBody(body, bodySHA256) | ||
} | ||
|
||
func compare(x, y string) bool { | ||
return subtle.ConstantTimeCompare([]byte(x), []byte(y)) == 1 | ||
} | ||
|
||
func (rv *RequestValidator) validateBody(body []byte, expectedSHA string) bool { | ||
if len(expectedSHA) == 0 { | ||
return true | ||
} | ||
|
||
hasher := sha256.New() | ||
_, err := hasher.Write(body) | ||
if err != nil { | ||
return false | ||
} | ||
sum := hasher.Sum(nil) | ||
return compare(fmt.Sprintf("%x", sum), expectedSHA) | ||
} | ||
|
||
func (rv *RequestValidator) getValidationSignature(url string, sortedConcatenatedParams []string) string { | ||
for _, param := range sortedConcatenatedParams { | ||
url += param | ||
} | ||
|
||
h := hmac.New(sha1.New, rv.signingKey) | ||
_, err := h.Write([]byte(url)) | ||
if err != nil { | ||
return "" | ||
} | ||
sum := h.Sum(nil) | ||
return base64.StdEncoding.EncodeToString(sum) | ||
} | ||
|
||
func addPort(url string) string { | ||
parsed, err := urllib.Parse(url) | ||
if err != nil { | ||
return url | ||
} | ||
|
||
port := parsed.Port() | ||
if len(port) != 0 { | ||
return url // url already has port | ||
} | ||
|
||
if parsed.Scheme == "https" { | ||
return updatePort(url, 443) | ||
} | ||
return updatePort(url, 80) | ||
} | ||
|
||
func updatePort(url string, newPort int) string { | ||
parsed, err := urllib.Parse(url) | ||
if err != nil { | ||
return url | ||
} | ||
|
||
var newHost string | ||
if len(parsed.Port()) == 0 { | ||
// url didn't already have port, add it | ||
newHost = fmt.Sprintf("%s:%d", parsed.Host, newPort) | ||
} else { | ||
// url already had port, grab just the host and add new port | ||
oldHost := strings.Split(parsed.Host, ":")[0] | ||
newHost = fmt.Sprintf("%s:%d", oldHost, newPort) | ||
} | ||
|
||
parsed.Host = newHost | ||
return parsed.String() | ||
} | ||
|
||
func removePort(url string) string { | ||
parsed, err := urllib.Parse(url) | ||
if err != nil { | ||
return url | ||
} | ||
|
||
if len(parsed.Port()) == 0 { | ||
return url | ||
} | ||
|
||
newHost := strings.Split(parsed.Host, ":")[0] | ||
parsed.Host = newHost | ||
return parsed.String() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
package client | ||
|
||
import ( | ||
"strings" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
const ( | ||
testURL = "https://mycompany.com/myapp.php?foo=1&bar=2" | ||
signature = "RSOYDt4T1cUTdK1PDd93/VVr8B8=" // of the testURL above with the params below | ||
bodyHash = "0a1ff7634d9ab3b95db5c9a2dfe9416e41502b283a80c7cf19632632f96e6620" | ||
) | ||
|
||
var ( | ||
validator = NewRequestValidator("12345") | ||
params = map[string]string{ | ||
"Digits": "1234", | ||
"CallSid": "CA1234567890ABCDE", | ||
"To": "+18005551212", | ||
"Caller": "+14158675309", | ||
"From": "+14158675309", | ||
} | ||
body = []byte(`{"property": "value", "boolean": true}`) | ||
) | ||
|
||
func TestRequestValidator_Validate(t *testing.T) { | ||
t.Parallel() | ||
|
||
t.Run("returns true when validation succeeds", func(t *testing.T) { | ||
assert.True(t, validator.Validate(testURL, params, signature)) | ||
}) | ||
|
||
t.Run("returns false when validation fails", func(t *testing.T) { | ||
assert.False(t, validator.Validate(testURL, params, "WRONG SIGNATURE")) | ||
}) | ||
|
||
t.Run("returns true when https and port is specified but signature is generated without it", func(t *testing.T) { | ||
theURL := strings.Replace(testURL, ".com", ".com:1234", 1) | ||
assert.True(t, validator.Validate(theURL, params, signature)) | ||
}) | ||
|
||
t.Run("returns true when https and port is specified and signature is generated with it", func(t *testing.T) { | ||
expectedSignature := "kvajT1Ptam85bY51eRf/AJRuM3w=" // hash of https uri without port | ||
assert.True(t, validator.Validate(testURL, params, expectedSignature)) | ||
}) | ||
|
||
t.Run("returns true when http and port port is specified but signature is generated without it", func(t *testing.T) { | ||
theURL := strings.Replace(testURL, ".com", ".com", 1) | ||
theURL = strings.Replace(theURL, "https", "http", 1) | ||
expectedSignature := "0ZXoZLH/DfblKGATFgpif+LLRf4=" // hash of http uri without port | ||
assert.True(t, validator.Validate(theURL, params, expectedSignature)) | ||
}) | ||
|
||
t.Run("returns true when http and port is specified and signature is generated with it", func(t *testing.T) { | ||
theURL := strings.Replace(testURL, ".com", ".com:1234", 1) | ||
theURL = strings.Replace(theURL, "https", "http", 1) | ||
expectedSignature := "Zmvh+3yNM1Phv2jhDCwEM3q5ebU=" // hash of http uri with port 1234 | ||
assert.True(t, validator.Validate(theURL, params, expectedSignature)) | ||
}) | ||
} | ||
|
||
func TestRequestValidator_ValidateBody(t *testing.T) { | ||
t.Parallel() | ||
|
||
t.Run("returns true when validation succeeds", func(t *testing.T) { | ||
theURL := testURL + "&bodySHA256=" + bodyHash | ||
signatureWithBodyHash := "a9nBmqA0ju/hNViExpshrM61xv4=" | ||
assert.True(t, validator.ValidateBody(theURL, body, signatureWithBodyHash)) | ||
}) | ||
|
||
t.Run("returns false when validation fails", func(t *testing.T) { | ||
assert.False(t, validator.ValidateBody(testURL, body, signature)) | ||
}) | ||
|
||
t.Run("returns true when there's no other parameters and the signature is right", func(t *testing.T) { | ||
theURL := "https://mycompany.com/myapp.php?bodySHA256=" + bodyHash | ||
signatureForURL := "y77kIzt2vzLz71DgmJGsen2scGs=" | ||
assert.True(t, validator.ValidateBody(theURL, body, signatureForURL)) | ||
}) | ||
} |