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)) + }) +}