Skip to content

Commit 0c647e1

Browse files
committed
feat: Add JSON body support for incoming webhook parameters
* Implemented support for passing incoming webhook parameters in the JSON request body. * Marked the legacy method using URL query parameters for secrets as insecure. * Added a warning log when the legacy method is detected. * Updated documentation to describe the new recommended method and the deprecated legacy method. * Updated end-to-end tests to cover both legacy and new methods. Fixes #1093 JIRA: https://issues.redhat.com/browse/SRVKP-7678 Signed-off-by: Chmouel Boudjnah <[email protected]>
1 parent 94e4bbd commit 0c647e1

File tree

5 files changed

+286
-33
lines changed

5 files changed

+286
-33
lines changed

docs/content/docs/guide/incoming_webhook.md

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -87,19 +87,28 @@ stringData:
8787

8888
After setting this up, you will be able to start the PipelineRun with a POST
8989
request sent to the controller URL appended with /incoming. The request
90-
includes the very-secure-shared-secret, the repository name (repo), the target
91-
branch (main), and the PipelineRun name.
90+
can include the very-secure-shared-secret, the repository name (repo), the
91+
target branch (main), and the PipelineRun name either as URL query parameters
92+
(legacy, insecure, and deprecated) or in the POST JSON body (recommended).
9293

93-
You can use the `generateName` field as the PipelineRun name but you will need to make sure to specify the hyphen (-) at the end.
94+
You can use the `generateName` field as the PipelineRun name but you will need
95+
to make sure to specify the hyphen (-) at the end.
9496

95-
As an example here is a curl snippet starting the PipelineRun:
97+
#### Legacy (URL query) method (deprecated)
9698

9799
```shell
98100
curl -X POST 'https://control.pac.url/incoming?secret=very-secure-shared-secret&repository=repo&branch=main&pipelinerun=target-pipelinerun'
99101
```
100102

101-
in this snippet, note two things the `"/incoming"` path to the controller URL
102-
and the `"POST"` method to the URL rather than a simple `"GET"`.
103+
**Warning:** Passing secrets in the URL is insecure and will be deprecated. Please use the POST body method below.
104+
105+
#### Recommended (POST JSON body) method
106+
107+
```shell
108+
curl -H "Content-Type: application/json" -X POST "https://control.pac.url/incoming" -d '{"repository":"repo","branch":"main","pipelinerun":"target-pipelinerun","secret":"very-secure-shared-secret"}'
109+
```
110+
111+
In both cases, the `"/incoming"` path to the controller URL and the `"POST"` method will remain unchanged.
103112

104113
It is important to note that when the PipelineRun is triggered, Pipelines as
105114
Code will treat it as a push event and will have the capability to report the
@@ -110,17 +119,17 @@ provides guidance on how to achieve this.
110119

111120
### Passing dynamic parameter value to incoming webhook
112121

