Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Twilio Signature Validation #96

Merged
merged 7 commits into from
Jul 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
})
}