@@ -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+
3092func 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.
57115func (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
186216func (l * listener ) processIncoming (targetRepo * v1alpha1.Repository ) (provider.Interface , * zap.SugaredLogger , error ) {
0 commit comments