Skip to content

Commit 6fc679e

Browse files
aThorp96zakisk
authored andcommitted
feat: Add support for optional namespace parameter in incoming webhook
When a Repository CR does not have a unique name in the cluster, a user must disambiguate the desired Repository by specifying a Namespace. The namespace is optional by default, but if the matcher finds more than one Repository of the same name then PaC will respond with the code 400 and indicate that the `namespace` parameter is required due to the ambiguity.
1 parent baea213 commit 6fc679e

File tree

5 files changed

+240
-70
lines changed

5 files changed

+240
-70
lines changed

docs/content/docs/guide/incoming_webhook.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ weight: 50
77

88
Pipelines-as-Code supports the concept of incoming webhook URL. It lets you
99
trigger PipelineRuns in a Repository using a shared secret and URL,
10-
instead of creating a new code iteration.
10+
instead of creating a new code iteration. This allows users to trigger
11+
PipelineRuns using an HTTP request, e.g. with `curl` or from a webservice.
1112

1213
## Incoming Webhook URL
1314

@@ -33,6 +34,20 @@ values are:
3334
Whereas for `github-apps` this does not need to be added.
3435
{{< /hint >}}
3536

37+
### Required Parameters
38+
39+
Whether using the recommended POST request body or deprecated QueryParams,
40+
the `/incoming` request accepts the following parameters:
41+
42+
| Parameter | Type | Description | Required |
43+
|-------------|--------|--------------------------------------------------------------------------------------|---------------------------------------------------|
44+
|`repository` |`string`| Name of Repository CR | `true` |
45+
|`namespace` |`string`| Namespace with the Repository CR | When Repository name is not unique in the cluster |
46+
|`branch` |`string`| Branch configured for incoming webhook | `true` |
47+
|`pipelinerun`|`string`| Name (or generateName) of PipelineRun, used to match PipelineRun definition | `true` |
48+
|`secret` |`string`| Secret key referenced by the Repository CR in desired incoming webhook configuration | `true` |
49+
|`params` |`json` | Parameters to override in PipelineRun context | `false` |
50+
3651
### GitHub App
3752

3853
The example below illustrates the use of GitHub App to trigger a PipelineRun

