-
Notifications
You must be signed in to change notification settings - Fork 241
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add v3 webhook signature verification
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
1 parent
b7286a4
commit a1fc6be
Showing
4 changed files
with
236 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |