Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: BitBucket Cloud: add support for webhook secrets #4275

Merged
9 changes: 1 addition & 8 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ var stringFlags = map[string]stringFlag{
defaultValue: DefaultBitbucketBaseURL,
},
BitbucketWebhookSecretFlag: {
description: "Secret used to validate Bitbucket webhooks. Only Bitbucket Server supports webhook secrets." +
description: "Secret used to validate Bitbucket webhooks." +
" SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from Bitbucket. " +
"This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " +
"Should be specified via the ATLANTIS_BITBUCKET_WEBHOOK_SECRET environment variable.",
Expand Down Expand Up @@ -1034,10 +1034,6 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error {
return fmt.Errorf("--%s cannot contain ://, should be hostnames only", RepoAllowlistFlag)
}

if userConfig.BitbucketBaseURL == DefaultBitbucketBaseURL && userConfig.BitbucketWebhookSecret != "" {
return fmt.Errorf("--%s cannot be specified for Bitbucket Cloud because it is not supported by Bitbucket", BitbucketWebhookSecretFlag)
}

parsed, err := url.Parse(userConfig.BitbucketBaseURL)
if err != nil {
return fmt.Errorf("error parsing --%s flag value %q: %s", BitbucketWebhookSecretFlag, userConfig.BitbucketBaseURL, err)
Expand Down Expand Up @@ -1185,9 +1181,6 @@ func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) {
if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL != DefaultBitbucketBaseURL && userConfig.BitbucketWebhookSecret == "" && !s.SilenceOutput {
s.Logger.Warn("no Bitbucket webhook secret set. This could allow attackers to spoof requests from Bitbucket")
}
if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL == DefaultBitbucketBaseURL && !s.SilenceOutput {
s.Logger.Warn("Bitbucket Cloud does not support webhook secrets. This could allow attackers to spoof requests from Bitbucket. Ensure you are allowing only Bitbucket IPs")
}
if userConfig.AzureDevopsWebhookUser != "" && userConfig.AzureDevopsWebhookPassword == "" && !s.SilenceOutput {
s.Logger.Warn("no Azure DevOps webhook user and password set. This could allow attackers to spoof requests from Azure DevOps.")
}
Expand Down
12 changes: 0 additions & 12 deletions cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -940,18 +940,6 @@ func TestExecute_ADUser(t *testing.T) {
Equals(t, "user", passedConfig.AzureDevopsUser)
}

// If using bitbucket cloud, webhook secrets are not supported.
func TestExecute_BitbucketCloudWithWebhookSecret(t *testing.T) {
c := setup(map[string]interface{}{
BitbucketUserFlag: "user",
BitbucketTokenFlag: "token",
RepoAllowlistFlag: "*",
BitbucketWebhookSecretFlag: "my secret",
}, t)
err := c.Execute()
ErrEquals(t, "--bitbucket-webhook-secret cannot be specified for Bitbucket Cloud because it is not supported by Bitbucket", err)
}

// Base URL must have a scheme.
func TestExecute_BitbucketServerBaseURLScheme(t *testing.T) {
c := setup(map[string]interface{}{
Expand Down
10 changes: 6 additions & 4 deletions runatlantis.io/docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,6 @@ echo -n "yoursecret" > webhook-secret
kubectl create secret generic atlantis-vcs --from-file=token --from-file=webhook-secret
```

::: tip Note
If you're using Bitbucket Cloud then there is no webhook secret since it's not supported.
:::

Next, edit the manifests below as follows:

1. Replace `<VERSION>` in `image: ghcr.io/runatlantis/atlantis:<VERSION>` with the most recent version from [GitHub: Atlantis latest release](https://github.com/runatlantis/atlantis/releases/latest).
Expand Down Expand Up @@ -231,6 +227,11 @@ spec:
secretKeyRef:
name: atlantis-vcs
key: token
- name: ATLANTIS_BITBUCKET_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
name: atlantis-vcs
key: webhook-secret
### End Bitbucket Config ###

### Azure DevOps Config ###
Expand Down Expand Up @@ -742,6 +743,7 @@ atlantis server \
--atlantis-url="$URL" \
--bitbucket-user="$USERNAME" \
--bitbucket-token="$TOKEN" \
--bitbucket-webhook-secret="$SECRET" \
--repo-allowlist="$REPO_ALLOWLIST"
```

Expand Down
15 changes: 0 additions & 15 deletions runatlantis.io/docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,6 @@ Atlantis could be exploited by
* Running malicious custom build commands specified in an `atlantis.yaml` file. Atlantis uses the `atlantis.yaml` file from the pull request branch, **not** `main`.
* Someone adding `atlantis plan/apply` comments on your valid pull requests causing terraform to run when you don't want it to.

## Bitbucket Cloud (bitbucket.org)

::: danger
Bitbucket Cloud does not support webhook secrets. This could allow attackers to spoof requests from Bitbucket. Ensure you are allowing only Bitbucket IPs.
:::
Bitbucket Cloud doesn't support webhook secrets. This means that an attacker could
make fake requests to Atlantis that look like they're coming from Bitbucket.

If you are specifying `--repo-allowlist` then they could only fake requests pertaining
to those repos so the most damage they could do would be to plan/apply on your
own repos.

To prevent this, allowlist [Bitbucket's IP addresses](https://confluence.atlassian.com/bitbucket/what-are-the-bitbucket-cloud-ip-addresses-i-should-use-to-configure-my-corporate-firewall-343343385.html)
(see Outbound IPv4 addresses).

## Mitigations

### Don't Use On Public Repos
Expand Down
3 changes: 1 addition & 2 deletions runatlantis.io/docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,7 @@ and set `--autoplan-modules` to `false`.
ATLANTIS_BITBUCKET_WEBHOOK_SECRET="secret"
```

Secret used to validate Bitbucket webhooks. Only Bitbucket Server supports webhook secrets.
For Bitbucket.org, see [Security](security.md#bitbucket-cloud-bitbucket-org) for mitigations.
Secret used to validate Bitbucket webhooks.

::: warning SECURITY WARNING
If not specified, Atlantis won't be able to validate that the incoming webhook call came from Bitbucket.
Expand Down
5 changes: 0 additions & 5 deletions runatlantis.io/docs/webhook-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,6 @@ Azure DevOps uses Basic authentication for webhooks rather than webhook secrets.
An app-wide token is generated during [GitHub App setup](access-credentials.md#github-app). You can recover it by navigating to the [GitHub app settings page](https://github.com/settings/apps) and selecting "Edit" next to your Atlantis app's name. Token appears after clicking "Edit" under the Webhook header.
:::

::: warning
Bitbucket.org **does not** support webhook secrets.
To mitigate, use repo allowlists and IP allowlists. See [Security](security.md#bitbucket-cloud-bitbucket-org) for more information.
:::

## Generating A Webhook Secret

You can use any random string generator to create your Webhook secret. It should be > 24 characters.
Expand Down
6 changes: 1 addition & 5 deletions runatlantis.io/guide/testing-locally.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,7 @@ URL="https://{YOUR_HOSTNAME}.ngrok.io"

GitHub and GitLab use webhook secrets so clients can verify that the webhooks came
from them.
::: warning
Bitbucket Cloud (bitbucket.org) doesn't use webhook secrets so if you're using Bitbucket Cloud you can skip this step.
When you're ready to do a production deploy of Atlantis you should allowlist [Bitbucket IPs](https://confluence.atlassian.com/bitbucket/what-are-the-bitbucket-cloud-ip-addresses-i-should-use-to-configure-my-corporate-firewall-343343385.html)
to ensure the webhooks are coming from them.
:::

Create a random string of any length (you can use [random.org](https://www.random.org/strings/))
and set an environment variable:

Expand Down
11 changes: 9 additions & 2 deletions server/controllers/events/events_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const giteaRequestIDHeader = "X-Gitea-Delivery"
const bitbucketEventTypeHeader = "X-Event-Key"
const bitbucketCloudRequestIDHeader = "X-Request-UUID"
const bitbucketServerRequestIDHeader = "X-Request-ID"
const bitbucketServerSignatureHeader = "X-Hub-Signature"
const bitbucketSignatureHeader = "X-Hub-Signature"

// The URL used for Azure DevOps test webhooks
const azuredevopsTestURL = "https://fabrikam.visualstudio.com/DefaultCollection/_apis/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079"
Expand Down Expand Up @@ -223,12 +223,19 @@ func (e *VCSEventsController) handleGithubPost(w http.ResponseWriter, r *http.Re
func (e *VCSEventsController) handleBitbucketCloudPost(w http.ResponseWriter, r *http.Request) {
eventType := r.Header.Get(bitbucketEventTypeHeader)
reqID := r.Header.Get(bitbucketCloudRequestIDHeader)
sig := r.Header.Get(bitbucketSignatureHeader)
defer r.Body.Close() // nolint: errcheck
body, err := io.ReadAll(r.Body)
if err != nil {
e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID)
return
}
if len(e.BitbucketWebhookSecret) > 0 {
if err := bitbucketcloud.ValidateSignature(body, sig, e.BitbucketWebhookSecret); err != nil {
e.respond(w, logging.Warn, http.StatusBadRequest, "%s", errors.Wrap(err, "request did not pass validation").Error())
return
}
}
switch eventType {
case bitbucketcloud.PullCreatedHeader, bitbucketcloud.PullUpdatedHeader, bitbucketcloud.PullFulfilledHeader, bitbucketcloud.PullRejectedHeader:
e.Logger.Debug("handling as pull request state changed event")
Expand All @@ -246,7 +253,7 @@ func (e *VCSEventsController) handleBitbucketCloudPost(w http.ResponseWriter, r
func (e *VCSEventsController) handleBitbucketServerPost(w http.ResponseWriter, r *http.Request) {
eventType := r.Header.Get(bitbucketEventTypeHeader)
reqID := r.Header.Get(bitbucketServerRequestIDHeader)
sig := r.Header.Get(bitbucketServerSignatureHeader)
sig := r.Header.Get(bitbucketSignatureHeader)
defer r.Body.Close() // nolint: errcheck
body, err := io.ReadAll(r.Body)
if err != nil {
Expand Down
72 changes: 72 additions & 0 deletions server/events/vcs/bitbucketcloud/request_validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package bitbucketcloud

import (
"crypto/hmac"
"crypto/sha1" // nolint: gosec
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"strings"

"github.com/pkg/errors"
)

// Attribution: This code is taken from https://github.com/google/go-github.

func ValidateSignature(payload []byte, signature string, secretKey []byte) error {
messageMAC, hashFunc, err := messageMAC(signature)
if err != nil {
return err
}
if !checkMAC(payload, messageMAC, secretKey, hashFunc) {
return errors.New("payload signature check failed")
}
return nil
}

// genMAC generates the HMAC signature for a message provided the secret key
// and hashFunc.
func genMAC(message, key []byte, hashFunc func() hash.Hash) []byte {
mac := hmac.New(hashFunc, key)
// nolint: errcheck
mac.Write(message)
return mac.Sum(nil)
}

// checkMAC reports whether messageMAC is a valid HMAC tag for message.
func checkMAC(message, messageMAC, key []byte, hashFunc func() hash.Hash) bool {
expectedMAC := genMAC(message, key, hashFunc)
return hmac.Equal(messageMAC, expectedMAC)
}

// messageMAC returns the hex-decoded HMAC tag from the signature and its
// corresponding hash function.
func messageMAC(signature string) ([]byte, func() hash.Hash, error) {
if signature == "" {
return nil, nil, errors.New("missing signature")
}
sigParts := strings.SplitN(signature, "=", 2)
if len(sigParts) != 2 {
return nil, nil, fmt.Errorf("error parsing signature %q", signature)
}

var hashFunc func() hash.Hash
switch sigParts[0] {
case "sha1":
hashFunc = sha1.New
case "sha256":
hashFunc = sha256.New
case "sha512":
hashFunc = sha512.New
default:
return nil, nil, fmt.Errorf("unknown hash type prefix: %q", sigParts[0])
}

buf, err := hex.DecodeString(sigParts[1])
if err != nil {
return nil, nil, fmt.Errorf("error decoding signature %q: %v", signature, err)
}
return buf, hashFunc, nil
}
24 changes: 24 additions & 0 deletions server/events/vcs/bitbucketcloud/request_validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package bitbucketcloud_test

import (
"testing"

"github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud"
. "github.com/runatlantis/atlantis/testing"
)

func TestValidateSignature(t *testing.T) {
body := `{"eventKey":"pr:comment:added","date":"2018-07-24T15:10:05+0200","actor":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"pullRequest":{"id":5,"version":0,"title":"another.tf edited online with Bitbucket","state":"OPEN","open":true,"closed":false,"createdDate":1532365427513,"updatedDate":1532365427513,"fromRef":{"id":"refs/heads/lkysow/anothertf-1532365422773","displayId":"lkysow/anothertf-1532365422773","latestCommit":"b52b8e254e956654dcdb394d0ccba9199f420427","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"toRef":{"id":"refs/heads/master","displayId":"master","latestCommit":"0a338874369017deba7c22e99e6000932724282f","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"locked":false,"author":{"user":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"role":"AUTHOR","approved":false,"status":"UNAPPROVED"},"reviewers":[],"participants":[]},"comment":{"properties":{"repositoryId":1},"id":65,"version":0,"text":"comment","author":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"createdDate":1532437805137,"updatedDate":1532437805137,"comments":[],"tasks":[]}}`
secret := "mysecret"
sig := `sha256=ed11f92d1565a4de586727fe8260558277d58009e8957a79eb4749a7009ce083`
err := bitbucketcloud.ValidateSignature([]byte(body), sig, []byte(secret))
Ok(t, err)
}

func TestValidateSignature_Invalid(t *testing.T) {
body := `"eventKey":"pr:comment:added","date":"2018-07-24T15:10:05+0200","actor":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"pullRequest":{"id":5,"version":0,"title":"another.tf edited online with Bitbucket","state":"OPEN","open":true,"closed":false,"createdDate":1532365427513,"updatedDate":1532365427513,"fromRef":{"id":"refs/heads/lkysow/anothertf-1532365422773","displayId":"lkysow/anothertf-1532365422773","latestCommit":"b52b8e254e956654dcdb394d0ccba9199f420427","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"toRef":{"id":"refs/heads/main","displayId":"main","latestCommit":"0a338874369017deba7c22e99e6000932724282f","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"locked":false,"author":{"user":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"role":"AUTHOR","approved":false,"status":"UNAPPROVED"},"reviewers":[],"participants":[]},"comment":{"properties":{"repositoryId":1},"id":65,"version":0,"text":"comment","author":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"createdDate":1532437805137,"updatedDate":1532437805137,"comments":[],"tasks":[]}}`
secret := "mysecret"
sig := `sha256=ed11f92d1565a4de586727fe8260558277d58009e8957a79eb4749a7009ce083`
err := bitbucketcloud.ValidateSignature([]byte(body), sig, []byte(secret))
ErrEquals(t, "payload signature check failed", err)
}
Loading