Add fuzz target to make (#6828)

Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
6543
2026-07-19 00:41:14 +02:00
committed by GitHub
co-authored by Claude
parent 86d4363dbc
commit fd93e64e46
31 changed files with 849 additions and 9 deletions
+1
View File
@@ -21,6 +21,7 @@
### Release
- [ ] Test the latest container images to make sure they work as expected
- [ ] Executed `FUZZ_TIME=90s make fuzz` to make sure we stay hardened against user input
- [ ] Update `https://ci.woodpecker.org` to the latest version of `next` and verify that it works as expected
- [ ] Merge documentation PR (shortly before release)
- [ ] Merge the release PR to start the release pipeline
+11
View File
@@ -99,6 +99,17 @@ steps:
when:
- path: *when_path
test-fuzz:
depends_on:
- vendor
image: *golang_image
commands:
- make fuzz
environment:
FUZZ_TIME: 2s
when:
- path: *when_path
sqlite:
depends_on:
- vendor
+10
View File
@@ -209,6 +209,16 @@ test-e2e: ## Test by running yaml config and compare expected result
.PHONY: test
test: test-agent test-server test-server-datastore test-cli test-lib test-e2e ## Run all tests
FUZZ_TIME ?= 30s
fuzz: ## Run all fuzz targets for FUZZ_TIME (default 30s) each
@for pkg in $$(grep -rl --include='fuzz_test.go' 'func Fuzz' . | xargs -n1 dirname | sort -u); do \
for target in $$(grep -h -o 'func Fuzz[A-Za-z0-9_]*' $$pkg/fuzz_test.go | cut -d' ' -f2); do \
echo "fuzzing $$pkg $$target"; \
go test -tags 'test $(TAGS)' -run 'XXX_NONE' -fuzz "^$$target"'$$' -fuzztime $(FUZZ_TIME) "./$$pkg" || exit 1; \
done; \
done
##@ Build
build-ui: ## Build UI
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package metadata_test
import (
"testing"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata"
)
// FuzzEnvVarSubst exercises the envsubst evaluation of untrusted yaml
// (substitution expressions are attacker controlled) and checks that it
// never panics. Values containing newlines take the quoting code path.
func FuzzEnvVarSubst(f *testing.F) {
f.Add("image: golang:${GO_VERSION}", "1.26")
f.Add("cmd: ${CI_COMMIT_MESSAGE}", "line1\nline2")
f.Add("x: ${VAR=default}", "")
f.Add("y: ${VAR/./-}", "a.b.c")
f.Add("z: ${VAR:0:3}", "abcdef")
f.Fuzz(func(_ *testing.T, yaml, value string) {
environ := map[string]string{
"GO_VERSION": value,
"CI_COMMIT_MESSAGE": value,
"VAR": value,
}
_, _ = metadata.EnvVarSubst(yaml, environ)
})
}
@@ -0,0 +1,43 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package settings
import (
"testing"
"go.yaml.in/yaml/v4"
)
// FuzzParamsToEnv drives the reflection and recursion heavy plugin settings
// conversion with untrusted structures with secrets decoded from yaml and checks that it never panics.
func FuzzParamsToEnv(f *testing.F) {
f.Add("string: stringz\nint: 1\nfloat: 1.2\nbool: true")
f.Add("slice: [1, 2, 3]\nmap: { hello: world }")
f.Add("my_secret:\n from_secret: secret_token")
f.Add("nested:\n - a:\n from_secret: tok\n - b: [x, {c: d}]")
getSecret := func(name string) (string, error) {
return "secret_" + name, nil
}
f.Fuzz(func(_ *testing.T, data string) {
from := map[string]any{}
if err := yaml.Unmarshal([]byte(data), &from); err != nil {
return
}
to := map[string]string{}
_ = ParamsToEnv(from, to, "PLUGIN_", true, getSecret, nil)
})
}
@@ -0,0 +1,53 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package constraint
import (
"testing"
"go.yaml.in/yaml/v4"
)
// FuzzListMatch exercises doublestar glob matching with untrusted patterns
// and values from pipeline `when` constraints and checks that it never
// panics.
func FuzzListMatch(f *testing.F) {
f.Add("feat/**", "feat/a/b", "release/*")
f.Add("{main,dev}", "main", "")
f.Add("[a-z]*", "abc", "**")
f.Add(`\{esc`, "{esc", "*")
f.Fuzz(func(_ *testing.T, include, value, exclude string) {
c := List{
Include: []string{include},
Exclude: []string{exclude},
}
_ = c.Match(value)
})
}
// FuzzWhenUnmarshal exercises the custom yaml unmarshalers of the `when`
// constraint tree with untrusted yaml and checks that they never panic.
func FuzzWhenUnmarshal(f *testing.F) {
f.Add("event: push")
f.Add("- event: [push, tag]\n branch: main")
f.Add("evaluate: 'CI_COMMIT_MESSAGE contains \"x\"'")
f.Add("path:\n include: ['src/**']\n on_empty: true")
f.Fuzz(func(_ *testing.T, data string) {
when := When{}
_ = yaml.Unmarshal([]byte(data), &when)
})
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package yaml
import (
"testing"
)
// FuzzParseBytes exercises the whole workflow yaml parsing including all
// custom UnmarshalYAML implementations (constraints, container lists, string-or-slice types and so on)
// with untrusted input. The property checked is that parsing never panics.
func FuzzParseBytes(f *testing.F) {
f.Add([]byte(sampleYaml))
f.Add([]byte(simpleYamlAnchors))
f.Add([]byte("steps: { a: { image: alpine, commands: [ls] } }"))
f.Add([]byte("when:\n - event: push\n branch: [main, 'feat/**']"))
f.Add([]byte("matrix:\n GO: [1, 2]\nsteps:\n a:\n image: golang:${GO}"))
f.Add([]byte("steps:\n a:\n image: alpine\n settings:\n s:\n from_secret: token"))
f.Fuzz(func(_ *testing.T, data []byte) {
_, _ = ParseBytes(data)
})
}
@@ -0,0 +1,36 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package schema_test
import (
"testing"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter/schema"
)
// FuzzLintString feeds untrusted yaml through the json-schema based linter
// (yaml -> json conversion + gojsonschema validation) and checks that it
// never panics.
func FuzzLintString(f *testing.F) {
f.Add("steps: { a: { image: alpine, commands: [ls] } }")
f.Add("when:\n event: push\nsteps:\n a:\n image: alpine")
f.Add("skip_clone: true\nsteps: []")
f.Add("{}")
f.Add("- 1\n- 2")
f.Fuzz(func(_ *testing.T, data string) {
_, _ = schema.LintString(data)
})
}
@@ -0,0 +1,32 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package matrix
import (
"testing"
)
// FuzzParse exercises matrix axis parsing and permutation calculation with
// untrusted yaml and checks that it never panics.
func FuzzParse(f *testing.F) {
f.Add([]byte("matrix:\n GO: [1, 2]\n OS: [linux, windows]"))
f.Add([]byte("matrix:\n include:\n - GO: 1\n OS: linux"))
f.Add([]byte("matrix: {}"))
f.Add([]byte("matrix:\n A: [x]"))
f.Fuzz(func(_ *testing.T, data []byte) {
_, _ = Parse(data)
})
}
+5
View File
@@ -74,6 +74,11 @@ func calc(matrix Matrix) []Axis {
var perm int
var tags []string
for k, v := range matrix {
// an axis without values contributes no permutations and would
// cause a division by zero below
if len(v) == 0 {
continue
}
perm *= len(v)
if perm == 0 {
perm = len(v)
@@ -37,6 +37,14 @@ func TestMatrixEmpty(t *testing.T) {
assert.Empty(t, axis)
}
func TestMatrixEmptyAxis(t *testing.T) {
axis, err := ParseString("matrix:\n A: [a1, a2]\n EMPTY:")
assert.NoError(t, err)
assert.Len(t, axis, 2)
assert.Equal(t, "a1", axis[0]["A"])
assert.Equal(t, "a2", axis[1]["A"])
}
func TestMatrixIncluded(t *testing.T) {
axis, err := ParseString(fakeMatrixInclude)
assert.NoError(t, err)
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package utils
import (
"testing"
)
// FuzzImageMatching exercises container image reference normalization and
// matching (privileged plugin matching, registry hostname matching) with
// untrusted image names from pipeline configs and checks that it never
// panics.
func FuzzImageMatching(f *testing.F) {
f.Add("golang", "docker.io/library/golang:latest", "docker.io")
f.Add("codeberg.org/woodpecker-plugins/docker-buildx", "woodpecker-plugins/docker-buildx", "codeberg.org")
f.Add("image:tag@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "image", "index.docker.io")
f.Add("REGISTRY.example/Repo/Image:v1", "*", "registry.example")
f.Fuzz(func(_ *testing.T, from, to, hostname string) {
_, _ = ParseNamed(from)
_ = MatchImage(from, to)
_ = MatchImageDynamic(from, to)
_ = MatchHostname(from, hostname)
})
}
+6 -1
View File
@@ -82,7 +82,12 @@ func CalcNewNext(schedule, tzLoc string, now time.Time) (time.Time, error) {
if err != nil {
return time.Time{}, fmt.Errorf("cron parse schedule: %w", err)
}
return c.Next(now), nil
next := c.Next(now)
if next.IsZero() {
return time.Time{}, fmt.Errorf("cron schedule yields no future execution time")
}
return next, nil
}
func runCron(ctx context.Context, store store.Store, cron *model.Cron, now time.Time) error {
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cron
import (
"testing"
"time"
)
// FuzzCalcNewNext feeds untrusted, user supplied cron schedule and timezone
// strings into the schedule parser. The property checked is that parsing
// never panics and that a successfully calculated execution time is never in
// the past.
func FuzzCalcNewNext(f *testing.F) {
f.Add("@daily", "")
f.Add("*/5 * * * *", "Europe/Berlin")
f.Add("0 0 1 1 *", "UTC")
f.Add("60 25 * * *", "Not/AZone")
now := time.Unix(1257894000, 0)
f.Fuzz(func(t *testing.T, schedule, tzLoc string) {
next, err := CalcNewNext(schedule, tzLoc, now)
if err != nil {
return
}
if next.Before(now) {
t.Fatalf("next execution %v is before now %v for schedule %q tz %q", next, now, schedule, tzLoc)
}
})
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package forgejo
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// FuzzParseHooks feeds untrusted webhook payloads into every payload level
// hook parser (push, created, pull request and release). The property checked
// is that parsing never panics, no matter how malformed the payload is.
func FuzzParseHooks(f *testing.F) {
fixtures, err := filepath.Glob(filepath.Join("fixtures", "*.json"))
if err != nil {
f.Fatal(err)
}
for _, fixture := range fixtures {
data, err := os.ReadFile(fixture)
if err != nil {
f.Fatal(err)
}
f.Add(data)
}
f.Fuzz(func(_ *testing.T, data []byte) {
_, _, _ = parsePushHook(bytes.NewReader(data))
_, _, _ = parseCreatedHook(bytes.NewReader(data))
_, _, _ = parsePullRequestHook(bytes.NewReader(data))
_, _, _ = parseReleaseHook(bytes.NewReader(data))
})
}
+3
View File
@@ -53,6 +53,9 @@ func toRepo(from *forgejo.Repository) *model.Repo {
// toPerm converts a Forgejo permission to a Woodpecker permission.
func toPerm(from *forgejo.Permission) *model.Perm {
if from == nil {
return &model.Perm{}
}
return &model.Perm{
Pull: from.Pull,
Push: from.Push,
+12
View File
@@ -93,6 +93,9 @@ func parsePushHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipelin
if err != nil {
return nil, nil, err
}
if err := push.validate(); err != nil {
return nil, nil, err
}
// ignore push events for tags
if strings.HasPrefix(push.Ref, "refs/tags/") {
@@ -116,6 +119,9 @@ func parseCreatedHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipe
if err != nil {
return nil, nil, err
}
if err := push.validate(); err != nil {
return nil, nil, err
}
if push.RefType != refTag {
return nil, nil, nil
@@ -137,6 +143,9 @@ func parsePullRequestHook(payload io.Reader) (*model.Repo, *model.Pipeline, erro
if err != nil {
return nil, nil, err
}
if err := pr.validate(); err != nil {
return nil, nil, err
}
// Only trigger pipelines for supported event types
if !supportedAction(pr.Action) {
@@ -173,6 +182,9 @@ func parseReleaseHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) {
if err != nil {
return nil, nil, err
}
if err := release.validate(); err != nil {
return nil, nil, err
}
repo = toRepo(release.Repo)
pipeline = pipelineFromRelease(release)
+37
View File
@@ -778,3 +778,40 @@ func TestForgejoParser(t *testing.T) {
})
}
}
func TestParseIncompleteHookPayloads(t *testing.T) {
incomplete := []string{
`{}`,
`{"repository": {}}`,
`{"repository": {"full_name": "noslash"}, "sender": {}}`,
}
for _, payload := range incomplete {
t.Run(payload, func(t *testing.T) {
assert.NotPanics(t, func() {
_, _, err := parsePushHook(bytes.NewBufferString(payload))
assert.Error(t, err)
_, _, err = parseCreatedHook(bytes.NewBufferString(payload))
assert.Error(t, err)
_, _, err = parsePullRequestHook(bytes.NewBufferString(payload))
assert.Error(t, err)
_, _, err = parseReleaseHook(bytes.NewBufferString(payload))
assert.Error(t, err)
})
})
}
incompletePullRequest := []string{
`{"repository": {"full_name": "a/b", "owner": {}}, "sender": {}, "action": "opened", "pull_request": {}}`,
`{"repository": {"full_name": "a/b", "owner": {}}, "sender": {}, "action": "opened", "pull_request": {"user": {}}}`,
}
for _, payload := range incompletePullRequest {
t.Run(payload, func(t *testing.T) {
assert.NotPanics(t, func() {
_, _, err := parsePullRequestHook(bytes.NewBufferString(payload))
assert.Error(t, err)
_, _, err = parseReleaseHook(bytes.NewBufferString(payload))
assert.Error(t, err)
})
})
}
}
+32 -1
View File
@@ -14,7 +14,12 @@
package forgejo
import "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
import (
"errors"
"strings"
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
)
type pushHook struct {
Sha string `json:"sha"`
@@ -49,3 +54,29 @@ type releaseHook struct {
Sender *forgejo.User `json:"sender"`
Release *forgejo.Release
}
var errIncompleteHook = errors.New("incomplete webhook payload")
// validate checks that all objects dereferenced during hook conversion are
// present, so malformed payloads are rejected instead of causing panics.
func (h *pushHook) validate() error {
if h.Repo == nil || h.Repo.Owner == nil || !strings.Contains(h.Repo.FullName, "/") || h.Sender == nil {
return errIncompleteHook
}
return nil
}
func (h *pullRequestHook) validate() error {
if h.Repo == nil || h.Repo.Owner == nil || !strings.Contains(h.Repo.FullName, "/") || h.Sender == nil ||
h.PullRequest == nil || h.PullRequest.Poster == nil || h.PullRequest.Head == nil || h.PullRequest.Base == nil {
return errIncompleteHook
}
return nil
}
func (h *releaseHook) validate() error {
if h.Repo == nil || h.Repo.Owner == nil || !strings.Contains(h.Repo.FullName, "/") || h.Sender == nil || h.Release == nil {
return errIncompleteHook
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitea
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// FuzzParseHooks feeds untrusted webhook payloads into every payload level
// hook parser (push, created, pull request and release). The property checked
// is that parsing never panics, no matter how malformed the payload is.
func FuzzParseHooks(f *testing.F) {
fixtures, err := filepath.Glob(filepath.Join("fixtures", "*.json"))
if err != nil {
f.Fatal(err)
}
for _, fixture := range fixtures {
data, err := os.ReadFile(fixture)
if err != nil {
f.Fatal(err)
}
f.Add(data)
}
f.Fuzz(func(_ *testing.T, data []byte) {
_, _, _ = parsePushHook(bytes.NewReader(data))
_, _, _ = parseCreatedHook(bytes.NewReader(data))
_, _, _ = parsePullRequestHook(bytes.NewReader(data))
_, _, _ = parseReleaseHook(bytes.NewReader(data))
})
}
+3
View File
@@ -54,6 +54,9 @@ func toRepo(from *gitea.Repository) *model.Repo {
// toPerm converts a Gitea permission to a Woodpecker permission.
func toPerm(from *gitea.Permission) *model.Perm {
if from == nil {
return &model.Perm{}
}
return &model.Perm{
Pull: from.Pull,
Push: from.Push,
+12
View File
@@ -95,6 +95,9 @@ func parsePushHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipelin
if err != nil {
return nil, nil, err
}
if err := push.validate(); err != nil {
return nil, nil, err
}
// ignore push events for tags
if strings.HasPrefix(push.Ref, "refs/tags/") {
@@ -118,6 +121,9 @@ func parseCreatedHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipe
if err != nil {
return nil, nil, err
}
if err := push.validate(); err != nil {
return nil, nil, err
}
if push.RefType != refTag {
return nil, nil, nil
@@ -139,6 +145,9 @@ func parsePullRequestHook(payload io.Reader) (*model.Repo, *model.Pipeline, erro
if err != nil {
return nil, nil, err
}
if err := pr.validate(); err != nil {
return nil, nil, err
}
if pr.PullRequest == nil {
// this should never have happened but it did - so we check
@@ -180,6 +189,9 @@ func parseReleaseHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) {
if err != nil {
return nil, nil, err
}
if err := release.validate(); err != nil {
return nil, nil, err
}
repo = toRepo(release.Repo)
pipeline = pipelineFromRelease(release)
+37
View File
@@ -703,3 +703,40 @@ func Test_parsePullRequestDraft(t *testing.T) {
assert.True(t, p.PullRequestDraft)
}
}
func TestParseIncompleteHookPayloads(t *testing.T) {
incomplete := []string{
`{}`,
`{"repository": {}}`,
`{"repository": {"full_name": "noslash"}, "sender": {}}`,
}
for _, payload := range incomplete {
t.Run(payload, func(t *testing.T) {
assert.NotPanics(t, func() {
_, _, err := parsePushHook(bytes.NewBufferString(payload))
assert.Error(t, err)
_, _, err = parseCreatedHook(bytes.NewBufferString(payload))
assert.Error(t, err)
_, _, err = parsePullRequestHook(bytes.NewBufferString(payload))
assert.Error(t, err)
_, _, err = parseReleaseHook(bytes.NewBufferString(payload))
assert.Error(t, err)
})
})
}
incompletePullRequest := []string{
`{"repository": {"full_name": "a/b", "owner": {}}, "sender": {}, "action": "opened", "pull_request": {}}`,
`{"repository": {"full_name": "a/b", "owner": {}}, "sender": {}, "action": "opened", "pull_request": {"user": {}}}`,
}
for _, payload := range incompletePullRequest {
t.Run(payload, func(t *testing.T) {
assert.NotPanics(t, func() {
_, _, err := parsePullRequestHook(bytes.NewBufferString(payload))
assert.Error(t, err)
_, _, err = parseReleaseHook(bytes.NewBufferString(payload))
assert.Error(t, err)
})
})
}
}
+32 -1
View File
@@ -15,7 +15,12 @@
package gitea
import "code.gitea.io/sdk/gitea"
import (
"errors"
"strings"
"code.gitea.io/sdk/gitea"
)
type pushHook struct {
Sha string `json:"sha"`
@@ -50,3 +55,29 @@ type releaseHook struct {
Sender *gitea.User `json:"sender"`
Release *gitea.Release
}
var errIncompleteHook = errors.New("incomplete webhook payload")
// validate checks that all objects dereferenced during hook conversion are
// present, so malformed payloads are rejected instead of causing panics.
func (h *pushHook) validate() error {
if h.Repo == nil || h.Repo.Owner == nil || !strings.Contains(h.Repo.FullName, "/") || h.Sender == nil {
return errIncompleteHook
}
return nil
}
func (h *pullRequestHook) validate() error {
if h.Repo == nil || h.Repo.Owner == nil || !strings.Contains(h.Repo.FullName, "/") || h.Sender == nil ||
h.PullRequest == nil || h.PullRequest.Poster == nil || h.PullRequest.Head == nil || h.PullRequest.Base == nil {
return errIncompleteHook
}
return nil
}
func (h *releaseHook) validate() error {
if h.Repo == nil || h.Repo.Owner == nil || !strings.Contains(h.Repo.FullName, "/") || h.Sender == nil || h.Release == nil {
return errIncompleteHook
}
return nil
}
+1 -1
View File
@@ -164,7 +164,7 @@ func convertRepoHook(eventRepo *github.PushEventRepository) *model.Repo {
func convertLabels(from []*github.Label) []string {
labels := make([]string, len(from))
for i, label := range from {
labels[i] = *label.Name
labels[i] = label.GetName()
}
return labels
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package github
import (
"os"
"path/filepath"
"strings"
"testing"
)
// FuzzParseHookPayload feeds untrusted webhook payloads of arbitrary event
// types into the payload parser. The property checked is that parsing never
// panics, no matter how malformed the payload or event type is.
func FuzzParseHookPayload(f *testing.F) {
fixtures, err := filepath.Glob(filepath.Join("fixtures", "*.json"))
if err != nil {
f.Fatal(err)
}
for _, fixture := range fixtures {
data, err := os.ReadFile(fixture)
if err != nil {
f.Fatal(err)
}
// derive the event type from the fixture name (HookPush.json -> push)
webhookType := "push"
name := filepath.Base(fixture)
switch {
case strings.HasPrefix(name, "HookPullRequest"):
webhookType = "pull_request"
case strings.HasPrefix(name, "HookDeploy"):
webhookType = "deployment"
case strings.HasPrefix(name, "HookRelease"):
webhookType = "release"
}
f.Add(webhookType, data, true)
}
f.Fuzz(func(_ *testing.T, webhookType string, raw []byte, merge bool) {
_, _, _, _, _, _ = parseHookPayload(webhookType, raw, merge)
})
}
+10 -3
View File
@@ -56,7 +56,7 @@ const (
// parseHook parses a GitHub hook from an http.Request request and returns
// Repo and Pipeline detail. If a hook type is unsupported nil values are returned.
func parseHook(r *http.Request, merge bool) (_ *github.PullRequest, _ *model.Repo, _ *model.Pipeline, currCommit, prevCommit string, _ error) {
func parseHook(r *http.Request, merge bool) (*github.PullRequest, *model.Repo, *model.Pipeline, string, string, error) {
var reader io.Reader = r.Body
if payload := r.FormValue(hookField); payload != "" {
@@ -68,7 +68,14 @@ func parseHook(r *http.Request, merge bool) (_ *github.PullRequest, _ *model.Rep
return nil, nil, nil, "", "", err
}
payload, err := github.ParseWebHook(github.WebHookType(r), raw)
return parseHookPayload(github.WebHookType(r), raw, merge)
}
// parseHookPayload parses a raw GitHub hook payload of the given webhook type
// and returns Repo and Pipeline detail. If a hook type is unsupported nil
// values are returned.
func parseHookPayload(webhookType string, raw []byte, merge bool) (_ *github.PullRequest, _ *model.Repo, _ *model.Pipeline, currCommit, prevCommit string, _ error) {
payload, err := github.ParseWebHook(webhookType, raw)
if err != nil {
return nil, nil, nil, "", "", err
}
@@ -216,7 +223,7 @@ func parsePullHook(hook *github.PullRequestEvent, merge bool) (*github.PullReque
hook.GetPullRequest().GetHead().GetRef(),
hook.GetPullRequest().GetBase().GetRef(),
),
PullRequestLabels: convertLabels(hook.GetPullRequest().Labels),
PullRequestLabels: convertLabels(hook.GetPullRequest().GetLabels()),
PullRequestMilestone: hook.GetPullRequest().GetMilestone().GetTitle(),
PullRequestDraft: hook.GetPullRequest().GetDraft(),
FromFork: fromFork,
+14 -2
View File
@@ -179,6 +179,8 @@ func convertMergeRequestHook(hook *gitlab.MergeEvent, req *http.Request) (mergeI
return 0, 0, nil, nil, fmt.Errorf("target key expected in merge request hook")
case source == nil:
return 0, 0, nil, nil, fmt.Errorf("source key expected in merge request hook")
case hook.User == nil:
return 0, 0, nil, nil, fmt.Errorf("user key expected in merge request hook")
}
if target.PathWithNamespace != "" {
@@ -285,10 +287,15 @@ func convertPushHook(hook *gitlab.PushEvent) (*model.Repo, *model.Pipeline, erro
// assume a capacity of 4 changed files per commit
files := make([]string, 0, len(hook.Commits)*4)
for _, cm := range hook.Commits {
if cm == nil {
continue
}
if hook.After == cm.ID {
pipeline.Email = cm.Author.Email
pipeline.Message = cm.Message
pipeline.Timestamp = cm.Timestamp.Unix()
if cm.Timestamp != nil {
pipeline.Timestamp = cm.Timestamp.Unix()
}
if len(pipeline.Email) != 0 {
pipeline.Avatar = getUserAvatar(pipeline.Email)
}
@@ -342,10 +349,15 @@ func convertTagHook(hook *gitlab.TagEvent) (*model.Repo, *model.Pipeline, string
pipeline.ForgeURL = fmt.Sprintf("%s/-/tags/%s", repo.ForgeURL, pipeline.TagTitle)
for _, cm := range hook.Commits {
if cm == nil {
continue
}
if hook.After == cm.ID {
pipeline.Email = cm.Author.Email
pipeline.Message = cm.Message
pipeline.Timestamp = cm.Timestamp.Unix()
if cm.Timestamp != nil {
pipeline.Timestamp = cm.Timestamp.Unix()
}
if len(pipeline.Email) != 0 {
pipeline.Avatar = getUserAvatar(pipeline.Email)
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlab
import (
"net/http"
"testing"
gitlab "gitlab.com/gitlab-org/api/client-go/v2"
"go.woodpecker-ci.org/woodpecker/v3/server/forge/gitlab/fixtures"
)
// FuzzParseWebhook feeds untrusted webhook payloads of arbitrary event types
// into the webhook decoding and the pure hook conversion functions. The
// property checked is that neither decoding nor conversion ever panics.
func FuzzParseWebhook(f *testing.F) {
f.Add("Push Hook", fixtures.HookPush)
f.Add("Tag Push Hook", fixtures.HookTag)
f.Add("Merge Request Hook", fixtures.HookPullRequestOpened)
f.Add("Merge Request Hook", fixtures.HookPullRequestMerged)
f.Add("Release Hook", fixtures.WebhookReleaseBody)
f.Fuzz(func(_ *testing.T, eventType string, payload []byte) {
parsed, err := gitlab.ParseWebhook(gitlab.EventType(eventType), payload)
if err != nil {
return
}
switch event := parsed.(type) {
case *gitlab.MergeEvent:
_, _, _, _, _ = convertMergeRequestHook(event, &http.Request{})
case *gitlab.PushEvent:
_, _, _ = convertPushHook(event)
case *gitlab.TagEvent:
_, _, _, _ = convertTagHook(event)
case *gitlab.ReleaseEvent:
_, _, _ = convertReleaseHook(event)
}
})
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package model
import (
"testing"
)
// FuzzParseRepo feeds untrusted repository full names into the owner/name
// splitter. The property checked is that parsing never panics and that a
// successful parse returns non-empty owner and name.
func FuzzParseRepo(f *testing.F) {
f.Add("octocat/hello-world")
f.Add("owner/group/repo")
f.Add("/missing-owner")
f.Fuzz(func(t *testing.T, str string) {
user, repo, err := ParseRepo(str)
if err != nil {
return
}
if user == "" || repo == "" {
t.Fatalf("ParseRepo(%q) returned empty owner (%q) or name (%q) without error", str, user, repo)
}
})
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package token
import (
"testing"
)
// FuzzParse feeds untrusted raw JWT strings into the token parser. The
// property checked is that parsing never panics and that a returned token
// always has one of the allowed types.
func FuzzParse(f *testing.F) {
const secret = "fuzz-secret"
allowedTypes := []Type{UserToken, SessToken, HookToken, CsrfToken, AgentToken, OAuthStateToken}
// seed with a validly signed token for each type so the fuzzer can reach
// the code paths behind signature verification
for _, tokenType := range allowedTypes {
signed, err := New(tokenType).Sign(secret)
if err != nil {
f.Fatal(err)
}
f.Add(signed)
}
f.Add("eyJhbGciOiJIUzI1NiJ9.e30.")
f.Add("not.a.jwt")
f.Fuzz(func(t *testing.T, raw string) {
parsed, err := Parse(allowedTypes, raw, func(*Token) (string, error) {
return secret, nil
})
if err != nil {
return
}
found := false
for _, allowed := range allowedTypes {
if parsed.Type == allowed {
found = true
break
}
}
if !found {
t.Fatalf("parsed token has disallowed type %q", parsed.Type)
}
})
}