From 8d8fab87d5c1a8c3a3a0b1792b665efd4be588d0 Mon Sep 17 00:00:00 2001
From: Pablo Canseco
Date: Mon, 19 Jul 2021 11:20:15 -0600
Subject: [PATCH] feat: Twilio Signature Validation (#96)
Co-authored-by: Elise Shanholtz
---
client/request_validator.go | 147 +++++++++++++++++++++++++++++++
client/request_validator_test.go | 82 +++++++++++++++++
2 files changed, 229 insertions(+)
create mode 100644 client/request_validator.go
create mode 100644 client/request_validator_test.go
diff --git a/client/request_validator.go b/client/request_validator.go
new file mode 100644
index 000000000..d4d75de38
--- /dev/null
+++ b/client/request_validator.go
@@ -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()
+}
diff --git a/client/request_validator_test.go b/client/request_validator_test.go
new file mode 100644
index 000000000..6efacd940
--- /dev/null
+++ b/client/request_validator_test.go
@@ -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))
+ })
+}