-
Notifications
You must be signed in to change notification settings - Fork 117
feat: Add support for optional namespace
parameter in incoming webhook
#2290
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ package adapter | |
import ( | ||
"context" | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
|
@@ -171,8 +172,17 @@ func (l listener) handleEvent(ctx context.Context) http.HandlerFunc { | |
|
||
isIncoming, targettedRepo, err := l.detectIncoming(ctx, request, payload) | ||
if err != nil { | ||
l.logger.Errorf("error processing incoming webhook: %v", err) | ||
return | ||
if errors.Is(err, errDeprecatedRequestMode) { | ||
// TODO: change this to a request failure once the deprecation is removed | ||
// In this specific case the error can be non-nil while the rest of the return values are valid | ||
response.Header().Add("Deprecation", "true") | ||
} else { | ||
if errors.Is(err, errMissingFields) { | ||
l.writeResponse(response, http.StatusBadRequest, err.Error()) | ||
} | ||
Comment on lines
+180
to
+182
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The idea here being: If the required fields are missing then an |
||
l.logger.Errorf("error processing incoming webhook: %v", err) | ||
return | ||
Comment on lines
+180
to
+184
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The error handling here is improved, but it still doesn't handle all error cases correctly. If You should handle all other errors by sending an appropriate HTTP status code, for example, if errors.Is(err, errMissingFields) {
l.writeResponse(response, http.StatusBadRequest, err.Error())
} else {
l.writeResponse(response, http.StatusInternalServerError, "Error processing incoming webhook")
}
l.logger.Errorf("error processing incoming webhook: %v", err)
return There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't come back with custom HTTP Status code currently, it's not a bad idea, but this hasn't been requested yet, so we may skip it |
||
} | ||
} | ||
|
||
if isIncoming { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,8 +4,10 @@ import ( | |
"context" | ||
"crypto/subtle" | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"net/http" | ||
"slices" | ||
|
||
apincoming "github.com/openshift-pipelines/pipelines-as-code/pkg/apis/incoming" | ||
"github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1" | ||
|
@@ -23,6 +25,68 @@ import ( | |
"go.uber.org/zap" | ||
) | ||
|
||
var errDeprecatedRequestMode = errors.New("requesting with secret name in query parameters is deprecated and will not be supported in future versions") | ||
|
||
var errMissingFields = errors.New("missing required fields") | ||
|
||
func errMissingSpecificFields(fields []string) error { | ||
return fmt.Errorf("%w: %s", errMissingFields, fields) | ||
} | ||
|
||
type incomingPayload struct { | ||
legacyMode bool // indicates the request was made using the deprecated queryparams method | ||
|
||
RepoName string `json:"repository"` | ||
Namespace string `json:"namespace,omitempty"` // Optional unless Repository name is not unique | ||
Branch string `json:"branch"` | ||
PipelineRun string `json:"pipelinerun"` | ||
Secret string `json:"secret"` | ||
Params map[string]any `json:"params"` | ||
} | ||
Comment on lines
+28
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @aThorp96 I see a lot done for deprecation, can we have it in separate commit for better tracking? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah i agree i think we can add the feature in one PR and refactoring and depreactioin in the other There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I can factor that into a separate PR. |
||
|
||
func (payload *incomingPayload) validate() error { | ||
missingFields := []string{} | ||
|
||
for field, value := range map[string]string{ | ||
"repository": payload.RepoName, | ||
"branch": payload.Branch, | ||
"pipelinerun": payload.PipelineRun, | ||
"secret": payload.Secret, | ||
} { | ||
if value == "" { | ||
missingFields = append(missingFields, field) | ||
} | ||
} | ||
|
||
if len(missingFields) > 0 { | ||
return errMissingSpecificFields(missingFields) | ||
} | ||
return nil | ||
} | ||
|
||
// parseIncomingPayload parses and validates the incoming payload. | ||
func parseIncomingPayload(request *http.Request, payloadBody []byte) (incomingPayload, error) { | ||
parsedPayload := incomingPayload{ | ||
RepoName: request.URL.Query().Get("repository"), | ||
Branch: request.URL.Query().Get("branch"), | ||
PipelineRun: request.URL.Query().Get("pipelinerun"), | ||
Secret: request.URL.Query().Get("secret"), | ||
Namespace: request.URL.Query().Get("namespace"), | ||
legacyMode: true, | ||
} | ||
|
||
if parsedPayload.validate() != nil { | ||
if request.Method == http.MethodPost && request.Header.Get("Content-Type") == "application/json" && len(payloadBody) > 0 { | ||
parsedPayload = incomingPayload{legacyMode: false} | ||
if err := json.Unmarshal(payloadBody, &parsedPayload); err != nil { | ||
return parsedPayload, fmt.Errorf("invalid JSON body for incoming webhook: %w", err) | ||
} | ||
} | ||
} | ||
|
||
return parsedPayload, parsedPayload.validate() | ||
} | ||
|
||
func compareSecret(incomingSecret, secretValue string) bool { | ||
return subtle.ConstantTimeCompare([]byte(incomingSecret), []byte(secretValue)) != 0 | ||
} | ||
|
@@ -36,86 +100,55 @@ func applyIncomingParams(req *http.Request, payloadBody []byte, params []string) | |
return apincoming.Payload{}, fmt.Errorf("error parsing incoming payload, not the expected format?: %w", err) | ||
} | ||
for k := range payload.Params { | ||
allowed := false | ||
for _, allowedP := range params { | ||
if k == allowedP { | ||
allowed = true | ||
break | ||
} | ||
} | ||
if !allowed { | ||
if !slices.Contains(params, k) { | ||
return apincoming.Payload{}, fmt.Errorf("param %s is not allowed in incoming webhook CR", k) | ||
} | ||
} | ||
return payload, nil | ||
} | ||
|
||
// detectIncoming checks if the request is for an "incoming" webhook request. | ||
// If the request is for an "incoming" webhook request the request is parsed and matched to the expected | ||
// repository. | ||
// | ||
// If the request is made using a deprecated mode, errDeprecatedRequestMode may be returned alongside otherwise valid return values. | ||
func (l *listener) detectIncoming(ctx context.Context, req *http.Request, payloadBody []byte) (bool, *v1alpha1.Repository, error) { | ||
// Support both legacy (URL query) and new (POST body) secret passing | ||
repository := req.URL.Query().Get("repository") | ||
branch := req.URL.Query().Get("branch") | ||
pipelineRun := req.URL.Query().Get("pipelinerun") | ||
querySecret := req.URL.Query().Get("secret") | ||
legacyMode := false | ||
|
||
if req.URL.Path != "/incoming" { | ||
return false, nil, nil | ||
} | ||
|
||
// If not all required query params are present, try to parse from JSON body | ||
if repository == "" || branch == "" || pipelineRun == "" || querySecret == "" { | ||
if req.Method == http.MethodPost && req.Header.Get("Content-Type") == "application/json" && len(payloadBody) > 0 { | ||
var body struct { | ||
Repository string `json:"repository"` | ||
Branch string `json:"branch"` | ||
PipelineRun string `json:"pipelinerun"` | ||
Secret string `json:"secret"` | ||
Params map[string]any `json:"params"` | ||
} | ||
if err := json.Unmarshal(payloadBody, &body); err == nil { | ||
repository = body.Repository | ||
branch = body.Branch | ||
pipelineRun = body.PipelineRun | ||
querySecret = body.Secret | ||
} else { | ||
return false, nil, fmt.Errorf("invalid JSON body for incoming webhook: %w", err) | ||
} | ||
} else { | ||
return false, nil, fmt.Errorf("missing query URL argument: pipelinerun, branch, repository, secret: '%s' '%s' '%s' '%s'", pipelineRun, branch, repository, querySecret) | ||
} | ||
} else { | ||
legacyMode = true | ||
} | ||
|
||
if legacyMode { | ||
l.logger.Infof("incoming request has been requested: %v", req.URL) | ||
payload, err := parseIncomingPayload(req, payloadBody) | ||
if payload.legacyMode { | ||
// Log this, even if the request is invalid | ||
l.logger.Warnf("[SECURITY] Incoming webhook used legacy URL-based secret passing. This is insecure and will be deprecated. Please use POST body instead.") | ||
} | ||
|
||
l.logger.Infof("incoming request has been requested: %v", req.URL) | ||
if pipelineRun == "" || repository == "" || querySecret == "" || branch == "" { | ||
err := fmt.Errorf("missing query URL argument: pipelinerun, branch, repository, secret: '%s' '%s' '%s' '%s'", pipelineRun, branch, repository, querySecret) | ||
if err != nil { | ||
return false, nil, err | ||
} | ||
|
||
repo, err := matcher.GetRepo(ctx, l.run, repository) | ||
repo, err := matcher.GetRepoByName(ctx, l.run, payload.RepoName, payload.Namespace) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If no namespace is provided, the zero value |
||
if err != nil { | ||
if errors.Is(err, matcher.ErrRepositoryNameConflict) { | ||
return false, nil, fmt.Errorf("%w: %w", err, errMissingSpecificFields([]string{"namespace"})) | ||
} | ||
return false, nil, fmt.Errorf("error getting repo: %w", err) | ||
} | ||
if repo == nil { | ||
return false, nil, fmt.Errorf("cannot find repository %s", repository) | ||
return false, nil, fmt.Errorf("cannot find repository %s", payload.RepoName) | ||
} | ||
|
||
if repo.Spec.Incomings == nil { | ||
return false, nil, fmt.Errorf("you need to have incoming webhooks rules in your repo spec, repo: %s", repository) | ||
return false, nil, fmt.Errorf("you need to have incoming webhooks rules in your repo spec, repo: %s", payload.RepoName) | ||
} | ||
|
||
hook := matcher.IncomingWebhookRule(branch, *repo.Spec.Incomings) | ||
hook := matcher.IncomingWebhookRule(payload.Branch, *repo.Spec.Incomings) | ||
if hook == nil { | ||
return false, nil, fmt.Errorf("branch '%s' has not matched any rules in repo incoming webhooks spec: %+v", branch, *repo.Spec.Incomings) | ||
return false, nil, fmt.Errorf("branch '%s' has not matched any rules in repo incoming webhooks spec: %+v", payload.Branch, *repo.Spec.Incomings) | ||
} | ||
|
||
// log incoming request | ||
l.logger.Infof("incoming request targeting pipelinerun %s on branch %s for repository %s has been accepted", pipelineRun, branch, repository) | ||
l.logger.Infof("incoming request targeting pipelinerun %s on branch %s for repository %s has been accepted", payload.PipelineRun, payload.Branch, payload.RepoName) | ||
|
||
secretOpts := ktypes.GetSecretOpt{ | ||
Namespace: repo.Namespace, | ||
|
@@ -131,7 +164,7 @@ func (l *listener) detectIncoming(ctx context.Context, req *http.Request, payloa | |
} | ||
|
||
// TODO: move to somewhere common to share between gitlab and here | ||
if !compareSecret(querySecret, secretValue) { | ||
if !compareSecret(payload.Secret, secretValue) { | ||
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) | ||
} | ||
|
||
|
@@ -164,14 +197,19 @@ func (l *listener) detectIncoming(ctx context.Context, req *http.Request, payloa | |
// eventType and vice versa, but keeping as is for now. | ||
l.event.EventType = "incoming" | ||
l.event.TriggerTarget = "push" | ||
l.event.TargetPipelineRun = pipelineRun | ||
l.event.HeadBranch = branch | ||
l.event.BaseBranch = branch | ||
l.event.TargetPipelineRun = payload.PipelineRun | ||
l.event.HeadBranch = payload.Branch | ||
l.event.BaseBranch = payload.Branch | ||
l.event.Request.Header = req.Header | ||
l.event.Request.Payload = payloadBody | ||
l.event.URL = repo.Spec.URL | ||
l.event.Sender = "incoming" | ||
return true, repo, nil | ||
|
||
// The caller can handle this error accordingly | ||
if payload.legacyMode { | ||
err = errDeprecatedRequestMode | ||
} | ||
return true, repo, err | ||
} | ||
|
||
func (l *listener) processIncoming(targetRepo *v1alpha1.Repository) (provider.Interface, *zap.SugaredLogger, error) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: this is new behavior and not particularly related to the principal change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i think we should use a X- prefixed header for that?