Skip to content

Commit

Permalink
Add v3 webhook signature verification
Browse files Browse the repository at this point in the history
This change is an extension of the PR raised by @ChezCrawford (#326), where the
requested changes have been applied to the branch and all commits have been
squashed.

This adds a new function to the package, VerifySignatureWebhookV3, which accepts
an *http.Request and a secret, and validates that the request is properly signed
using that secret. This function does return some sentinel error values so that
you can choose which HTTP status to send back to the caller.

Supersedes #326
  • Loading branch information
ChuckCrawford authored and theckman committed Oct 11, 2021
1 parent b7286a4 commit a1fc6be
Show file tree
Hide file tree
Showing 4 changed files with 236 additions and 0 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ if ok { // resp is an *http.Response we can inspect
}
```

#### Included Packages

##### webhookv3

Support for V3 of PagerDuty Webhooks is provided via the `webhookv3` package.
The intent is for this package to provide signature verification and decoding
helpers.

## Contributing

1. Fork it ( https://github.com/PagerDuty/go-pagerduty/fork )
Expand Down
39 changes: 39 additions & 0 deletions examples/webhooks/webhook_server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package main

import (
"fmt"
"log"
"net/http"

"github.com/PagerDuty/go-pagerduty/webhookv3"
)

const (
secret = "lDQHScfUeXUKaQRNF+8XIiDKZ7XX3itBAYzwU0TARw8lJqRnkKl2iB1anSb0Z+IK"
)

func main() {
http.HandleFunc("/webhook", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}

func handler(w http.ResponseWriter, r *http.Request) {
err := webhookv3.VerifySignature(r, secret)
if err != nil {
switch err {
case webhookv3.ErrWebhookNoValidSignatures:
w.WriteHeader(http.StatusUnauthorized)

case webhookv3.ErrWebhookMalformedBody, webhookv3.ErrWebhookMalformedHeader:
w.WriteHeader(http.StatusBadRequest)

default:
w.WriteHeader(http.StatusInternalServerError)
}

fmt.Fprintf(w, "%v", err)
return
}

fmt.Fprintf(w, "received signed webhook")
}
105 changes: 105 additions & 0 deletions webhookv3/webhookv3.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Package webhookv3 provides functionality for working with V3 PagerDuty
// Webhooks, including signature verification and decoding.
package webhookv3

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
)

// ErrWebhookNoValidSignatures is returned when a webhook is not properly signed
// with the expected signature. When receiving this error, it is reccommended
// that the server return HTTP 403 to prevent redelivery.
var ErrWebhookNoValidSignatures = errors.New("invalid webhook signature")

// ErrWebhookMalformedHeader is returned when the *http.Request is missing the
// X-PagerDuty-Signature header. When receiving this error, it is recommended
// that the server return HTTP 400 to prevent redelivery.
var ErrWebhookMalformedHeader = errors.New("X-PagerDuty-Signature header is either missing or malformed")

// ErrWebhookMalformedBody is returned when the *http.Request body is either
// missing or malformed. When receiving this error, it's recommended that the
// server return HTTP 400 to prevent redelivery.
var ErrWebhookMalformedBody = errors.New("HTTP request body is either empty or malformed")

const (
webhookSignaturePrefix = "v1="
webhookSignatureHeader = "X-PagerDuty-Signature"
webhookBodyReaderLimit = 10 * 1024 * 1024 // 10MB
)

// VerifySignature compares the provided signature of a PagerDuty v3 Webhook
// against the expected value and returns an error if the values do not match.
//
// See https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTkz-verifying-signatures for more details.
//
// This function will fail to read any HTTP request body that's 10MB or larger.
func VerifySignature(r *http.Request, secret string) error {
h := r.Header.Get(webhookSignatureHeader)
if len(h) == 0 {
return ErrWebhookMalformedHeader
}

orb := r.Body

b, err := ioutil.ReadAll(io.LimitReader(r.Body, webhookBodyReaderLimit))
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}

r.Body = ioutil.NopCloser(bytes.NewReader(b))
defer func() { _ = orb.Close() }()

if len(b) == 0 {
return ErrWebhookMalformedBody
}

sigs := extractPayloadSignatures(h)
if len(sigs) == 0 {
return ErrWebhookMalformedHeader
}

s := calculateSignature(b, secret)

for _, sig := range sigs {
if hmac.Equal(s, sig) {
return nil
}
}

return ErrWebhookNoValidSignatures
}

func extractPayloadSignatures(s string) [][]byte {
var sigs [][]byte

for _, sv := range strings.Split(s, ",") {
// Ignore any signatures that are not the initial v1 version.
if !strings.HasPrefix(sv, webhookSignaturePrefix) {
continue
}

sig, err := hex.DecodeString(strings.TrimPrefix(sv, webhookSignaturePrefix))
if err != nil {
continue
}

sigs = append(sigs, sig)
}

return sigs
}

func calculateSignature(payload []byte, secret string) []byte {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
return mac.Sum(nil)
}
84 changes: 84 additions & 0 deletions webhookv3/webhookv3_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package webhookv3

import (
"errors"
"net/http"
"strings"
"testing"
)

const (
secret = "lDQHScfUeXUKaQRNF+8XIiDKZ7XX3itBAYzwU0TARw8lJqRnkKl2iB1anSb0Z+IK" /* #nosec */
defaultBody = `{"event":{"id":"01BWDWL3NYY7LUFPZCC28QUCMK","event_type":"incident.priority_updated","resource_type":"incident","occurred_at":"2021-04-26T17:36:27.458Z","agent":{"html_url":"https://acme.pagerduty.com/users/PLH1HKV","id":"PLH1HKV","self":"https://api.pagerduty.com/users/PLH1HKV","summary":"Tenex Engineer","type":"user_reference"},"client":null,"data":{"id":"PGR0VU2","type":"incident","self":"https://api.pagerduty.com/incidents/PGR0VU2","html_url":"https://acme.pagerduty.com/incidents/PGR0VU2","number":2,"status":"triggered","title":"A little bump in the road","service":{"html_url":"https://acme.pagerduty.com/services/PF9KMXH","id":"PF9KMXH","self":"https://api.pagerduty.com/services/PF9KMXH","summary":"API Service","type":"service_reference"},"assignees":[{"html_url":"https://acme.pagerduty.com/users/PTUXL6G","id":"PTUXL6G","self":"https://api.pagerduty.com/users/PTUXL6G","summary":"User 123","type":"user_reference"}],"escalation_policy":{"html_url":"https://acme.pagerduty.com/escalation_policies/PUS0KTE","id":"PUS0KTE","self":"https://api.pagerduty.com/escalation_policies/PUS0KTE","summary":"Default","type":"escalation_policy_reference"},"teams":[{"html_url":"https://acme.pagerduty.com/teams/PFCVPS0","id":"PFCVPS0","self":"https://api.pagerduty.com/teams/PFCVPS0","summary":"Engineering","type":"team_reference"}],"priority":{"html_url":"https://acme.pagerduty.com/account/incident_priorities","id":"PSO75BM","self":"https://api.pagerduty.com/priorities/PSO75BM","summary":"P1","type":"priority_reference"},"urgency":"high","conference_bridge":{"conference_number":1000,"conference_url":"https://example.com"},"resolve_reason":null}}}`
)

func TestVerifySignature(t *testing.T) {
tests := []struct {
name string
sig string
body string
err error
}{
{
name: "valid",
sig: "v1=0c0b9495b893a39e70d1fea2fe11fbe0a825f88b9f67846f6cc07dd2bc5476cd",
body: defaultBody,
},
{
name: "mismatch",
sig: "v1=7020c8a7ec668a9b7012bc3dd82e483394b038f4230acc6785efbf2a7d8bcaf5",
body: defaultBody,
err: ErrWebhookNoValidSignatures,
},
{
name: "malformed_header",
body: defaultBody,
err: ErrWebhookMalformedHeader,
},
{
name: "malformed_body",
sig: "v1=0c0b9495b893a39e70d1fea2fe11fbe0a825f88b9f67846f6cc07dd2bc5476cd",
err: ErrWebhookMalformedBody,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:80/test", strings.NewReader(tt.body))
if err != nil {
t.Fatalf("failed to generate new request: %s", err.Error())
}

req.Header.Set(webhookSignatureHeader, tt.sig)

testErrIs(t, "VerifySignature", tt.err, VerifySignature(req, secret))
})
}
}

// testErrIs looks to see if wantErr is gotErr. If not, this calls t.Fatal(). It
// also calls t.Fatal() if there gotErr is not nil, but wantErr is. Returns true
// if you should continue running the test, or false if you should stop the
// test.
func testErrIs(t *testing.T, name string, wantErr, gotErr error) bool {
t.Helper()

if wantErr != nil {
if gotErr == nil {
t.Fatalf("%s error = <nil>, should be %v", name, wantErr)
return false
}

if !errors.Is(gotErr, wantErr) {
t.Fatalf("error %v is not %v", gotErr, wantErr)
return false
}
}

if wantErr == nil && gotErr != nil {
t.Fatalf("%s unexpected error: %v", name, gotErr)
return false
}

return true
}

0 comments on commit a1fc6be

Please sign in to comment.