|
| 1 | +package bitbucket |
| 2 | + |
| 3 | +import ( |
| 4 | + "crypto/hmac" |
| 5 | + "crypto/sha1" |
| 6 | + "crypto/sha256" |
| 7 | + "crypto/sha512" |
| 8 | + "encoding/hex" |
| 9 | + "encoding/json" |
| 10 | + "errors" |
| 11 | + "fmt" |
| 12 | + "hash" |
| 13 | + "io/ioutil" |
| 14 | + "net/http" |
| 15 | + "net/url" |
| 16 | + "strings" |
| 17 | +) |
| 18 | + |
| 19 | +const ( |
| 20 | + // sha1Prefix is the prefix used by Bitbucket Server before the HMAC hexdigest. |
| 21 | + sha1Prefix = "sha1" |
| 22 | + // sha256Prefix and sha512Prefix are provided for future compatibility. |
| 23 | + sha256Prefix = "sha256" |
| 24 | + sha512Prefix = "sha512" |
| 25 | + // signatureHeader is the Bitbucket Server header key used to pass the HMAC hexdigest. |
| 26 | + signatureHeader = "X-Hub-Signature" |
| 27 | + // eventKeyHeader is the Bitbucket Server header key used to pass the event type. |
| 28 | + eventKeyHeader = "X-Event-Key" |
| 29 | + // requestIDHeader is the Bitbucket Server header key used to pass the unique ID for the webhook event. |
| 30 | + requestIDHeader = "X-Request-Id" |
| 31 | + // payloadFormParam is the name of the form parameter that the JSON payload |
| 32 | + // will be in if a webhook has its content type set to application/x-www-form-urlencoded. |
| 33 | + payloadFormParam = "payload" |
| 34 | +) |
| 35 | + |
| 36 | +// genMAC generates the HMAC signature for a message provided the secret key |
| 37 | +// and hashFunc. |
| 38 | +func genMAC(message, key []byte, hashFunc func() hash.Hash) []byte { |
| 39 | + mac := hmac.New(hashFunc, key) |
| 40 | + mac.Write(message) |
| 41 | + return mac.Sum(nil) |
| 42 | +} |
| 43 | + |
| 44 | +// checkMAC reports whether messageMAC is a valid HMAC tag for message. |
| 45 | +func checkMAC(message, messageMAC, key []byte, hashFunc func() hash.Hash) bool { |
| 46 | + expectedMAC := genMAC(message, key, hashFunc) |
| 47 | + return hmac.Equal(messageMAC, expectedMAC) |
| 48 | +} |
| 49 | + |
| 50 | +// messageMAC returns the hex-decoded HMAC tag from the signature and its |
| 51 | +// corresponding hash function. |
| 52 | +func messageMAC(signature string) ([]byte, func() hash.Hash, error) { |
| 53 | + if signature == "" { |
| 54 | + return nil, nil, errors.New("missing signature") |
| 55 | + } |
| 56 | + sigParts := strings.SplitN(signature, "=", 2) |
| 57 | + if len(sigParts) != 2 { |
| 58 | + return nil, nil, fmt.Errorf("error parsing signature %q", signature) |
| 59 | + } |
| 60 | + |
| 61 | + var hashFunc func() hash.Hash |
| 62 | + switch sigParts[0] { |
| 63 | + case sha1Prefix: |
| 64 | + hashFunc = sha1.New |
| 65 | + case sha256Prefix: |
| 66 | + hashFunc = sha256.New |
| 67 | + case sha512Prefix: |
| 68 | + hashFunc = sha512.New |
| 69 | + default: |
| 70 | + return nil, nil, fmt.Errorf("unknown hash type prefix: %q", sigParts[0]) |
| 71 | + } |
| 72 | + |
| 73 | + buf, err := hex.DecodeString(sigParts[1]) |
| 74 | + if err != nil { |
| 75 | + return nil, nil, fmt.Errorf("error decoding signature %q: %v", signature, err) |
| 76 | + } |
| 77 | + return buf, hashFunc, nil |
| 78 | +} |
| 79 | + |
| 80 | +// ValidatePayload validates an incoming Bitbucket Server Webhook event request |
| 81 | +// and returns the (JSON) payload. |
| 82 | +// The Content-Type header of the payload can be "application/json" or "application/x-www-form-urlencoded". |
| 83 | +// If the Content-Type is neither then an error is returned. |
| 84 | +// secretToken is the Bitbucket Server Webhook secret token. |
| 85 | +// If your webhook does not contain a secret token, you can pass nil or an empty slice. |
| 86 | +// This is intended for local development purposes only and all webhooks should ideally set up a secret token. |
| 87 | +// |
| 88 | +// Example usage: |
| 89 | +// |
| 90 | +// func (s *BitbucketEventMonitor) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 91 | +// payload, err := bitbucket.ValidatePayload(r, s.webhookSecretKey) |
| 92 | +// if err != nil { ... } |
| 93 | +// // Process payload... |
| 94 | +// } |
| 95 | +// |
| 96 | +func ValidatePayload(r *http.Request, secretToken []byte) (payload []byte, err error) { |
| 97 | + var body []byte // Raw body that Bitbucket Server uses to calculate the signature. |
| 98 | + |
| 99 | + switch ct := r.Header.Get("Content-Type"); ct { |
| 100 | + case "application/json": |
| 101 | + var err error |
| 102 | + if body, err = ioutil.ReadAll(r.Body); err != nil { |
| 103 | + return nil, err |
| 104 | + } |
| 105 | + |
| 106 | + // If the content type is application/json, |
| 107 | + // the JSON payload is just the original body. |
| 108 | + payload = body |
| 109 | + |
| 110 | + case "application/x-www-form-urlencoded": |
| 111 | + var err error |
| 112 | + if body, err = ioutil.ReadAll(r.Body); err != nil { |
| 113 | + return nil, err |
| 114 | + } |
| 115 | + |
| 116 | + // If the content type is application/x-www-form-urlencoded, |
| 117 | + // the JSON payload will be under the "payload" form param. |
| 118 | + form, err := url.ParseQuery(string(body)) |
| 119 | + if err != nil { |
| 120 | + return nil, err |
| 121 | + } |
| 122 | + payload = []byte(form.Get(payloadFormParam)) |
| 123 | + |
| 124 | + default: |
| 125 | + return nil, fmt.Errorf("webhook request has unsupported Content-Type %q", ct) |
| 126 | + } |
| 127 | + |
| 128 | + // Only validate the signature if a secret token exists. This is intended for |
| 129 | + // local development only and all webhooks should ideally set up a secret token. |
| 130 | + if len(secretToken) > 0 { |
| 131 | + sig := r.Header.Get(signatureHeader) |
| 132 | + if err := ValidateSignature(sig, body, secretToken); err != nil { |
| 133 | + return nil, err |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + return payload, nil |
| 138 | +} |
| 139 | + |
| 140 | +// ValidateSignature validates the signature for the given payload. |
| 141 | +// signature is the Bitbucket Server hash signature delivered in the X-Hub-Signature header. |
| 142 | +// payload is the JSON payload sent by Bitbucket Server Webhooks. |
| 143 | +// secretToken is the Bitbucket Server Webhook secret token. |
| 144 | +// |
| 145 | +// Doc: https://confluence.atlassian.com/bitbucketserver070/managing-webhooks-in-bitbucket-server-996644364.html#ManagingwebhooksinBitbucketServer-webhooksecretsWebhooksecrets |
| 146 | +func ValidateSignature(signature string, payload, secretToken []byte) error { |
| 147 | + messageMAC, hashFunc, err := messageMAC(signature) |
| 148 | + if err != nil { |
| 149 | + return err |
| 150 | + } |
| 151 | + if !checkMAC(payload, messageMAC, secretToken, hashFunc) { |
| 152 | + return errors.New("payload signature check failed") |
| 153 | + } |
| 154 | + return nil |
| 155 | +} |
| 156 | + |
| 157 | +// WebHookType returns the event key of webhook request r. |
| 158 | +// |
| 159 | +// Doc: https://confluence.atlassian.com/bitbucketserver070/event-payload-996644369.html#Eventpayload-HTTPheaders |
| 160 | +func WebHookType(r *http.Request) string { |
| 161 | + return r.Header.Get(eventKeyHeader) |
| 162 | +} |
| 163 | + |
| 164 | +// RequestID returns the unique UUID for each webhook request r. |
| 165 | +// |
| 166 | +// Doc: https://confluence.atlassian.com/bitbucketserver070/event-payload-996644369.html#Eventpayload-HTTPheaders |
| 167 | +func RequestID(r *http.Request) string { |
| 168 | + return r.Header.Get(requestIDHeader) |
| 169 | +} |
| 170 | + |
| 171 | +// ParseWebHook parses the event payload. An error will be returned for unrecognized |
| 172 | +// event types. |
| 173 | +// |
| 174 | +// Example usage: |
| 175 | +// |
| 176 | +// func (s *BitbucketEventMonitor) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 177 | +// payload, err := bitbucket.ValidatePayload(r, s.webhookSecretKey) |
| 178 | +// if err != nil { ... } |
| 179 | +// event, err := bitbucket.ParseWebHook(bitbucket.WebHookType(r), payload) |
| 180 | +// if err != nil { ... } |
| 181 | +// switch event := event.(type) { |
| 182 | +// case *bitbucket.RepositoryForkedEvent: |
| 183 | +// processRepositoryForkedEvent(event) |
| 184 | +// case *bitbucket.PullRequestModifiedEvent: |
| 185 | +// processPullRequestModifiedEvent(event) |
| 186 | +// ... |
| 187 | +// } |
| 188 | +// } |
| 189 | +// |
| 190 | +func ParseWebHook(eventKey string, payload []byte) (interface{}, error) { |
| 191 | + var event interface{} |
| 192 | + |
| 193 | + switch eventKey { |
| 194 | + case EventKeyRepositoryPush: |
| 195 | + event = &PushEvent{} |
| 196 | + case EventKeyRepositoryModified: |
| 197 | + event = &RepositoryModifiedEvent{} |
| 198 | + case EventKeyRepositoryForked: |
| 199 | + event = &RepositoryForkedEvent{} |
| 200 | + case EventKeyPullRequestOpened: |
| 201 | + event = &PullRequestOpenedEvent{} |
| 202 | + case EventKeyPullRequestReviewersUpdated: |
| 203 | + event = &PullRequestReviewerEvent{} |
| 204 | + case EventKeyPullRequestModified: |
| 205 | + event = &PullRequestModifiedEvent{} |
| 206 | + case EventKeyPullRequestBranchUpdated: |
| 207 | + event = &PullRequestBranchUpdatedEvent{} |
| 208 | + case EventKeyPullRequestApproved: |
| 209 | + event = &PullRequestApprovedEvent{} |
| 210 | + case EventKeyPullRequestUnapproved: |
| 211 | + event = &PullRequestUnapprovedEvent{} |
| 212 | + case EventKeyPullRequestNeedsWork: |
| 213 | + event = &PullRequestNeedsWorkEvent{} |
| 214 | + case EventKeyPullRequestMerged: |
| 215 | + event = &PullRequestMergedEvent{} |
| 216 | + case EventKeyPullRequestDeclined: |
| 217 | + event = &PullRequestDeclinedEvent{} |
| 218 | + case EventKeyPullRequestDeleted: |
| 219 | + event = &PullRequestDeletedEvent{} |
| 220 | + |
| 221 | + default: |
| 222 | + return nil, fmt.Errorf("unknown X-Event-Key in message: %v", eventKey) |
| 223 | + } |
| 224 | + |
| 225 | + err := json.Unmarshal(payload, event) |
| 226 | + if err != nil { |
| 227 | + return nil, err |
| 228 | + } |
| 229 | + |
| 230 | + return event, nil |
| 231 | +} |
0 commit comments