diff --git a/.gitignore b/.gitignore index b52c626..5cb43c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ env.txt secret.yaml +webhook-secret.yaml diff --git a/README.md b/README.md index f6dcade..043ab47 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ This way we can trigger cleanups when the PR is closed. ``` X-Interceptor-Push-Ref: string: "refs/heads/master" +X-Interceptor-Include-Repo: []string{"my/repo"} +X-Interceptor-Exclude-Repo: []string{"my/repo"} ``` Response Headers: @@ -53,10 +55,33 @@ Issue: Commit: ``` +## Validate Webhooks + +Add secret webhook keys you want to have validated. + +**NOTE:** the name must be `webhook-secret`. + +``` +cat <webhook-secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: webhook-secret + namespace: webhook-interceptor +type: Opaque +stringData: + github.com: "" + github.com_foo: "" + github.com_foo_bar: "" +EOF +``` + # Build and run Create git authentication secret: +**NOTE:** the name must be `git-auth`. + ``` cat <secret.yaml apiVersion: v1 @@ -67,6 +92,8 @@ metadata: type: Opaque stringData: github.com: ":" + github.com_foo: ":" + github.com_foo_bar: ":" EOF ``` diff --git a/build.sh b/build.sh index a986ac5..f5ea3cd 100755 --- a/build.sh +++ b/build.sh @@ -4,5 +4,6 @@ source env.txt kubectl -n webhook-interceptor delete deploy webhook-interceptor ko apply -f config/ kubectl apply -f secret.yaml +kubectl apply -f webhook-secret.yaml sleep 10 kubectl -n webhook-interceptor logs -f $(kubectl -n webhook-interceptor get pod -ojsonpath='{.items[0].metadata.name}') diff --git a/git.go b/git.go index 91e9976..4b288a9 100644 --- a/git.go +++ b/git.go @@ -4,9 +4,14 @@ package main import ( "bytes" + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" + "hash" "net/http" "net/url" "strings" @@ -19,7 +24,10 @@ const ( contentType = "Content-Type" jsonType = "application/json" - secretName = "git-auth" + authSecretName = "git-auth" + validateSecretName = "webhook-secret" + + secretDelim = "_" ) type Git struct { @@ -27,17 +35,82 @@ type Git struct { cl *http.Client } -func (g *Git) getSecret(host string) (username, password string, err error) { - sec, err := g.sec.Get(secretName, metav1.GetOptions{}) +func makeKeys(host, owner, repo string) []string { + return []string{ + host + secretDelim + owner + secretDelim + repo, + host + secretDelim + owner, + host, + } +} + +func (g *Git) getValidateSecret(host, owner, repo string) (key []byte, ok bool) { + sec, err := g.sec.Get(validateSecretName, metav1.GetOptions{}) if err != nil { return } - s, ok := sec.Data[host] + for _, v := range makeKeys(host, owner, repo) { + key, ok = sec.Data[v] + if ok { + break + } + } + return +} + +func (g *Git) validate(r *http.Request, payload []byte, repo Repository) (valid bool) { + var ( + h hash.Hash + sig []byte + err error + ) + u, err := url.Parse(repo.CloneURL) + if err != nil { + return + } + key, ok := g.getValidateSecret(u.Host, repo.Owner.Login, repo.Name) + if !ok { + return + } + api := detectAPI(r) + switch api { + case giteaAPI: + sig, err = hex.DecodeString(r.Header.Get("X-Gitea-Signature")) + if err != nil { + return + } + h = hmac.New(sha256.New, key) + case githubAPI: + sig, err = hex.DecodeString(strings.TrimPrefix(r.Header.Get("X-Hub-Signature"), "sha1=")) + if err != nil { + return + } + h = hmac.New(sha1.New, key) + } + h.Write(payload) + return hmac.Equal(h.Sum(nil), sig) + +} + +func (g *Git) getAuthSecret(host, owner, repo string) (username, password string, err error) { + sec, err := g.sec.Get(authSecretName, metav1.GetOptions{}) + if err != nil { + return + } + var ( + secret []byte + ok bool + ) + for _, v := range makeKeys(host, owner, repo) { + secret, ok = sec.Data[v] + if ok { + break + } + } if !ok { err = fmt.Errorf("no key found for host %s in secret %s/%s", host, sec.Namespace, sec.Name) return } - a := strings.SplitN(string(s), ":", 2) + a := strings.SplitN(string(secret), ":", 2) if len(a) < 2 { err = fmt.Errorf("missing credentials for host %s in secret %s/%s", host, sec.Namespace, sec.Name) return @@ -47,8 +120,8 @@ func (g *Git) getSecret(host string) (username, password string, err error) { return } -func (g *Git) send(req *http.Request) (*http.Response, error) { - user, pass, err := g.getSecret(req.URL.Host) +func (g *Git) send(req *http.Request, owner, repo string) (*http.Response, error) { + user, pass, err := g.getAuthSecret(req.URL.Host, owner, repo) if err != nil { return nil, err } @@ -69,7 +142,7 @@ func (g *Git) getPullRequest(repo Repository, api, id string) (pr PullRequest, e if err != nil { return } - resp, err := g.send(req) + resp, err := g.send(req, repo.Owner.Login, repo.Name) if err != nil { return } @@ -97,7 +170,7 @@ func (g *Git) addLabel(repo Repository, api, label, color string) (labelID int64 return } - resp, err := g.send(req) + resp, err := g.send(req, repo.Owner.Login, repo.Name) if err != nil { err = fmt.Errorf("error getting labels of %s: %s", u, err) return @@ -121,6 +194,9 @@ func (g *Git) addLabel(repo Repository, api, label, color string) (labelID int64 return } buf := new(bytes.Buffer) + if api == githubAPI { + color = strings.TrimPrefix(color, "#") + } l := &Label{Name: label, Color: color} err = json.NewEncoder(buf).Encode(l) if err != nil { @@ -131,7 +207,7 @@ func (g *Git) addLabel(repo Repository, api, label, color string) (labelID int64 return } - resp, err = g.send(req) + resp, err = g.send(req, repo.Owner.Login, repo.Name) if err != nil { err = fmt.Errorf("error creating label %s for %s: %s", label, u, err) return @@ -162,7 +238,7 @@ func (g *Git) setLabel(repo Repository, api, id, label, color string, remove []s if err != nil { return } - resp, err := g.send(req) + resp, err := g.send(req, repo.Owner.Login, repo.Name) if err != nil { return fmt.Errorf("error getting issue labels of %s: %s", u, err) } @@ -207,7 +283,7 @@ func (g *Git) setLabel(repo Repository, api, id, label, color string, remove []s return } - resp, err = g.send(req) + resp, err = g.send(req, repo.Owner.Login, repo.Name) if err != nil { return fmt.Errorf("error setting issue label %s for %s: %s", label, u, err) } diff --git a/main.go b/main.go index ae34ced..a4d4574 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,8 @@ const ( commentAction = "X-Interceptor-Comment-Action" labelHeader = "X-Interceptor-Label" removeLabelsHeader = "X-Interceptor-Remove-Labels" + includeHeader = "X-Interceptor-Include-Repo" + excludeHeader = "X-Interceptor-Exclude-Repo" githubEvent = "X-GitHub-Event" giteaEvent = "X-Gitea-Event" @@ -42,28 +44,33 @@ type Handler struct { } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var valid bool event := r.Header.Get(githubEvent) switch event { case "pull_request": - h.prHandler(w, r) + valid = h.prHandler(w, r) case "push": - h.pushHandler(w, r) + valid = h.pushHandler(w, r) case "issue_comment": - h.commentHandler(w, r) + valid = h.commentHandler(w, r) default: h.dumpHandler(w, r) } status := int(reflect.Indirect(reflect.ValueOf(w)).FieldByName("status").Int()) - log.Printf("%s %s %s event:%s status:%d", r.RemoteAddr, r.Method, r.URL, event, status) + log.Printf("%s %s %s event:%s valid:%t status:%d", r.RemoteAddr, r.Method, r.URL, event, valid, status) } -func (h *Handler) prHandler(w http.ResponseWriter, r *http.Request) { +func (h *Handler) prHandler(w http.ResponseWriter, r *http.Request) (valid bool) { var prb PullRequestBody body, err := readBody(r.Body, &prb) if err != nil { logError(w, err.Error()) return } + if valid = h.git.validate(r, body, prb.Repository); !valid { + w.WriteHeader(http.StatusBadRequest) + return + } if contains(r.Header[prAction], prb.Action) { if l := r.Header.Get(labelHeader); l != "" { @@ -85,33 +92,54 @@ func (h *Handler) prHandler(w http.ResponseWriter, r *http.Request) { return } w.WriteHeader(http.StatusBadRequest) + return } -func (h *Handler) pushHandler(w http.ResponseWriter, r *http.Request) { +func (h *Handler) pushHandler(w http.ResponseWriter, r *http.Request) (valid bool) { var pb PushBody body, err := readBody(r.Body, &pb) if err != nil { logError(w, err.Error()) return } + if valid = h.git.validate(r, body, pb.Repository); !valid { + w.WriteHeader(http.StatusBadRequest) + return + } + push := r.Header.Get(pushRef) //if contains(r.Header[pushAction], pb.Ref) { if push != "" && push == pb.Ref { + inc := r.Header[includeHeader] + exc := r.Header[excludeHeader] + if inc != nil && !contains(inc, pb.Repository.FullName) { + w.WriteHeader(http.StatusBadRequest) + return + } + if exc != nil && contains(exc, pb.Repository.FullName) { + w.WriteHeader(http.StatusBadRequest) + return + } h := w.Header() h.Set(commitHeader, pb.After) w.Write(body) return } w.WriteHeader(http.StatusBadRequest) + return } -func (h *Handler) commentHandler(w http.ResponseWriter, r *http.Request) { +func (h *Handler) commentHandler(w http.ResponseWriter, r *http.Request) (valid bool) { var cb CommentBody body, err := readBody(r.Body, &cb) if err != nil { logError(w, err.Error()) return } + if valid = h.git.validate(r, body, cb.Repository); !valid { + w.WriteHeader(http.StatusBadRequest) + return + } if !contains(r.Header[commentAction], cb.Action) { w.WriteHeader(http.StatusBadRequest) @@ -163,6 +191,7 @@ func (h *Handler) commentHandler(w http.ResponseWriter, r *http.Request) { return } w.WriteHeader(http.StatusBadRequest) + return } func (h *Handler) dumpHandler(w http.ResponseWriter, r *http.Request) { diff --git a/types.go b/types.go index 53cc128..d4f5126 100644 --- a/types.go +++ b/types.go @@ -6,6 +6,7 @@ type PullRequestBody struct { Action string `json:"action"` Number int64 `json:"number"` PullRequest PullRequest `json:"pull_request"` + Repository Repository `json:"repository"` } type PullRequest struct { @@ -32,8 +33,9 @@ type PostLabelsInt struct { } type PushBody struct { - Ref string `json:"ref"` - After string `json:"after"` + Ref string `json:"ref"` + After string `json:"after"` + Repository Repository `json:"repository"` } type CommentBody struct { @@ -54,6 +56,7 @@ type Comment struct { type Repository struct { Name string `json:"name"` + FullName string `json:"full_name"` Owner Owner `json:"owner"` CloneURL string `json:"clone_url"` }