Skip to content

Commit 404fc85

Browse files
committed
fix: Enforce webhook signature for Forgejo
Implemented signature validation for Gitea and Forgejo webhooks to ensure the authenticity of incoming requests. This involves checking the `X-Forgejo-Signature` and `X-Gitea-Signature` headers against a computed HMAC-SHA256 hash of the request payload using a configured webhook secret. The validation ensures that only requests originating from a trusted source with the correct secret are processed, enhancing the security of webhook integrations. This change also includes necessary test cases to verify the correct functioning of the validation logic. Jira: https://issues.redhat.com/browse/SRVKP-10609 Signed-off-by: Chmouel Boudjnah <chmouel@redhat.com>
1 parent 0ca9063 commit 404fc85

File tree

6 files changed

+173
-12
lines changed

6 files changed

+173
-12
lines changed

pkg/provider/gitea/gitea.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package gitea
22

33
import (
44
"context"
5+
"crypto/hmac"
6+
"crypto/sha256"
7+
"crypto/subtle"
58
"encoding/base64"
9+
"encoding/hex"
610
"encoding/json"
711
"fmt"
812
"net/http"
@@ -40,6 +44,9 @@ const (
4044
</td></tr>
4145
{{- end }}
4246
</table>`
47+
48+
ForgejoSignatureHeader = "X-Forgejo-Signature"
49+
GiteaSignatureHeader = "X-Gitea-Signature"
4350
)
4451

4552
// validate the struct to interface.
@@ -122,9 +129,36 @@ func (v *Provider) SetLogger(logger *zap.SugaredLogger) {
122129
v.Logger = logger
123130
}
124131

125-
func (v *Provider) Validate(_ context.Context, _ *params.Run, _ *info.Event) error {
126-
// TODO: figure out why gitea doesn't work with mac validation as github which seems to be the same
127-
v.Logger.Debug("no secret and signature found, skipping validation for gitea")
132+
func (v *Provider) Validate(_ context.Context, _ *params.Run, event *info.Event) error {
133+
signature := event.Request.Header.Get(ForgejoSignatureHeader)
134+
if signature == "" {
135+
signature = event.Request.Header.Get(GiteaSignatureHeader)
136+
}
137+
if signature == "" {
138+
return fmt.Errorf("no signature has been detected, for security reason we are not allowing webhooks without a secret")
139+
}
140+
141+
secret := event.Provider.WebhookSecret
142+
if secret == "" {
143+
return fmt.Errorf("no webhook secret has been set, in repository CR or secret")
144+
}
145+
146+
return validateSignature(signature, event.Request.Payload, []byte(secret))
147+
}
148+
149+
func validateSignature(signature string, payload, secret []byte) error {
150+
signatureBytes, err := hex.DecodeString(signature)
151+
if err != nil {
152+
return fmt.Errorf("gitea/forgejo webhook signature is not valid hex: %w", err)
153+
}
154+
155+
mac := hmac.New(sha256.New, secret)
156+
mac.Write(payload)
157+
expectedMAC := mac.Sum(nil)
158+
159+
if subtle.ConstantTimeCompare(signatureBytes, expectedMAC) != 1 {
160+
return fmt.Errorf("gitea/forgejo webhook signature validation failed")
161+
}
128162
return nil
129163
}
130164

pkg/provider/gitea/gitea_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package gitea
22

33
import (
44
"context"
5+
"crypto/hmac"
56
"crypto/sha256"
7+
"encoding/hex"
68
"fmt"
79
"io"
810
"net/http"
@@ -164,6 +166,115 @@ func TestProvider_CreateStatus(t *testing.T) {
164166
}
165167
}
166168

169+
func computeHMACSHA256(payload, secret []byte) string {
170+
mac := hmac.New(sha256.New, secret)
171+
mac.Write(payload)
172+
return hex.EncodeToString(mac.Sum(nil))
173+
}
174+
175+
func TestProvider_Validate(t *testing.T) {
176+
testPayload := []byte(`{"ref":"refs/heads/main"}`)
177+
testSecret := "mysecret"
178+
validSignature := computeHMACSHA256(testPayload, []byte(testSecret))
179+
180+
tests := []struct {
181+
name string
182+
signatureHeader string
183+
signature string
184+
secret string
185+
payload []byte
186+
wantErr string
187+
}{
188+
{
189+
name: "valid forgejo signature",
190+
signatureHeader: ForgejoSignatureHeader,
191+
signature: validSignature,
192+
secret: testSecret,
193+
payload: testPayload,
194+
},
195+
{
196+
name: "valid gitea signature",
197+
signatureHeader: GiteaSignatureHeader,
198+
signature: validSignature,
199+
secret: testSecret,
200+
payload: testPayload,
201+
},
202+
{
203+
name: "invalid signature mismatch",
204+
signatureHeader: ForgejoSignatureHeader,
205+
signature: computeHMACSHA256([]byte("wrong payload"), []byte(testSecret)),
206+
secret: testSecret,
207+
payload: testPayload,
208+
wantErr: "gitea/forgejo webhook signature validation failed",
209+
},
210+
{
211+
name: "invalid hex in signature",
212+
signatureHeader: ForgejoSignatureHeader,
213+
signature: "not-valid-hex!@#$",
214+
secret: testSecret,
215+
payload: testPayload,
216+
wantErr: "gitea/forgejo webhook signature is not valid hex",
217+
},
218+
{
219+
name: "signature present but no secret configured",
220+
signatureHeader: ForgejoSignatureHeader,
221+
signature: validSignature,
222+
secret: "",
223+
payload: testPayload,
224+
wantErr: "no webhook secret has been set, in repository CR or secret",
225+
},
226+
{
227+
name: "secret configured but no signature",
228+
signatureHeader: "",
229+
signature: "",
230+
secret: testSecret,
231+
payload: testPayload,
232+
wantErr: "no signature has been detected, for security reason we are not allowing webhooks without a secret",
233+
},
234+
{
235+
name: "no secret and no signature",
236+
signatureHeader: "",
237+
signature: "",
238+
secret: "",
239+
payload: testPayload,
240+
wantErr: "no signature has been detected, for security reason we are not allowing webhooks without a secret",
241+
},
242+
}
243+
244+
for _, tt := range tests {
245+
t.Run(tt.name, func(t *testing.T) {
246+
observer, _ := zapobserver.New(zap.InfoLevel)
247+
logger := zap.New(observer).Sugar()
248+
249+
header := http.Header{}
250+
if tt.signatureHeader != "" && tt.signature != "" {
251+
header.Set(tt.signatureHeader, tt.signature)
252+
}
253+
254+
event := &info.Event{
255+
Provider: &info.Provider{
256+
WebhookSecret: tt.secret,
257+
},
258+
Request: &info.Request{
259+
Header: header,
260+
Payload: tt.payload,
261+
},
262+
}
263+
264+
p := &Provider{Logger: logger}
265+
err := p.Validate(context.Background(), nil, event)
266+
267+
if tt.wantErr != "" {
268+
assert.Assert(t, err != nil, "expected error but got nil")
269+
assert.Assert(t, strings.Contains(err.Error(), tt.wantErr),
270+
"expected error to contain %q, got %q", tt.wantErr, err.Error())
271+
} else {
272+
assert.NilError(t, err)
273+
}
274+
})
275+
}
276+
}
277+
167278
func TestProvider_GetFiles(t *testing.T) {
168279
type args struct {
169280
runevent *info.Event

test/gitea_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,7 @@ func TestGiteaWithCLI(t *testing.T) {
625625

626626
output, err = tknpactest.ExecCommand(topts.ParamsRun, tknpacdelete.Root, "-n", topts.TargetNS, "repository", topts.TargetNS, "--cascade")
627627
assert.NilError(t, err)
628-
expectedOutput := fmt.Sprintf("secret %s has been deleted\nrepository %s has been deleted\n", topts.TargetNS, topts.TargetNS)
628+
expectedOutput := fmt.Sprintf("secret %s has been deleted\nsecret webhook-secret has been deleted\nrepository %s has been deleted\n", topts.TargetNS, topts.TargetNS)
629629
assert.Assert(t, output == expectedOutput, topts.TargetRefName, fmt.Sprintf("delete command should have this output: %s received: %s", expectedOutput, output))
630630
}
631631

test/pkg/gitea/crd.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package gitea
22

33
import (
44
"context"
5+
"os"
56

67
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
78
"github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1"
@@ -11,6 +12,8 @@ import (
1112
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1213
)
1314

15+
const webhookSecretName = "webhook-secret"
16+
1417
// CreateToken creates gitea token with all scopes.
1518
func CreateToken(topts *TestOpts) (string, error) {
1619
token, _, err := topts.GiteaCNX.Client().CreateAccessToken(forgejo.CreateAccessTokenOption{
@@ -48,14 +51,22 @@ func CreateCRD(ctx context.Context, topts *TestOpts, spec v1alpha1.RepositorySpe
4851
}
4952
}
5053

54+
_ = topts.ParamsRun.Clients.Kube.CoreV1().Secrets(ns).Delete(ctx, webhookSecretName, metav1.DeleteOptions{})
55+
webhookSecret, _ := os.LookupEnv("TEST_EL_WEBHOOK_SECRET")
56+
if err := secret.Create(ctx, topts.ParamsRun, map[string]string{"secret": webhookSecret}, ns, webhookSecretName); err != nil {
57+
return err
58+
}
59+
60+
if spec.GitProvider != nil {
61+
spec.GitProvider.WebhookSecret = &v1alpha1.Secret{Name: webhookSecretName, Key: "secret"}
62+
}
63+
repoName := ns
64+
if isGlobal {
65+
repoName = info.DefaultGlobalRepoName
66+
}
5167
repository := &v1alpha1.Repository{
5268
ObjectMeta: metav1.ObjectMeta{
53-
Name: func() string {
54-
if isGlobal {
55-
return info.DefaultGlobalRepoName
56-
}
57-
return topts.TargetNS
58-
}(),
69+
Name: repoName,
5970
},
6071
Spec: spec,
6172
}

test/pkg/gitea/scm.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func GetIssueTimeline(ctx context.Context, topts *TestOpts) (Timelines, error) {
106106
return tls, nil
107107
}
108108

109-
func CreateGiteaRepo(giteaClient *forgejo.Client, user, name, defaultBranch, hookURL string, onOrg bool, logger *zap.SugaredLogger) (*forgejo.Repository, error) {
109+
func CreateGiteaRepo(giteaClient *forgejo.Client, user, name, defaultBranch, hookURL, webhookSecret string, onOrg bool, logger *zap.SugaredLogger) (*forgejo.Repository, error) {
110110
var repo *forgejo.Repository
111111
var err error
112112
// Create a new repo
@@ -149,6 +149,7 @@ func CreateGiteaRepo(giteaClient *forgejo.Client, user, name, defaultBranch, hoo
149149
"name": "hook to smee url",
150150
"url": hookURL,
151151
"content_type": "json",
152+
"secret": webhookSecret,
152153
},
153154
Events: []string{"push", "issue_comments", "pull_request"},
154155
})

test/pkg/gitea/test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ func TestPR(t *testing.T, topts *TestOpts) (context.Context, func()) {
155155
topts.DefaultBranch = options.MainBranch
156156
}
157157

158-
repoInfo, err := CreateGiteaRepo(topts.GiteaCNX.Client(), topts.Opts.Organization, topts.TargetRepoName, topts.DefaultBranch, hookURL, topts.OnOrg, topts.ParamsRun.Clients.Log)
158+
webhookSecret := os.Getenv("TEST_EL_WEBHOOK_SECRET")
159+
repoInfo, err := CreateGiteaRepo(topts.GiteaCNX.Client(), topts.Opts.Organization,
160+
topts.TargetRepoName, topts.DefaultBranch, hookURL,
161+
webhookSecret, topts.OnOrg,
162+
topts.ParamsRun.Clients.Log)
159163
assert.NilError(t, err)
160164
topts.Opts.Repo = repoInfo.Name
161165
topts.Opts.Organization = repoInfo.Owner.UserName

0 commit comments

Comments
 (0)