aboutsummaryrefslogtreecommitdiff
path: root/pkg
diff options
context:
space:
mode:
authorGrégoire Duchêne <gduchene@awhk.org>2021-03-14 15:41:22 +0000
committerGrégoire Duchêne <gduchene@awhk.org>2021-03-14 15:41:22 +0000
commitce3182d4c3f5fb723a16bece3157a5058b1236f9 (patch)
tree858301133dd6a547e935da356f049ac165261c06 /pkg
parentd10731043bfd7429ebd22c717794c8363f462caf (diff)
Move Twilio authn code into its own package
Diffstat (limited to 'pkg')
-rw-r--r--pkg/twilio/filter.go74
-rw-r--r--pkg/twilio/filter_test.go114
-rw-r--r--pkg/twilio/handler.go4
-rw-r--r--pkg/twilio/sms.go34
-rw-r--r--pkg/twilio/util.go16
5 files changed, 242 insertions, 0 deletions
diff --git a/pkg/twilio/filter.go b/pkg/twilio/filter.go
new file mode 100644
index 0000000..7d5f6b5
--- /dev/null
+++ b/pkg/twilio/filter.go
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: © 2021 Grégoire Duchêne <gduchene@awhk.org>
+// SPDX-License-Identifier: ISC
+
+package twilio
+
+import (
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/base64"
+ "errors"
+ "log"
+ "net/http"
+ "sort"
+ "strings"
+)
+
+var (
+ ErrBase64 = errors.New("failed to decode X-Twilio-Signature header")
+ ErrMissingHeader = errors.New("missing X-Twilio-Signature header")
+ ErrSignatureMismatch = errors.New("signature mismatch")
+)
+
+type Filter struct {
+ AuthToken []byte
+ Handler http.Handler
+}
+
+var _ http.Handler = &Filter{}
+
+func (th *Filter) CheckRequestSignature(r *http.Request) error {
+ hdr := r.Header.Get("X-Twilio-Signature")
+ if len(hdr) == 0 {
+ return ErrMissingHeader
+ }
+ reqSig, err := base64.StdEncoding.DecodeString(hdr)
+ if err != nil {
+ return ErrBase64
+ }
+
+ // See https://www.twilio.com/docs/usage/security#validating-requests
+ // for more details.
+
+ parts := []string{}
+ if r.Method == http.MethodPost {
+ if err := r.ParseForm(); err != nil {
+ return err
+ }
+ for k := range r.PostForm {
+ parts = append(parts, k)
+ }
+ sort.Strings(parts)
+ for i, k := range parts {
+ parts[i] += r.PostForm[k][0]
+ }
+ }
+ s := r.URL.String() + strings.Join(parts, "")
+ h := hmac.New(sha1.New, th.AuthToken)
+ h.Write([]byte(s))
+ ourSig := h.Sum(nil)
+
+ if !hmac.Equal(reqSig, ourSig) {
+ return ErrSignatureMismatch
+ }
+ return nil
+}
+
+func (th *Filter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if err := th.CheckRequestSignature(r); err != nil {
+ log.Println("Failed to check Twilio signature:", err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ th.Handler.ServeHTTP(w, r)
+}
diff --git a/pkg/twilio/filter_test.go b/pkg/twilio/filter_test.go
new file mode 100644
index 0000000..c0c737c
--- /dev/null
+++ b/pkg/twilio/filter_test.go
@@ -0,0 +1,114 @@
+// SPDX-FileCopyrightText: © 2021 Grégoire Duchêne <gduchene@awhk.org>
+// SPDX-License-Identifier: ISC
+
+package twilio
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFilter_CheckRequestSignature(t *testing.T) {
+ th := &Filter{[]byte("token"), EmptyResponseHandler}
+
+ t.Run("Good Signature (POST)", func(t *testing.T) {
+ assert.NoError(t, th.CheckRequestSignature(newRequest(Post)))
+ })
+
+ t.Run("Good Signature (GET)", func(t *testing.T) {
+ assert.NoError(t, th.CheckRequestSignature(newRequest(Get)))
+ })
+
+ t.Run("Missing Header", func(t *testing.T) {
+ r := newRequest(Post)
+ r.Header.Del("X-Twilio-Signature")
+ assert.ErrorIs(t, th.CheckRequestSignature(r), ErrMissingHeader)
+ })
+
+ t.Run("Bad Base64", func(t *testing.T) {
+ r := newRequest(Post)
+ r.Header.Set("X-Twilio-Signature", "Very suspicious Base64 header.")
+ assert.ErrorIs(t, th.CheckRequestSignature(r), ErrBase64)
+ })
+
+ t.Run("Signature Mismatch", func(t *testing.T) {
+ r := newRequest(Post)
+ r.Header.Set("X-Twilio-Signature", "dpE7iSS3LEQo72hCT34eBRt3UEI=")
+ assert.ErrorIs(t, th.CheckRequestSignature(r), ErrSignatureMismatch)
+ })
+}
+
+func TestFilter_ServeHTTP(t *testing.T) {
+ th := &Filter{[]byte("token"), EmptyResponseHandler}
+
+ t.Run("Good Signature (POST)", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ th.ServeHTTP(w, newRequest(Post))
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "text/xml", w.HeaderMap.Get("Content-Type"))
+ assert.Equal(t, "<Response/>", w.Body.String())
+ })
+
+ t.Run("Good Signature (GET)", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ th.ServeHTTP(w, newRequest(Get))
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "text/xml", w.HeaderMap.Get("Content-Type"))
+ assert.Equal(t, "<Response/>", w.Body.String())
+ })
+
+ t.Run("Missing Header", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := newRequest(Post)
+ r.Header.Del("X-Twilio-Signature")
+ th.ServeHTTP(w, r)
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ })
+
+ t.Run("Bad Base64", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := newRequest(Post)
+ r.Header.Set("X-Twilio-Signature", "Very suspicious Base64 header.")
+ th.ServeHTTP(w, r)
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ })
+
+ t.Run("Signature Mismatch", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := newRequest(Post)
+ r.Header.Set("X-Twilio-Signature", "dpE7iSS3LEQo72hCT34eBRt3UEI=")
+ th.ServeHTTP(w, r)
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ })
+}
+
+const (
+ Get = true
+ Post = false
+)
+
+// X-Twilio-Signature can be manually generated with:
+// % echo -n "${SOME_STRING}" | openssl dgst -binary -hmac ${AUTH_TOKEN} -sha1 | base64
+
+func newRequest(get bool) *http.Request {
+ vals := url.Values{
+ "To": {"Bob"},
+ "From": {"Alice"},
+ "Body": {"A random message."},
+ }.Encode()
+ if get {
+ r := httptest.NewRequest(http.MethodGet, "https://example.test/endpoint?"+vals, nil)
+ r.Header.Set("X-Twilio-Signature", "Hh0ReTk/+7Ea38qZ3Xt1/NQx4i4=")
+ return r
+ }
+ rd := strings.NewReader(vals)
+ r := httptest.NewRequest(http.MethodPost, "https://example.test/endpoint", rd)
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("X-Twilio-Signature", "j61PPnnoUAAsfEnLuwUefOfylf4=")
+ return r
+}
diff --git a/pkg/twilio/handler.go b/pkg/twilio/handler.go
new file mode 100644
index 0000000..11cb0b8
--- /dev/null
+++ b/pkg/twilio/handler.go
@@ -0,0 +1,4 @@
+// SPDX-FileCopyrightText: © 2021 Grégoire Duchêne <gduchene@awhk.org>
+// SPDX-License-Identifier: ISC
+
+package twilio
diff --git a/pkg/twilio/sms.go b/pkg/twilio/sms.go
new file mode 100644
index 0000000..0a7dba8
--- /dev/null
+++ b/pkg/twilio/sms.go
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: © 2021 Grégoire Duchêne <gduchene@awhk.org>
+// SPDX-License-Identifier: ISC
+
+package twilio
+
+import (
+ "net/http"
+ "time"
+)
+
+type SMS struct {
+ DateReceived time.Time
+ From, To, Body string
+}
+
+type SMSTee struct {
+ Chan chan<- SMS
+ Handler http.Handler
+}
+
+var _ http.Handler = &SMSTee{}
+
+func (th *SMSTee) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ select {
+ case th.Chan <- SMS{
+ DateReceived: time.Now(),
+ From: r.FormValue("From"),
+ To: r.FormValue("To"),
+ Body: r.FormValue("Body"),
+ }:
+ th.Handler.ServeHTTP(w, r)
+ case <-r.Context().Done():
+ }
+}
diff --git a/pkg/twilio/util.go b/pkg/twilio/util.go
new file mode 100644
index 0000000..1f1d269
--- /dev/null
+++ b/pkg/twilio/util.go
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: © 2021 Grégoire Duchêne <gduchene@awhk.org>
+// SPDX-License-Identifier: ISC
+
+package twilio
+
+import (
+ "fmt"
+ "net/http"
+)
+
+// EmptyResponseHandler writes an empty XML response so Twilio knows not
+// to do anything after a webhook has been called.
+var EmptyResponseHandler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/xml")
+ fmt.Fprint(w, "<Response/>")
+})