From e8c0f7c5bb2ba8d7e045a665a7866d7fc4d32a96 Mon Sep 17 00:00:00 2001 From: Luke Kysow Date: Tue, 24 Jul 2018 16:13:55 +0200 Subject: [PATCH] Add webhook secret checking. --- cmd/server.go | 100 +++--- cmd/server_test.go | 284 +++++++++++------- .../vcs/bitbucketcloud/bitbucketcloud.go | 2 +- .../vcs/bitbucketserver/request_validation.go | 71 +++++ server/events_controller.go | 13 + server/server.go | 44 +-- 6 files changed, 351 insertions(+), 163 deletions(-) create mode 100644 server/events/vcs/bitbucketserver/request_validation.go diff --git a/cmd/server.go b/cmd/server.go index 75567eea1..4b76fb3f0 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -15,6 +15,7 @@ package cmd import ( "fmt" + "net/url" "os" "path/filepath" "strings" @@ -33,36 +34,37 @@ import ( // 3. Add your flag's description etc. to the stringFlags, intFlags, or boolFlags slices. const ( // Flag names. - AllowForkPRsFlag = "allow-fork-prs" - AllowRepoConfigFlag = "allow-repo-config" - AtlantisURLFlag = "atlantis-url" - BitbucketHostnameFlag = "bitbucket-hostname" - BitbucketTokenFlag = "bitbucket-token" - BitbucketUserFlag = "bitbucket-user" - ConfigFlag = "config" - DataDirFlag = "data-dir" - GHHostnameFlag = "gh-hostname" - GHTokenFlag = "gh-token" - GHUserFlag = "gh-user" - GHWebhookSecret = "gh-webhook-secret" // nolint: gosec - GitlabHostnameFlag = "gitlab-hostname" - GitlabTokenFlag = "gitlab-token" - GitlabUserFlag = "gitlab-user" - GitlabWebhookSecret = "gitlab-webhook-secret" // nolint: gosec - LogLevelFlag = "log-level" - PortFlag = "port" - RepoWhitelistFlag = "repo-whitelist" - RequireApprovalFlag = "require-approval" - SSLCertFileFlag = "ssl-cert-file" - SSLKeyFileFlag = "ssl-key-file" + AllowForkPRsFlag = "allow-fork-prs" + AllowRepoConfigFlag = "allow-repo-config" + AtlantisURLFlag = "atlantis-url" + BitbucketBaseURLFlag = "bitbucket-base-url" + BitbucketTokenFlag = "bitbucket-token" + BitbucketUserFlag = "bitbucket-user" + BitbucketWebhookSecretFlag = "bitbucket-webhook-secret" // nolint: gosec + ConfigFlag = "config" + DataDirFlag = "data-dir" + GHHostnameFlag = "gh-hostname" + GHTokenFlag = "gh-token" + GHUserFlag = "gh-user" + GHWebhookSecretFlag = "gh-webhook-secret" // nolint: gosec + GitlabHostnameFlag = "gitlab-hostname" + GitlabTokenFlag = "gitlab-token" + GitlabUserFlag = "gitlab-user" + GitlabWebhookSecretFlag = "gitlab-webhook-secret" // nolint: gosec + LogLevelFlag = "log-level" + PortFlag = "port" + RepoWhitelistFlag = "repo-whitelist" + RequireApprovalFlag = "require-approval" + SSLCertFileFlag = "ssl-cert-file" + SSLKeyFileFlag = "ssl-key-file" // Flag defaults. - DefaultBitbucketHostname = bitbucketcloud.Hostname - DefaultDataDir = "~/.atlantis" - DefaultGHHostname = "github.com" - DefaultGitlabHostname = "gitlab.com" - DefaultLogLevel = "info" - DefaultPort = 4141 + DefaultBitbucketBaseURL = bitbucketcloud.BaseURL + DefaultDataDir = "~/.atlantis" + DefaultGHHostname = "github.com" + DefaultGitlabHostname = "gitlab.com" + DefaultLogLevel = "info" + DefaultPort = 4141 ) const RedTermStart = "\033[31m" @@ -82,9 +84,18 @@ var stringFlags = []stringFlag{ description: "Bitbucket app password of API user. Can also be specified via the ATLANTIS_BITBUCKET_TOKEN environment variable.", }, { - name: BitbucketHostnameFlag, - description: "Hostname and port of your Bitbucket Server (aka Stash) installation. If using Bitbucket Cloud (bitbucket.org), no need to set.", - defaultValue: DefaultBitbucketHostname, + name: BitbucketBaseURLFlag, + description: "Base URL of Bitbucket Server (aka Stash) installation." + + " Must include scheme, ex. 'http://bitbucket.corp:7990' or 'https://bitbucket.corp'." + + " If using Bitbucket Cloud (bitbucket.org), do not set.", + defaultValue: DefaultBitbucketBaseURL, + }, + { + name: BitbucketWebhookSecretFlag, + description: "Secret used to validate Bitbucket webhooks. Only Bitbucket Server supports webhook secrets." + + " 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.", }, { name: ConfigFlag, @@ -109,7 +120,7 @@ var stringFlags = []stringFlag{ description: "GitHub token of API user. Can also be specified via the ATLANTIS_GH_TOKEN environment variable.", }, { - name: GHWebhookSecret, + name: GHWebhookSecretFlag, description: "Secret used to validate GitHub webhooks (see https://developer.github.com/webhooks/securing/)." + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from GitHub. " + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + @@ -129,7 +140,7 @@ var stringFlags = []stringFlag{ description: "GitLab token of API user. Can also be specified via the ATLANTIS_GITLAB_TOKEN environment variable.", }, { - name: GitlabWebhookSecret, + name: GitlabWebhookSecretFlag, description: "Optional secret used to validate GitLab webhooks." + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from GitLab. " + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + @@ -144,7 +155,8 @@ var stringFlags = []stringFlag{ name: RepoWhitelistFlag, description: "Comma separated list of repositories that Atlantis will operate on. " + "The format is {hostname}/{owner}/{repo}, ex. github.com/runatlantis/atlantis. '*' matches any characters until the next comma and can be used for example to whitelist " + - "all repos: '*' (not recommended), an entire hostname: 'internalgithub.com/*' or an organization: 'github.com/runatlantis/*'.", + "all repos: '*' (not recommended), an entire hostname: 'internalgithub.com/*' or an organization: 'github.com/runatlantis/*'." + + " For Bitbucket Server, {hostname} is the domain without scheme and port, {owner} is the name of the project (not the key), and {repo} is the repo name.", }, { name: SSLCertFileFlag, @@ -344,8 +356,8 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.GitlabHostname == "" { c.GitlabHostname = DefaultGitlabHostname } - if c.BitbucketHostname == "" { - c.BitbucketHostname = DefaultBitbucketHostname + if c.BitbucketBaseURL == "" { + c.BitbucketBaseURL = DefaultBitbucketBaseURL } if c.LogLevel == "" { c.LogLevel = DefaultLogLevel @@ -387,6 +399,17 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return fmt.Errorf("--%s cannot contain ://, should be hostnames only", RepoWhitelistFlag) } + 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) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("--%s must have http:// or https://, got %q", BitbucketBaseURLFlag, userConfig.BitbucketBaseURL) + } return nil } @@ -440,7 +463,10 @@ func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) { if userConfig.GitlabUser != "" && userConfig.GitlabWebhookSecret == "" && !s.SilenceOutput { fmt.Fprintf(os.Stderr, "%s[WARN] No GitLab webhook secret set. This could allow attackers to spoof requests from GitLab.%s\n", RedTermStart, RedTermEnd) } - if userConfig.BitbucketUser != "" && userConfig.BitbucketHostname == DefaultBitbucketHostname && !s.SilenceOutput { + if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL != DefaultBitbucketBaseURL && userConfig.BitbucketWebhookSecret == "" && !s.SilenceOutput { + fmt.Fprintf(os.Stderr, "%s[WARN] No Bitbucket webhook secret set. This could allow attackers to spoof requests from Bitbucket.%s\n", RedTermStart, RedTermEnd) + } + if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL == DefaultBitbucketBaseURL && !s.SilenceOutput { fmt.Fprintf(os.Stderr, "%s[WARN] Bitbucket Cloud does not support webhook secrets. This could allow attackers to spoof requests from Bitbucket. Ensure you are whitelisting Bitbucket IPs.%s\n", RedTermStart, RedTermEnd) } } diff --git a/cmd/server_test.go b/cmd/server_test.go index 71d52305b..9397146fe 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -339,9 +339,10 @@ func TestExecute_Defaults(t *testing.T) { Equals(t, "gitlab-token", passedConfig.GitlabToken) Equals(t, "gitlab-user", passedConfig.GitlabUser) Equals(t, "", passedConfig.GitlabWebhookSecret) - Equals(t, "bitbucket.org", passedConfig.BitbucketHostname) + Equals(t, "https://api.bitbucket.org", passedConfig.BitbucketBaseURL) Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "", passedConfig.BitbucketWebhookSecret) Equals(t, "info", passedConfig.LogLevel) Equals(t, 4141, passedConfig.Port) Equals(t, false, passedConfig.RequireApproval) @@ -422,27 +423,28 @@ func TestExecute_BitbucketUser(t *testing.T) { func TestExecute_Flags(t *testing.T) { t.Log("Should use all flags that are set.") c := setup(map[string]interface{}{ - cmd.AtlantisURLFlag: "url", - cmd.AllowForkPRsFlag: true, - cmd.AllowRepoConfigFlag: true, - //cmd.BitbucketHostnameFlag: "ghhostname", - cmd.BitbucketTokenFlag: "bitbucket-token", - cmd.BitbucketUserFlag: "bitbucket-user", - cmd.DataDirFlag: "/path", - cmd.GHHostnameFlag: "ghhostname", - cmd.GHTokenFlag: "token", - cmd.GHUserFlag: "user", - cmd.GHWebhookSecret: "secret", - cmd.GitlabHostnameFlag: "gitlab-hostname", - cmd.GitlabTokenFlag: "gitlab-token", - cmd.GitlabUserFlag: "gitlab-user", - cmd.GitlabWebhookSecret: "gitlab-secret", - cmd.LogLevelFlag: "debug", - cmd.PortFlag: 8181, - cmd.RepoWhitelistFlag: "github.com/runatlantis/atlantis", - cmd.RequireApprovalFlag: true, - cmd.SSLCertFileFlag: "cert-file", - cmd.SSLKeyFileFlag: "key-file", + cmd.AtlantisURLFlag: "url", + cmd.AllowForkPRsFlag: true, + cmd.AllowRepoConfigFlag: true, + cmd.BitbucketBaseURLFlag: "https://bitbucket-base-url.com", + cmd.BitbucketTokenFlag: "bitbucket-token", + cmd.BitbucketUserFlag: "bitbucket-user", + cmd.BitbucketWebhookSecretFlag: "bitbucket-secret", + cmd.DataDirFlag: "/path", + cmd.GHHostnameFlag: "ghhostname", + cmd.GHTokenFlag: "token", + cmd.GHUserFlag: "user", + cmd.GHWebhookSecretFlag: "secret", + cmd.GitlabHostnameFlag: "gitlab-hostname", + cmd.GitlabTokenFlag: "gitlab-token", + cmd.GitlabUserFlag: "gitlab-user", + cmd.GitlabWebhookSecretFlag: "gitlab-secret", + cmd.LogLevelFlag: "debug", + cmd.PortFlag: 8181, + cmd.RepoWhitelistFlag: "github.com/runatlantis/atlantis", + cmd.RequireApprovalFlag: true, + cmd.SSLCertFileFlag: "cert-file", + cmd.SSLKeyFileFlag: "key-file", }) err := c.Execute() Ok(t, err) @@ -450,8 +452,10 @@ func TestExecute_Flags(t *testing.T) { Equals(t, "url", passedConfig.AtlantisURL) Equals(t, true, passedConfig.AllowForkPRs) Equals(t, true, passedConfig.AllowRepoConfig) + Equals(t, "https://bitbucket-base-url.com", passedConfig.BitbucketBaseURL) Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "bitbucket-secret", passedConfig.BitbucketWebhookSecret) Equals(t, "/path", passedConfig.DataDir) Equals(t, "ghhostname", passedConfig.GithubHostname) Equals(t, "token", passedConfig.GithubToken) @@ -475,8 +479,10 @@ func TestExecute_ConfigFile(t *testing.T) { atlantis-url: "url" allow-fork-prs: true allow-repo-config: true +bitbucket-base-url: "https://mydomain.com" bitbucket-token: "bitbucket-token" bitbucket-user: "bitbucket-user" +bitbucket-webhook-secret: "bitbucket-secret" data-dir: "/path" gh-hostname: "ghhostname" gh-token: "token" @@ -503,8 +509,10 @@ ssl-key-file: key-file Equals(t, "url", passedConfig.AtlantisURL) Equals(t, true, passedConfig.AllowForkPRs) Equals(t, true, passedConfig.AllowRepoConfig) + Equals(t, "https://mydomain.com", passedConfig.BitbucketBaseURL) Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "bitbucket-secret", passedConfig.BitbucketWebhookSecret) Equals(t, "/path", passedConfig.DataDir) Equals(t, "ghhostname", passedConfig.GithubHostname) Equals(t, "token", passedConfig.GithubToken) @@ -528,8 +536,10 @@ func TestExecute_EnvironmentOverride(t *testing.T) { atlantis-url: "url" allow-fork-prs: true allow-repo-config: true +bitbucket-base-url: "https://mydomain.com" bitbucket-token: "bitbucket-token" bitbucket-user: "bitbucket-user" +bitbucket-webhook-secret: "bitbucket-secret" data-dir: "/path" gh-hostname: "ghhostname" gh-token: "token" @@ -550,26 +560,28 @@ ssl-key-file: key-file // NOTE: We add the ATLANTIS_ prefix below. for name, value := range map[string]string{ - "ATLANTIS_URL": "override-url", - "ALLOW_FORK_PRS": "false", - "ALLOW_REPO_CONFIG": "false", - "BITBUCKET_TOKEN": "override-bitbucket-token", - "BITBUCKET_USER": "override-bitbucket-user", - "DATA_DIR": "/override-path", - "GH_HOSTNAME": "override-gh-hostname", - "GH_TOKEN": "override-gh-token", - "GH_USER": "override-gh-user", - "GH_WEBHOOK_SECRET": "override-gh-webhook-secret", - "GITLAB_HOSTNAME": "override-gitlab-hostname", - "GITLAB_TOKEN": "override-gitlab-token", - "GITLAB_USER": "override-gitlab-user", - "GITLAB_WEBHOOK_SECRET": "override-gitlab-webhook-secret", - "LOG_LEVEL": "info", - "PORT": "8282", - "REPO_WHITELIST": "override,override", - "REQUIRE_APPROVAL": "false", - "SSL_CERT_FILE": "override-cert-file", - "SSL_KEY_FILE": "override-key-file", + "ATLANTIS_URL": "override-url", + "ALLOW_FORK_PRS": "false", + "ALLOW_REPO_CONFIG": "false", + "BITBUCKET_BASE_URL": "https://override-bitbucket-base-url", + "BITBUCKET_TOKEN": "override-bitbucket-token", + "BITBUCKET_USER": "override-bitbucket-user", + "BITBUCKET_WEBHOOK_SECRET": "override-bitbucket-secret", + "DATA_DIR": "/override-path", + "GH_HOSTNAME": "override-gh-hostname", + "GH_TOKEN": "override-gh-token", + "GH_USER": "override-gh-user", + "GH_WEBHOOK_SECRET": "override-gh-webhook-secret", + "GITLAB_HOSTNAME": "override-gitlab-hostname", + "GITLAB_TOKEN": "override-gitlab-token", + "GITLAB_USER": "override-gitlab-user", + "GITLAB_WEBHOOK_SECRET": "override-gitlab-webhook-secret", + "LOG_LEVEL": "info", + "PORT": "8282", + "REPO_WHITELIST": "override,override", + "REQUIRE_APPROVAL": "false", + "SSL_CERT_FILE": "override-cert-file", + "SSL_KEY_FILE": "override-key-file", } { os.Setenv("ATLANTIS_"+name, value) // nolint: errcheck } @@ -581,8 +593,10 @@ ssl-key-file: key-file Equals(t, "override-url", passedConfig.AtlantisURL) Equals(t, false, passedConfig.AllowForkPRs) Equals(t, false, passedConfig.AllowRepoConfig) + Equals(t, "https://override-bitbucket-base-url", passedConfig.BitbucketBaseURL) Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken) Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "override-bitbucket-secret", passedConfig.BitbucketWebhookSecret) Equals(t, "/override-path", passedConfig.DataDir) Equals(t, "override-gh-hostname", passedConfig.GithubHostname) Equals(t, "override-gh-token", passedConfig.GithubToken) @@ -606,8 +620,10 @@ func TestExecute_FlagConfigOverride(t *testing.T) { atlantis-url: "url" allow-fork-prs: true allow-repo-config: true +bitbucket-base-url: "https://bitbucket-base-url" bitbucket-token: "bitbucket-token" bitbucket-user: "bitbucket-user" +bitbucket-webhook-secret: "bitbucket-secret" data-dir: "/path" gh-hostname: "ghhostname" gh-token: "token" @@ -627,33 +643,37 @@ ssl-key-file: key-file defer os.Remove(tmpFile) // nolint: errcheck c := setup(map[string]interface{}{ - cmd.AtlantisURLFlag: "override-url", - cmd.AllowForkPRsFlag: false, - cmd.AllowRepoConfigFlag: false, - cmd.BitbucketTokenFlag: "override-bitbucket-token", - cmd.BitbucketUserFlag: "override-bitbucket-user", - cmd.DataDirFlag: "/override-path", - cmd.GHHostnameFlag: "override-gh-hostname", - cmd.GHTokenFlag: "override-gh-token", - cmd.GHUserFlag: "override-gh-user", - cmd.GHWebhookSecret: "override-gh-webhook-secret", - cmd.GitlabHostnameFlag: "override-gitlab-hostname", - cmd.GitlabTokenFlag: "override-gitlab-token", - cmd.GitlabUserFlag: "override-gitlab-user", - cmd.GitlabWebhookSecret: "override-gitlab-webhook-secret", - cmd.LogLevelFlag: "info", - cmd.PortFlag: 8282, - cmd.RepoWhitelistFlag: "override,override", - cmd.RequireApprovalFlag: false, - cmd.SSLCertFileFlag: "override-cert-file", - cmd.SSLKeyFileFlag: "override-key-file", + cmd.AtlantisURLFlag: "override-url", + cmd.AllowForkPRsFlag: false, + cmd.AllowRepoConfigFlag: false, + cmd.BitbucketBaseURLFlag: "https://override-bitbucket-base-url", + cmd.BitbucketTokenFlag: "override-bitbucket-token", + cmd.BitbucketUserFlag: "override-bitbucket-user", + cmd.BitbucketWebhookSecretFlag: "override-bitbucket-secret", + cmd.DataDirFlag: "/override-path", + cmd.GHHostnameFlag: "override-gh-hostname", + cmd.GHTokenFlag: "override-gh-token", + cmd.GHUserFlag: "override-gh-user", + cmd.GHWebhookSecretFlag: "override-gh-webhook-secret", + cmd.GitlabHostnameFlag: "override-gitlab-hostname", + cmd.GitlabTokenFlag: "override-gitlab-token", + cmd.GitlabUserFlag: "override-gitlab-user", + cmd.GitlabWebhookSecretFlag: "override-gitlab-webhook-secret", + cmd.LogLevelFlag: "info", + cmd.PortFlag: 8282, + cmd.RepoWhitelistFlag: "override,override", + cmd.RequireApprovalFlag: false, + cmd.SSLCertFileFlag: "override-cert-file", + cmd.SSLKeyFileFlag: "override-key-file", }) err := c.Execute() Ok(t, err) Equals(t, "override-url", passedConfig.AtlantisURL) Equals(t, false, passedConfig.AllowForkPRs) + Equals(t, "https://override-bitbucket-base-url", passedConfig.BitbucketBaseURL) Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken) Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "override-bitbucket-secret", passedConfig.BitbucketWebhookSecret) Equals(t, "/override-path", passedConfig.DataDir) Equals(t, "override-gh-hostname", passedConfig.GithubHostname) Equals(t, "override-gh-token", passedConfig.GithubToken) @@ -674,52 +694,63 @@ ssl-key-file: key-file func TestExecute_FlagEnvVarOverride(t *testing.T) { t.Log("Flags should override environment variables.") - for name, value := range map[string]string{ - "ATLANTIS_URL": "url", - "ALLOW_FORK_PRS": "true", - "ALLOW_REPO_CONFIG": "true", - "BITBUCKET_TOKEN": "bitbucket-token", - "BITBUCKET_USER": "bitbucket-user", - "DATA_DIR": "/path", - "GH_HOSTNAME": "gh-hostname", - "GH_TOKEN": "gh-token", - "GH_USER": "gh-user", - "GH_WEBHOOK_SECRET": "gh-webhook-secret", - "GITLAB_HOSTNAME": "gitlab-hostname", - "GITLAB_TOKEN": "gitlab-token", - "GITLAB_USER": "gitlab-user", - "GITLAB_WEBHOOK_SECRET": "gitlab-webhook-secret", - "LOG_LEVEL": "debug", - "PORT": "8181", - "REPO_WHITELIST": "*", - "REQUIRE_APPROVAL": "true", - "SSL_CERT_FILE": "cert-file", - "SSL_KEY_FILE": "key-file", - } { + envVars := map[string]string{ + "ATLANTIS_URL": "url", + "ALLOW_FORK_PRS": "true", + "ALLOW_REPO_CONFIG": "true", + "BITBUCKET_BASE_URL": "https://bitbucket-base-url", + "BITBUCKET_TOKEN": "bitbucket-token", + "BITBUCKET_USER": "bitbucket-user", + "BITBUCKET_WEBHOOK_SECRET": "bitbucket-secret", + "DATA_DIR": "/path", + "GH_HOSTNAME": "gh-hostname", + "GH_TOKEN": "gh-token", + "GH_USER": "gh-user", + "GH_WEBHOOK_SECRET": "gh-webhook-secret", + "GITLAB_HOSTNAME": "gitlab-hostname", + "GITLAB_TOKEN": "gitlab-token", + "GITLAB_USER": "gitlab-user", + "GITLAB_WEBHOOK_SECRET": "gitlab-webhook-secret", + "LOG_LEVEL": "debug", + "PORT": "8181", + "REPO_WHITELIST": "*", + "REQUIRE_APPROVAL": "true", + "SSL_CERT_FILE": "cert-file", + "SSL_KEY_FILE": "key-file", + } + for name, value := range envVars { os.Setenv("ATLANTIS_"+name, value) // nolint: errcheck } + defer func() { + // Unset after this test finishes. + for name := range envVars { + os.Unsetenv("ATLANTIS_" + name) // nolint: errcheck + } + }() c := setup(map[string]interface{}{ - cmd.AtlantisURLFlag: "override-url", - cmd.AllowForkPRsFlag: false, - cmd.AllowRepoConfigFlag: false, - cmd.BitbucketTokenFlag: "override-bitbucket-token", - cmd.BitbucketUserFlag: "override-bitbucket-user", - cmd.DataDirFlag: "/override-path", - cmd.GHHostnameFlag: "override-gh-hostname", - cmd.GHTokenFlag: "override-gh-token", - cmd.GHUserFlag: "override-gh-user", - cmd.GHWebhookSecret: "override-gh-webhook-secret", - cmd.GitlabHostnameFlag: "override-gitlab-hostname", - cmd.GitlabTokenFlag: "override-gitlab-token", - cmd.GitlabUserFlag: "override-gitlab-user", - cmd.GitlabWebhookSecret: "override-gitlab-webhook-secret", - cmd.LogLevelFlag: "info", - cmd.PortFlag: 8282, - cmd.RepoWhitelistFlag: "override,override", - cmd.RequireApprovalFlag: false, - cmd.SSLCertFileFlag: "override-cert-file", - cmd.SSLKeyFileFlag: "override-key-file", + cmd.AtlantisURLFlag: "override-url", + cmd.AllowForkPRsFlag: false, + cmd.AllowRepoConfigFlag: false, + cmd.BitbucketBaseURLFlag: "https://override-bitbucket-base-url", + cmd.BitbucketTokenFlag: "override-bitbucket-token", + cmd.BitbucketUserFlag: "override-bitbucket-user", + cmd.BitbucketWebhookSecretFlag: "override-bitbucket-secret", + cmd.DataDirFlag: "/override-path", + cmd.GHHostnameFlag: "override-gh-hostname", + cmd.GHTokenFlag: "override-gh-token", + cmd.GHUserFlag: "override-gh-user", + cmd.GHWebhookSecretFlag: "override-gh-webhook-secret", + cmd.GitlabHostnameFlag: "override-gitlab-hostname", + cmd.GitlabTokenFlag: "override-gitlab-token", + cmd.GitlabUserFlag: "override-gitlab-user", + cmd.GitlabWebhookSecretFlag: "override-gitlab-webhook-secret", + cmd.LogLevelFlag: "info", + cmd.PortFlag: 8282, + cmd.RepoWhitelistFlag: "override,override", + cmd.RequireApprovalFlag: false, + cmd.SSLCertFileFlag: "override-cert-file", + cmd.SSLKeyFileFlag: "override-key-file", }) err := c.Execute() Ok(t, err) @@ -727,8 +758,10 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { Equals(t, "override-url", passedConfig.AtlantisURL) Equals(t, false, passedConfig.AllowForkPRs) Equals(t, false, passedConfig.AllowRepoConfig) + Equals(t, "https://override-bitbucket-base-url", passedConfig.BitbucketBaseURL) Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken) Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "override-bitbucket-secret", passedConfig.BitbucketWebhookSecret) Equals(t, "/override-path", passedConfig.DataDir) Equals(t, "override-gh-hostname", passedConfig.GithubHostname) Equals(t, "override-gh-token", passedConfig.GithubToken) @@ -746,6 +779,49 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { Equals(t, "override-key-file", passedConfig.SSLKeyFile) } +// If using bitbucket cloud, webhook secrets are not supported. +func TestExecute_BitbucketCloudWithWebhookSecret(t *testing.T) { + c := setup(map[string]interface{}{ + cmd.BitbucketUserFlag: "user", + cmd.BitbucketTokenFlag: "token", + cmd.RepoWhitelistFlag: "*", + cmd.BitbucketWebhookSecretFlag: "my secret", + }) + 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{}{ + cmd.BitbucketUserFlag: "user", + cmd.BitbucketTokenFlag: "token", + cmd.RepoWhitelistFlag: "*", + cmd.BitbucketBaseURLFlag: "mydomain.com", + }) + ErrEquals(t, "--bitbucket-base-url must have http:// or https://, got \"mydomain.com\"", c.Execute()) + + c = setup(map[string]interface{}{ + cmd.BitbucketUserFlag: "user", + cmd.BitbucketTokenFlag: "token", + cmd.RepoWhitelistFlag: "*", + cmd.BitbucketBaseURLFlag: "://mydomain.com", + }) + ErrEquals(t, "error parsing --bitbucket-webhook-secret flag value \"://mydomain.com\": parse ://mydomain.com: missing protocol scheme", c.Execute()) +} + +// Port should be retained on base url. +func TestExecute_BitbucketServerBaseURLPort(t *testing.T) { + c := setup(map[string]interface{}{ + cmd.BitbucketUserFlag: "user", + cmd.BitbucketTokenFlag: "token", + cmd.RepoWhitelistFlag: "*", + cmd.BitbucketBaseURLFlag: "http://mydomain.com:7990", + }) + Ok(t, c.Execute()) + Equals(t, "http://mydomain.com:7990", passedConfig.BitbucketBaseURL) +} + func setup(flags map[string]interface{}) *cobra.Command { vipr := viper.New() for k, v := range flags { diff --git a/server/events/vcs/bitbucketcloud/bitbucketcloud.go b/server/events/vcs/bitbucketcloud/bitbucketcloud.go index 91efa0e5c..89dcd3ce5 100644 --- a/server/events/vcs/bitbucketcloud/bitbucketcloud.go +++ b/server/events/vcs/bitbucketcloud/bitbucketcloud.go @@ -3,4 +3,4 @@ // APIs. package bitbucketcloud -const Hostname = "bitbucket.org" +const BaseURL = "https://api.bitbucket.org" diff --git a/server/events/vcs/bitbucketserver/request_validation.go b/server/events/vcs/bitbucketserver/request_validation.go new file mode 100644 index 000000000..3c269a8cf --- /dev/null +++ b/server/events/vcs/bitbucketserver/request_validation.go @@ -0,0 +1,71 @@ +package bitbucketserver + +import ( + "crypto/hmac" + "crypto/sha1" + "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) + 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 +} diff --git a/server/events_controller.go b/server/events_controller.go index 7293b9b42..0b5ffc960 100644 --- a/server/events_controller.go +++ b/server/events_controller.go @@ -20,6 +20,7 @@ import ( "github.com/google/go-github/github" "github.com/lkysow/go-gitlab" + "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" @@ -35,6 +36,7 @@ const gitlabHeader = "X-Gitlab-Event" const bitbucketEventTypeHeader = "X-Event-Key" const bitbucketCloudRequestIDHeader = "X-Request-UUID" const bitbucketServerRequestIDHeader = "X-Request-ID" +const bitbucketServerSignatureHeader = "X-Hub-Signature" // EventsController handles all webhook requests which signify 'events' in the // VCS host, ex. GitHub. @@ -60,6 +62,10 @@ type EventsController struct { SupportedVCSHosts []models.VCSHostType VCSClient vcs.ClientProxy TestingMode bool + // BitbucketWebhookSecret is the secret added to this webhook via the Bitbucket + // UI that identifies this call as coming from Bitbucket. If empty, no + // request validation is done. + BitbucketWebhookSecret []byte } // Post handles POST webhook requests. @@ -153,12 +159,19 @@ func (e *EventsController) handleBitbucketCloudPost(w http.ResponseWriter, r *ht func (e *EventsController) handleBitbucketServerPost(w http.ResponseWriter, r *http.Request) { eventType := r.Header.Get(bitbucketEventTypeHeader) reqID := r.Header.Get(bitbucketServerRequestIDHeader) + sig := r.Header.Get(bitbucketServerSignatureHeader) defer r.Body.Close() // nolint: errcheck body, err := ioutil.ReadAll(r.Body) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, bitbucketServerRequestIDHeader, reqID) return } + if len(e.BitbucketWebhookSecret) > 0 { + if err := bitbucketserver.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 bitbucketserver.PullCreatedHeader, bitbucketserver.PullFulfilledHeader, bitbucketserver.PullDeclinedHeader: e.Logger.Debug("handling as pull request state changed event") diff --git a/server/server.go b/server/server.go index c4d7bb1f6..ae48f0bc5 100644 --- a/server/server.go +++ b/server/server.go @@ -82,24 +82,25 @@ type Server struct { // The mapstructure tags correspond to flags in cmd/server.go and are used when // the config is parsed from a YAML file. type UserConfig struct { - AllowForkPRs bool `mapstructure:"allow-fork-prs"` - AllowRepoConfig bool `mapstructure:"allow-repo-config"` - AtlantisURL string `mapstructure:"atlantis-url"` - BitbucketHostname string `mapstructure:"bitbucket-hostname"` - BitbucketToken string `mapstructure:"bitbucket-token"` - BitbucketUser string `mapstructure:"bitbucket-user"` - DataDir string `mapstructure:"data-dir"` - GithubHostname string `mapstructure:"gh-hostname"` - GithubToken string `mapstructure:"gh-token"` - GithubUser string `mapstructure:"gh-user"` - GithubWebhookSecret string `mapstructure:"gh-webhook-secret"` - GitlabHostname string `mapstructure:"gitlab-hostname"` - GitlabToken string `mapstructure:"gitlab-token"` - GitlabUser string `mapstructure:"gitlab-user"` - GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"` - LogLevel string `mapstructure:"log-level"` - Port int `mapstructure:"port"` - RepoWhitelist string `mapstructure:"repo-whitelist"` + AllowForkPRs bool `mapstructure:"allow-fork-prs"` + AllowRepoConfig bool `mapstructure:"allow-repo-config"` + AtlantisURL string `mapstructure:"atlantis-url"` + BitbucketBaseURL string `mapstructure:"bitbucket-base-url"` + BitbucketToken string `mapstructure:"bitbucket-token"` + BitbucketUser string `mapstructure:"bitbucket-user"` + BitbucketWebhookSecret string `mapstructure:"bitbucket-webhook-secret"` + DataDir string `mapstructure:"data-dir"` + GithubHostname string `mapstructure:"gh-hostname"` + GithubToken string `mapstructure:"gh-token"` + GithubUser string `mapstructure:"gh-user"` + GithubWebhookSecret string `mapstructure:"gh-webhook-secret"` + GitlabHostname string `mapstructure:"gitlab-hostname"` + GitlabToken string `mapstructure:"gitlab-token"` + GitlabUser string `mapstructure:"gitlab-user"` + GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"` + LogLevel string `mapstructure:"log-level"` + Port int `mapstructure:"port"` + RepoWhitelist string `mapstructure:"repo-whitelist"` // RequireApproval is whether to require pull request approval before // allowing terraform apply's to be run. RequireApproval bool `mapstructure:"require-approval"` @@ -170,7 +171,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } } if userConfig.BitbucketUser != "" { - if userConfig.BitbucketHostname == bitbucketcloud.Hostname { + if userConfig.BitbucketBaseURL == bitbucketcloud.BaseURL { supportedVCSHosts = append(supportedVCSHosts, models.BitbucketCloud) bitbucketCloudClient = bitbucketcloud.NewClient( http.DefaultClient, @@ -184,7 +185,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { http.DefaultClient, userConfig.BitbucketUser, userConfig.BitbucketToken, - userConfig.BitbucketHostname, + userConfig.BitbucketBaseURL, userConfig.AtlantisURL) if err != nil { return nil, errors.Wrapf(err, "setting up Bitbucket Server client") @@ -248,7 +249,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { GitlabToken: userConfig.GitlabToken, BitbucketUser: userConfig.BitbucketUser, BitbucketToken: userConfig.BitbucketToken, - BitbucketServerURL: userConfig.BitbucketHostname, + BitbucketServerURL: userConfig.BitbucketBaseURL, } commentParser := &events.CommentParser{ GithubUser: userConfig.GithubUser, @@ -326,6 +327,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { RepoWhitelistChecker: repoWhitelist, SupportedVCSHosts: supportedVCSHosts, VCSClient: vcsClient, + BitbucketWebhookSecret: []byte(userConfig.BitbucketWebhookSecret), } return &Server{ AtlantisVersion: config.AtlantisVersion,