113-
You can define the value of a any Pipelines-as Code Parameters (including
114-
redefining the [builtin ones](../authoringprs#default-parameters).
122+
You can define the value of any Pipelines-as-Code Parameters (including
123+
redefining the [builtin ones](../authoringprs#default-parameters)).
115124

116125
You need to list the overridden or added params in the params section of the
117-
Repo CR configuration and pass the value in the json body of the incoming webhook
126+
Repo CR configuration and pass the value in the JSON body of the incoming webhook
118127
request.
119128

120-
You will need to pass the `content-type` as `application/json` in the header of
129+
You will need to pass the `Content-Type` as `application/json` in the header of
121130
your URL request.
122131

123-
Here is a Repository CR letting passing the `pull_request_number` dynamic variable:
132+
Here is a Repository CR allowing passing the `pull_request_number` dynamic variable:
124133

125134
```yaml
126135
---
@@ -144,10 +153,11 @@ spec:
144153
and here is a curl snippet passing the `pull_request_number` value:
145154

146155
```shell
147-
curl -H "Content-Type: application/json" -X POST "https://control.pac.url/incoming?repository=repo&branch=main&secret=very-secure-shared-secret&pipelinerun=target-pipelinerun" -d '{"params": {"pull_request_number": "12345"}}'
156+
curl -H "Content-Type: application/json" -X POST "https://control.pac.url/incoming" -d '{"repository":"repo","branch":"main","pipelinerun":"target-pipelinerun","secret":"very-secure-shared-secret","params": {"pull_request_number": "12345"}}'
148157
```
149158

150-
The parameter value of `pull_request_number` will be set to `12345` when using the variable `{{pull_request_number}}` in your PipelineRun.
159+
The parameter value of `pull_request_number` will be set to `12345` when using
160+
the variable `{{pull_request_number}}` in your PipelineRun.
151161

152162
### Using incoming webhook with GitHub Enterprise application
153163

pkg/adapter/incoming.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package adapter
33
import (
44
"context"
55
"crypto/subtle"
6+
"encoding/json"
67
"fmt"
78
"net/http"
89

@@ -50,14 +51,46 @@ func applyIncomingParams(req *http.Request, payloadBody []byte, params []string)
5051
}
5152

5253
func (l *listener) detectIncoming(ctx context.Context, req *http.Request, payloadBody []byte) (bool, *v1alpha1.Repository, error) {
54+
// Support both legacy (URL query) and new (POST body) secret passing
5355
repository := req.URL.Query().Get("repository")
54-
querySecret := req.URL.Query().Get("secret")
55-
pipelineRun := req.URL.Query().Get("pipelinerun")
5656
branch := req.URL.Query().Get("branch")
57+
pipelineRun := req.URL.Query().Get("pipelinerun")
58+
querySecret := req.URL.Query().Get("secret")
59+
legacyMode := false
5760

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

pkg/adapter/incoming_test.go

Lines changed: 162 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ func Test_listener_detectIncoming(t *testing.T) {
182182
},
183183
},
184184
},
185-
method: "POST",
185+
method: http.MethodPost,
186186
queryURL: "/incoming",
187187
queryRepository: "test-good",
188188
querySecret: "verysecrete",
@@ -223,7 +223,7 @@ func Test_listener_detectIncoming(t *testing.T) {
223223
},
224224
},
225225
},
226-
method: "POST",
226+
method: http.MethodPost,
227227
queryURL: "/incoming",
228228
queryRepository: "test-good",
229229
querySecret: "verysecrete",
@@ -235,7 +235,7 @@ func Test_listener_detectIncoming(t *testing.T) {
235235
{
236236
name: "invalid incoming body",
237237
args: args{
238-
method: "POST",
238+
method: http.MethodPost,
239239
queryURL: "/incoming",
240240
queryRepository: "test-good",
241241
querySecret: "verysecrete",
@@ -563,7 +563,7 @@ func Test_listener_detectIncoming(t *testing.T) {
563563
},
564564
},
565565
},
566-
method: "POST",
566+
method: http.MethodPost,
567567
queryURL: "/incoming",
568568
queryRepository: "test-good",
569569
querySecret: "verysecrete",
@@ -600,7 +600,7 @@ func Test_listener_detectIncoming(t *testing.T) {
600600
},
601601
},
602602
},
603-
method: "POST",
603+
method: http.MethodPost,
604604
queryURL: "/incoming",
605605
queryRepository: "test-good",
606606
querySecret: "verysecrete",
@@ -641,7 +641,7 @@ func Test_listener_detectIncoming(t *testing.T) {
641641
},
642642
},
643643
},
644-
method: "POST",
644+
method: http.MethodPost,
645645
queryURL: "/incoming",
646646
queryRepository: "test-good",
647647
querySecret: "verysecrete",
@@ -913,3 +913,159 @@ func TestApplyIncomingParams(t *testing.T) {
913913
})
914914
}
915915
}
916+
917+
func Test_detectIncoming_legacy_warning(t *testing.T) {
918+
ctx, _ := rtesting.SetupFakeContext(t)
919+
testNamespace := &corev1.Namespace{
920+
ObjectMeta: metav1.ObjectMeta{
921+
Name: "pipelinesascode",
922+
},
923+
}
924+
ctx = info.StoreCurrentControllerName(ctx, "default")
925+
ctx = info.StoreNS(ctx, testNamespace.GetName())
926+
cs, _ := testclient.SeedTestData(t, ctx, testclient.Data{
927+
Repositories: []*v1alpha1.Repository{
928+
{
929+
ObjectMeta: metav1.ObjectMeta{Name: "test-good"},
930+
Spec: v1alpha1.RepositorySpec{
931+
URL: "https://matched/by/incoming",
932+
Incomings: &[]v1alpha1.Incoming{{
933+
Targets: []string{"main"},
934+
Secret: v1alpha1.Secret{Name: "good-secret"},
935+
Params: []string{"foo", "bar"},
936+
}},
937+
GitProvider: &v1alpha1.GitProvider{Type: "github"},
938+
},
939+
},
940+
},
941+
})
942+
client := &params.Run{
943+
Clients: clients.Clients{
944+
PipelineAsCode: cs.PipelineAsCode,
945+
Kube: cs.Kube,
946+
},
947+
Info: info.Info{
948+
Controller: &info.ControllerInfo{Secret: info.DefaultPipelinesAscodeSecretName},
949+
},
950+
}
951+
tests := []struct {
952+
name string
953+
req *http.Request
954+
body []byte
955+
expectWarning bool
956+
}{
957+
{
958+
name: "legacy mode - params in URL",
959+
req: httptest.NewRequest(http.MethodPost,
960+
"http://localhost/incoming?repository=test-good&secret=verysecrete&pipelinerun=pipelinerun1&branch=main",
961+
strings.NewReader("")),
962+
body: nil,
963+
expectWarning: true,
964+
},
965+
{
966+
name: "new mode - params in JSON body",
967+
req: func() *http.Request {
968+
payload := `{
969+
"repository": "test-good",
970+
"branch": "main",
971+
"pipelinerun": "pipelinerun2",
972+
"secret": "verysecrete",
973+
"params": {"foo": "bar"}
974+
}`
975+
r := httptest.NewRequest(http.MethodPost,
976+
"http://localhost/incoming",
977+
strings.NewReader(payload))
978+
r.Header.Set("Content-Type", "application/json")
979+
return r
980+
}(),
981+
body: []byte(`{"repository":"test-good","branch":"main","pipelinerun":"pipelinerun2","secret":"verysecrete","params":{"foo":"bar"}}`),
982+
expectWarning: false,
983+
},
984+
}
985+
986+
for _, tt := range tests {
987+
t.Run(tt.name, func(t *testing.T) {
988+
observer, observedLogs := zapobserver.New(zap.InfoLevel)
989+
logger := zap.New(observer).Sugar()
990+
kint := &kubernetestint.KinterfaceTest{GetSecretResult: map[string]string{"good-secret": "verysecrete"}}
991+
l := &listener{
992+
run: client,
993+
logger: logger,
994+
kint: kint,
995+
event: info.NewEvent(),
996+
}
997+
got, _, err := l.detectIncoming(ctx, tt.req, tt.body)
998+
assert.NilError(t, err)
999+
assert.Assert(t, got)
1000+
found := false
1001+
for _, entry := range observedLogs.All() {
1002+
if strings.Contains(entry.Message, "[SECURITY] Incoming webhook used legacy URL-based secret passing") {
1003+
found = true
1004+
break
1005+
}
1006+
}
1007+
if tt.expectWarning {
1008+
assert.Assert(t, found, "expected security warning log for legacy URL-based secret passing")
1009+
} else {
1010+
assert.Assert(t, !found, "did not expect security warning log for new mode")
1011+
}
1012+
})
1013+
}
1014+
}
1015+
1016+
func Test_detectIncoming_body_params_are_parsed(t *testing.T) {
1017+
ctx, _ := rtesting.SetupFakeContext(t)
1018+
testNamespace := &corev1.Namespace{
1019+
ObjectMeta: metav1.ObjectMeta{
1020+
Name: "pipelinesascode",
1021+
},
1022+
}
1023+
ctx = info.StoreCurrentControllerName(ctx, "default")
1024+
ctx = info.StoreNS(ctx, testNamespace.GetName())
1025+
cs, _ := testclient.SeedTestData(t, ctx, testclient.Data{
1026+
Repositories: []*v1alpha1.Repository{
1027+
{
1028+
ObjectMeta: metav1.ObjectMeta{Name: "test-good"},
1029+
Spec: v1alpha1.RepositorySpec{
1030+
URL: "https://matched/by/incoming",
1031+
Incomings: &[]v1alpha1.Incoming{{
1032+
Targets: []string{"main"},
1033+
Secret: v1alpha1.Secret{Name: "good-secret"},
1034+
Params: []string{"foo", "bar"},
1035+
}},
1036+
GitProvider: &v1alpha1.GitProvider{Type: "github"},
1037+
},
1038+
},
1039+
},
1040+
})
1041+
client := &params.Run{
1042+
Clients: clients.Clients{
1043+
PipelineAsCode: cs.PipelineAsCode,
1044+
Kube: cs.Kube,
1045+
},
1046+
Info: info.Info{
1047+
Controller: &info.ControllerInfo{Secret: info.DefaultPipelinesAscodeSecretName},
1048+
},
1049+
}
1050+
payload := `{
1051+
"repository": "test-good",
1052+
"branch": "main",
1053+
"pipelinerun": "pipelinerun2",
1054+
"secret": "verysecrete",
1055+
"params": {"foo": "bar", "bar": "baz"}
1056+
}`
1057+
req := httptest.NewRequest(http.MethodPost,
1058+
"http://localhost/incoming",
1059+
strings.NewReader(payload))
1060+
req.Header.Set("Content-Type", "application/json")
1061+
kint := &kubernetestint.KinterfaceTest{GetSecretResult: map[string]string{"good-secret": "verysecrete"}}
1062+
l := &listener{
1063+
run: client,
1064+
logger: zap.NewNop().Sugar(),
1065+
kint: kint,
1066+
event: info.NewEvent(),
1067+
}
1068+
got, _, err := l.detectIncoming(ctx, req, []byte(payload))
1069+
assert.NilError(t, err)
1070+
assert.Assert(t, got)
1071+
}

test/github_incoming_test.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package test
55

66
import (
77
"context"
8+
"encoding/json"
89
"fmt"
910
"net/http"
1011
"net/url"
@@ -119,12 +120,35 @@ func verifyIncomingWebhook(t *testing.T, randomedString, pipelinerunName string,
119120
assert.NilError(t, err)
120121
runcnx.Clients.Log.Infof("Commit %s has been created and pushed to branch %s", sha, vref.GetURL())
121122

122-
incomingURL := fmt.Sprintf("%s/incoming?repository=%s&branch=%s&pipelinerun=%s&secret=%s", opts.ControllerURL,
123-
randomedString, randomedString, pipelinerunName, incomingSecreteValue)
124-
body := `{"params":{"the_best_superhero_is":"Superman"}}`
123+
var req *http.Request
124+
var incomingURL string
125125
client := &http.Client{}
126-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, incomingURL, strings.NewReader(body))
127-
req.Header.Add("Content-Type", "application/json")
126+
127+
if onWebhook {
128+
// Legacy URL query parameters method
129+
incomingURL = fmt.Sprintf("%s/incoming?repository=%s&branch=%s&pipelinerun=%s&secret=%s",
130+
opts.ControllerURL, randomedString, randomedString, pipelinerunName, incomingSecreteValue)
131+
req, err = http.NewRequestWithContext(ctx, http.MethodPost, incomingURL, strings.NewReader(`{"params":{"the_best_superhero_is":"Superman"}}`))
132+
assert.NilError(t, err)
133+
req.Header.Add("Content-Type", "application/json")
134+
} else {
135+
// JSON body method
136+
incomingURL = fmt.Sprintf("%s/incoming", opts.ControllerURL)
137+
jsonBody := map[string]interface{}{
138+
"repository": randomedString,
139+
"branch": randomedString,
140+
"pipelinerun": pipelinerunName,
141+
"secret": incomingSecreteValue,
142+
"params": map[string]string{
143+
"the_best_superhero_is": "Superman",
144+
},
145+
}
146+
jsonData, err := json.Marshal(jsonBody)
147+
assert.NilError(t, err)
148+
req, err = http.NewRequestWithContext(ctx, http.MethodPost, incomingURL, strings.NewReader(string(jsonData)))
149+
assert.NilError(t, err)
150+
req.Header.Add("Content-Type", "application/json")
151+
}
128152
if onSecondController {
129153
urlParse, _ := url.Parse(*ghprovider.APIURL)
130154
req.Header.Add("X-GitHub-Enterprise-Host", urlParse.Host)

0 commit comments

Comments
 (0)