pkg/adapter/adapter.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package adapter
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"net/http"
@@ -171,6 +172,9 @@ func (l listener) handleEvent(ctx context.Context) http.HandlerFunc {
171172

172173
isIncoming, targettedRepo, err := l.detectIncoming(ctx, request, payload)
173174
if err != nil {
175+
if errors.Is(err, errMissingFields) {
176+
l.writeResponse(response, http.StatusBadRequest, err.Error())
177+
}
174178
l.logger.Errorf("error processing incoming webhook: %v", err)
175179
return
176180
}

pkg/adapter/incoming.go

Lines changed: 86 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import (
44
"context"
55
"crypto/subtle"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"net/http"
10+
"slices"
911

1012
apincoming "github.com/openshift-pipelines/pipelines-as-code/pkg/apis/incoming"
1113
"github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1"
@@ -27,6 +29,66 @@ const (
2729
defaultIncomingWebhookSecretKey = "secret"
2830
)
2931

32+
var errMissingFields = errors.New("missing required fields")
33+
34+
func errMissingSpecificFields(fields []string) error {
35+
return fmt.Errorf("%w: %s", errMissingFields, fields)
36+
}
37+
38+
type incomingPayload struct {
39+
legacyMode bool // indicates the request was made using the deprecated queryparams method
40+
41+
RepoName string `json:"repository"`
42+
Namespace string `json:"namespace,omitempty"` // Optional unless Repository name is not unique
43+
Branch string `json:"branch"`
44+
PipelineRun string `json:"pipelinerun"`
45+
Secret string `json:"secret"`
46+
Params map[string]any `json:"params"`
47+
}
48+
49+
func (payload *incomingPayload) validate() error {
50+
missingFields := []string{}
51+
52+
for field, value := range map[string]string{
53+
"repository": payload.RepoName,
54+
"branch": payload.Branch,
55+
"pipelinerun": payload.PipelineRun,
56+
"secret": payload.Secret,
57+
} {
58+
if value == "" {
59+
missingFields = append(missingFields, field)
60+
}
61+
}
62+
63+
if len(missingFields) > 0 {
64+
return errMissingSpecificFields(missingFields)
65+
}
66+
return nil
67+
}
68+
69+
// parseIncomingPayload parses and validates the incoming payload.
70+
func parseIncomingPayload(request *http.Request, payloadBody []byte) (incomingPayload, error) {
71+
parsedPayload := incomingPayload{
72+
RepoName: request.URL.Query().Get("repository"),
73+
Branch: request.URL.Query().Get("branch"),
74+
PipelineRun: request.URL.Query().Get("pipelinerun"),
75+
Secret: request.URL.Query().Get("secret"),
76+
Namespace: request.URL.Query().Get("namespace"),
77+
legacyMode: true,
78+
}
79+
80+
if parsedPayload.validate() != nil {
81+
if request.Method == http.MethodPost && request.Header.Get("Content-Type") == "application/json" && len(payloadBody) > 0 {
82+
parsedPayload = incomingPayload{legacyMode: false}
83+
if err := json.Unmarshal(payloadBody, &parsedPayload); err != nil {
84+
return parsedPayload, fmt.Errorf("invalid JSON body for incoming webhook: %w", err)
85+
}
86+
}
87+
}
88+
89+
return parsedPayload, parsedPayload.validate()
90+
}
91+
3092
func compareSecret(incomingSecret, secretValue string) bool {
3193
return subtle.ConstantTimeCompare([]byte(incomingSecret), []byte(secretValue)) != 0
3294
}
@@ -40,86 +102,53 @@ func applyIncomingParams(req *http.Request, payloadBody []byte, params []string)
40102
return apincoming.Payload{}, fmt.Errorf("error parsing incoming payload, not the expected format?: %w", err)
41103
}
42104
for k := range payload.Params {
43-
allowed := false
44-
for _, allowedP := range params {
45-
if k == allowedP {
46-
allowed = true
47-
break
48-
}
49-
}
50-
if !allowed {
105+
if !slices.Contains(params, k) {
51106
return apincoming.Payload{}, fmt.Errorf("param %s is not allowed in incoming webhook CR", k)
52107
}
53108
}
54109
return payload, nil
55110
}
56111

112+
// detectIncoming checks if the request is for an "incoming" webhook request.
113+
// If the request is for an "incoming" webhook request the request is parsed and matched to the expected
114+
// repository.
57115
func (l *listener) detectIncoming(ctx context.Context, req *http.Request, payloadBody []byte) (bool, *v1alpha1.Repository, error) {
58-
// Support both legacy (URL query) and new (POST body) secret passing
59-
repository := req.URL.Query().Get("repository")
60-
branch := req.URL.Query().Get("branch")
61-
pipelineRun := req.URL.Query().Get("pipelinerun")
62-
querySecret := req.URL.Query().Get("secret")
63-
legacyMode := false
64-
65116
if req.URL.Path != "/incoming" {
66117
return false, nil, nil
67118
}
68119

69-
// If not all required query params are present, try to parse from JSON body
70-
if repository == "" || branch == "" || pipelineRun == "" || querySecret == "" {
71-
if req.Method == http.MethodPost && req.Header.Get("Content-Type") == "application/json" && len(payloadBody) > 0 {
72-
var body struct {
73-
Repository string `json:"repository"`
74-
Branch string `json:"branch"`
75-
PipelineRun string `json:"pipelinerun"`
76-
Secret string `json:"secret"`
77-
Params map[string]any `json:"params"`
78-
}
79-
if err := json.Unmarshal(payloadBody, &body); err == nil {
80-
repository = body.Repository
81-
branch = body.Branch
82-
pipelineRun = body.PipelineRun
83-
querySecret = body.Secret
84-
} else {
85-
return false, nil, fmt.Errorf("invalid JSON body for incoming webhook: %w", err)
86-
}
87-
} else {
88-
return false, nil, fmt.Errorf("missing query URL argument: pipelinerun, branch, repository, secret: '%s' '%s' '%s' '%s'", pipelineRun, branch, repository, querySecret)
89-
}
90-
} else {
91-
legacyMode = true
92-
}
93-
94-
if legacyMode {
120+
l.logger.Infof("incoming request has been requested: %v", req.URL)
121+
payload, err := parseIncomingPayload(req, payloadBody)
122+
if payload.legacyMode {
123+
// Log this, even if the request is invalid
95124
l.logger.Warnf("[SECURITY] Incoming webhook used legacy URL-based secret passing. This is insecure and will be deprecated. Please use POST body instead.")
96125
}
97-
98-
l.logger.Infof("incoming request has been requested: %v", req.URL)
99-
if pipelineRun == "" || repository == "" || querySecret == "" || branch == "" {
100-
err := fmt.Errorf("missing query URL argument: pipelinerun, branch, repository, secret: '%s' '%s' '%s' '%s'", pipelineRun, branch, repository, querySecret)
126+
if err != nil {
101127
return false, nil, err
102128
}
103129

104-
repo, err := matcher.GetRepo(ctx, l.run, repository)
130+
repo, err := matcher.GetRepoByName(ctx, l.run, payload.RepoName, payload.Namespace)
105131
if err != nil {
132+
if errors.Is(err, matcher.ErrRepositoryNameConflict) {
133+
return false, nil, fmt.Errorf("%w: %w", err, errMissingSpecificFields([]string{"namespace"}))
134+
}
106135
return false, nil, fmt.Errorf("error getting repo: %w", err)
107136
}
108137
if repo == nil {
109-
return false, nil, fmt.Errorf("cannot find repository %s", repository)
138+
return false, nil, fmt.Errorf("cannot find repository %s", payload.RepoName)
110139
}
111140

112141
if repo.Spec.Incomings == nil {
113-
return false, nil, fmt.Errorf("you need to have incoming webhooks rules in your repo spec, repo: %s", repository)
142+
return false, nil, fmt.Errorf("you need to have incoming webhooks rules in your repo spec, repo: %s", payload.RepoName)
114143
}
115144

116-
hook := matcher.IncomingWebhookRule(branch, *repo.Spec.Incomings)
145+
hook := matcher.IncomingWebhookRule(payload.Branch, *repo.Spec.Incomings)
117146
if hook == nil {
118-
return false, nil, fmt.Errorf("branch '%s' has not matched any rules in repo incoming webhooks spec: %+v", branch, *repo.Spec.Incomings)
147+
return false, nil, fmt.Errorf("branch '%s' has not matched any rules in repo incoming webhooks spec: %+v", payload.Branch, *repo.Spec.Incomings)
119148
}
120149

121150
// log incoming request
122-
l.logger.Infof("incoming request targeting pipelinerun %s on branch %s for repository %s has been accepted", pipelineRun, branch, repository)
151+
l.logger.Infof("incoming request targeting pipelinerun %s on branch %s for repository %s has been accepted", payload.PipelineRun, payload.Branch, payload.RepoName)
123152

124153
secretOpts := ktypes.GetSecretOpt{
125154
Namespace: repo.Namespace,
@@ -140,7 +169,7 @@ func (l *listener) detectIncoming(ctx context.Context, req *http.Request, payloa
140169
}
141170

142171
// TODO: move to somewhere common to share between gitlab and here
143-
if !compareSecret(querySecret, secretValue) {
172+
if !compareSecret(payload.Secret, secretValue) {
144173
return false, nil, fmt.Errorf("secret passed to the webhook does not match the incoming webhook secret set on repository CR in secret %s", hook.Secret.Name)
145174
}
146175

@@ -173,14 +202,15 @@ func (l *listener) detectIncoming(ctx context.Context, req *http.Request, payloa
173202
// eventType and vice versa, but keeping as is for now.
174203
l.event.EventType = "incoming"
175204
l.event.TriggerTarget = "push"
176-
l.event.TargetPipelineRun = pipelineRun
177-
l.event.HeadBranch = branch
178-
l.event.BaseBranch = branch
205+
l.event.TargetPipelineRun = payload.PipelineRun
206+
l.event.HeadBranch = payload.Branch
207+
l.event.BaseBranch = payload.Branch
179208
l.event.Request.Header = req.Header
180209
l.event.Request.Payload = payloadBody
181210
l.event.URL = repo.Spec.URL
182211
l.event.Sender = "incoming"
183-
return true, repo, nil
212+
213+
return true, repo, err
184214
}
185215

186216
func (l *listener) processIncoming(targetRepo *v1alpha1.Repository) (provider.Interface, *zap.SugaredLogger, error) {

0 commit comments

Comments
 (0)