diff options
Diffstat (limited to 'pkg')
| -rw-r--r-- | pkg/twilio/filter.go | 74 | ||||
| -rw-r--r-- | pkg/twilio/filter_test.go | 114 | ||||
| -rw-r--r-- | pkg/twilio/handler.go | 4 | ||||
| -rw-r--r-- | pkg/twilio/sms.go | 34 | ||||
| -rw-r--r-- | pkg/twilio/util.go | 16 |
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/>") +}) |
