Skip to content

Commit

Permalink
feat: Twilio Signature Validation (#96)
Browse files Browse the repository at this point in the history
Co-authored-by: Elise Shanholtz <[email protected]>
  • Loading branch information
pmcanseco and eshanholtz authored Jul 19, 2021
1 parent 2d7bdd3 commit 8d8fab8
Show file tree
Hide file tree
Showing 2 changed files with 229 additions and 0 deletions.
147 changes: 147 additions & 0 deletions client/request_validator.go
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()
}
82 changes: 82 additions & 0 deletions client/request_validator_test.go
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))
})
}

0 comments on commit 8d8fab8

Please sign in to comment.