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 @@ -238,7 +238,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 @@ -912,10 +912,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 @@ -1044,9 +1040,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 @@ -738,18 +738,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
19 changes: 11 additions & 8 deletions runatlantis.io/docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,26 +99,23 @@ echo -n "yourtoken" > token
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.
:::
jamengual marked this conversation as resolved.
Show resolved Hide resolved

Next, edit the manifests below as follows:
1. Replace `<VERSION>` in `image: ghcr.io/runatlantis/atlantis:<VERSION>` with the most recent version from [https://github.com/runatlantis/atlantis/releases/latest](https://github.com/runatlantis/atlantis/releases/latest).
* NOTE: You never want to run with `:latest` because if your Pod moves to a new node, Kubernetes will pull the latest image and you might end
up upgrading Atlantis by accident!
2. Replace `value: github.com/yourorg/*` under `name: ATLANTIS_REPO_ALLOWLIST` with the allowlist pattern
1. Replace `value: github.com/yourorg/*` under `name: ATLANTIS_REPO_ALLOWLIST` with the allowlist pattern
for your Terraform repos. See [Repo Allowlist](server-configuration.html#repo-allowlist) for more details.
3. If you're using GitHub:
1. If you're using GitHub:
1. Replace `<YOUR_GITHUB_USER>` with the username of your Atlantis GitHub user without the `@`.
2. Delete all the `ATLANTIS_GITLAB_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables.
4. If you're using GitLab:
2. If you're using GitLab:
1. Replace `<YOUR_GITLAB_USER>` with the username of your Atlantis GitLab user without the `@`.
2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables.
5. If you're using Bitbucket:
3. If you're using Bitbucket:
1. Replace `<YOUR_BITBUCKET_USER>` with the username of your Atlantis Bitbucket user without the `@`.
2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables.
6. If you're using Azure DevOps:
4. If you're using Azure DevOps:
1. Replace `<YOUR_AZUREDEVOPS_USER>` with the username of your Atlantis Azure DevOps user without the `@`.
2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, and `ATLANTIS_BITBUCKET_*` environment variables.

Expand Down Expand Up @@ -193,6 +190,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 @@ -638,6 +640,7 @@ atlantis server \
--atlantis-url="$URL" \
--bitbucket-user="$USERNAME" \
--bitbucket-token="$TOKEN" \
--bitbucket-webhook-secret="$SECRET" \
--repo-allowlist="$REPO_ALLOWLIST"
```

Expand Down
14 changes: 0 additions & 14 deletions runatlantis.io/docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,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
Because anyone can comment on public pull requests, even with all the security mitigations available, it's still dangerous to run Atlantis on public repos without proper configuration of the security settings.
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 @@ -297,8 +297,7 @@ and set `--autoplan-modules` to `false`.
# or (recommended)
ATLANTIS_BITBUCKET_WEBHOOK_SECRET="secret"
```
Secret used to validate Bitbucket webhooks. Only Bitbucket Server supports webhook secrets.
For Bitbucket.org, see [Security](security.html#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/guide/testing-locally.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ URL="https://{YOUR_HOSTNAME}.ngrok.io"
## Create a Webhook Secret
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 [https://www.random.org/strings/](https://www.random.org/strings/))
and set an environment variable:
```
Expand Down
7 changes: 7 additions & 0 deletions server/controllers/events/events_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,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(bitbucketServerSignatureHeader)
almightyfoon marked this conversation as resolved.
Show resolved Hide resolved
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, 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 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":"[email protected]","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":"[email protected]","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":"[email protected]","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":"[email protected]","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":"[email protected]","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":"[email protected]","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