-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathwebhook.go
More file actions
163 lines (148 loc) · 5.58 KB
/
Copy pathwebhook.go
File metadata and controls
163 lines (148 loc) · 5.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
package stream_chat
import (
"bytes"
"compress/gzip"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
)
// ErrInvalidWebhook is the sentinel wrapped by every malformed-webhook /
// verification error from this package so callers can use errors.Is with a single check.
var ErrInvalidWebhook = errors.New("invalid webhook")
var gzipMagic = []byte{0x1f, 0x8b}
// GunzipPayload returns body unchanged unless the first two bytes are
// the gzip magic (1f 8b, per RFC 1952), in which case the gzip stream
// is inflated and the decompressed bytes are returned.
//
// Magic-byte detection lets the same handler stay correct when
// middleware auto-decompresses the request before your code sees it.
func GunzipPayload(body []byte) ([]byte, error) {
if len(body) < 2 || !bytes.Equal(body[:2], gzipMagic) {
return body, nil
}
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("gzip decompression failed: %w", ErrInvalidWebhook)
}
out, err := io.ReadAll(zr)
if err != nil {
_ = zr.Close()
return nil, fmt.Errorf("gzip decompression failed: %w", ErrInvalidWebhook)
}
if err := zr.Close(); err != nil {
return nil, fmt.Errorf("gzip decompression failed: %w", ErrInvalidWebhook)
}
return out, nil
}
// DecodeSqsPayload reverses the SQS firehose envelope: the message Body
// is base64-decoded and, when the result begins with the gzip magic, it
// is gzip-decompressed. The same call works whether or not Stream is
// currently compressing payloads.
func DecodeSqsPayload(body string) ([]byte, error) {
decoded, err := base64.StdEncoding.DecodeString(body)
if err != nil {
return nil, fmt.Errorf("invalid base64 encoding: %w", ErrInvalidWebhook)
}
return GunzipPayload(decoded)
}
// DecodeSnsPayload reverses an SNS HTTP notification envelope: when the
// input is a JSON envelope ({"Type":"Notification","Message":"..."}),
// the inner Message field is extracted and run through the SQS pipeline
// (base64-decode, then gzip-if-magic). When the input is not a JSON
// envelope it is treated as the already-extracted Message string, so
// existing call sites that pre-unwrap continue to work.
func DecodeSnsPayload(notificationBody string) ([]byte, error) {
if msg, ok := extractSnsMessage(notificationBody); ok {
return DecodeSqsPayload(msg)
}
return DecodeSqsPayload(notificationBody)
}
// extractSnsMessage returns the inner Message field from an SNS HTTP
// notification envelope. The ok result is false when input is not a JSON
// object with a string Message field.
func extractSnsMessage(notificationBody string) (string, bool) {
trimmed := bytes.TrimLeft([]byte(notificationBody), " \t\r\n")
if len(trimmed) == 0 || trimmed[0] != '{' {
return "", false
}
var envelope struct {
Message *string `json:"Message"`
}
if err := json.Unmarshal(trimmed, &envelope); err != nil {
return "", false
}
if envelope.Message == nil {
return "", false
}
return *envelope.Message, true
}
func signatureMatch(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(body)
expected := []byte(hex.EncodeToString(mac.Sum(nil)))
return len(signature) == len(expected) && hmac.Equal(expected, []byte(signature))
}
// VerifySignature compares the hex-encoded HMAC-SHA256 of body (using secret as the key)
// to signature with a constant-time comparison. It returns nil on match.
// On mismatch it returns an error wrapping ErrInvalidWebhook (message contains
// "signature mismatch"). The digest is always over uncompressed JSON bytes.
func VerifySignature(body []byte, signature, secret string) error {
if !signatureMatch(body, signature, secret) {
return fmt.Errorf("signature mismatch: %w", ErrInvalidWebhook)
}
return nil
}
// ParseEvent decodes the JSON-encoded webhook payload into a typed
// Event. Unknown event types still parse successfully because Event.Type
// is a string alias.
func ParseEvent(payload []byte) (*Event, error) {
var ev Event
if err := json.Unmarshal(payload, &ev); err != nil {
return nil, fmt.Errorf("invalid JSON payload: %w", ErrInvalidWebhook)
}
return &ev, nil
}
func verifyAndParse(payload []byte, signature, secret string) (*Event, error) {
if err := VerifySignature(payload, signature, secret); err != nil {
return nil, err
}
return ParseEvent(payload)
}
// VerifyAndParseWebhook decompresses body when gzipped, verifies the
// HMAC signature against secret, and returns the parsed Event.
func VerifyAndParseWebhook(body []byte, signature, secret string) (*Event, error) {
inflated, err := GunzipPayload(body)
if err != nil {
return nil, err
}
return verifyAndParse(inflated, signature, secret)
}
// ParseSqs decodes the SQS message Body and returns the parsed Event.
// Stream does not attach an HMAC to SQS deliveries; use VerifyAndParseWebhook for HTTP.
func ParseSqs(messageBody string) (*Event, error) {
inflated, err := DecodeSqsPayload(messageBody)
if err != nil {
return nil, err
}
return ParseEvent(inflated)
}
// ParseSns decodes an SNS-delivered payload (unwraps SNS envelope when
// present), then parses the inner JSON Event. No HMAC verification.
func ParseSns(message string) (*Event, error) {
inflated, err := DecodeSnsPayload(message)
if err != nil {
return nil, err
}
return ParseEvent(inflated)
}
// VerifyAndParseWebhook is the client-bound form of the package-level
// helper; it pulls the API secret from the receiver so call sites only
// supply the request body and signature.
func (c *Client) VerifyAndParseWebhook(body []byte, signature string) (*Event, error) {
return VerifyAndParseWebhook(body, signature, string(c.apiSecret))
}