From fd93e64e46bea3a93b034873edd0dc4c4c19f832 Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Sun, 19 Jul 2026 00:41:14 +0200 Subject: [PATCH] Add `fuzz` target to make (#6828) Co-authored-by: Claude --- .github/release_template.md | 1 + .woodpecker/test.yaml | 11 ++++ Makefile | 10 ++++ pipeline/frontend/metadata/fuzz_test.go | 41 +++++++++++++ .../yaml/compiler/settings/fuzz_test.go | 43 ++++++++++++++ .../frontend/yaml/constraint/fuzz_test.go | 53 +++++++++++++++++ pipeline/frontend/yaml/fuzz_test.go | 35 +++++++++++ .../frontend/yaml/linter/schema/fuzz_test.go | 36 +++++++++++ pipeline/frontend/yaml/matrix/fuzz_test.go | 32 ++++++++++ pipeline/frontend/yaml/matrix/matrix.go | 5 ++ pipeline/frontend/yaml/matrix/matrix_test.go | 8 +++ pipeline/frontend/yaml/utils/fuzz_test.go | 37 ++++++++++++ server/cron/cron.go | 7 ++- server/cron/fuzz_test.go | 43 ++++++++++++++ server/forge/forgejo/fuzz_test.go | 46 +++++++++++++++ server/forge/forgejo/helper.go | 3 + server/forge/forgejo/parse.go | 12 ++++ server/forge/forgejo/parse_test.go | 37 ++++++++++++ server/forge/forgejo/types.go | 33 ++++++++++- server/forge/gitea/fuzz_test.go | 46 +++++++++++++++ server/forge/gitea/helper.go | 3 + server/forge/gitea/parse.go | 12 ++++ server/forge/gitea/parse_test.go | 37 ++++++++++++ server/forge/gitea/types.go | 33 ++++++++++- server/forge/github/convert.go | 2 +- server/forge/github/fuzz_test.go | 54 +++++++++++++++++ server/forge/github/parse.go | 13 +++- server/forge/gitlab/convert.go | 16 ++++- server/forge/gitlab/fuzz_test.go | 52 ++++++++++++++++ server/model/fuzz_test.go | 38 ++++++++++++ shared/token/fuzz_test.go | 59 +++++++++++++++++++ 31 files changed, 849 insertions(+), 9 deletions(-) create mode 100644 pipeline/frontend/metadata/fuzz_test.go create mode 100644 pipeline/frontend/yaml/compiler/settings/fuzz_test.go create mode 100644 pipeline/frontend/yaml/constraint/fuzz_test.go create mode 100644 pipeline/frontend/yaml/fuzz_test.go create mode 100644 pipeline/frontend/yaml/linter/schema/fuzz_test.go create mode 100644 pipeline/frontend/yaml/matrix/fuzz_test.go create mode 100644 pipeline/frontend/yaml/utils/fuzz_test.go create mode 100644 server/cron/fuzz_test.go create mode 100644 server/forge/forgejo/fuzz_test.go create mode 100644 server/forge/gitea/fuzz_test.go create mode 100644 server/forge/github/fuzz_test.go create mode 100644 server/forge/gitlab/fuzz_test.go create mode 100644 server/model/fuzz_test.go create mode 100644 shared/token/fuzz_test.go diff --git a/.github/release_template.md b/.github/release_template.md index 33fed4461..3ab365ec2 100644 --- a/.github/release_template.md +++ b/.github/release_template.md @@ -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 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml index b5db4b787..ac1521f36 100644 --- a/.woodpecker/test.yaml +++ b/.woodpecker/test.yaml @@ -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 diff --git a/Makefile b/Makefile index 63b0fe5a5..be5f39565 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/pipeline/frontend/metadata/fuzz_test.go b/pipeline/frontend/metadata/fuzz_test.go new file mode 100644 index 000000000..2d256e120 --- /dev/null +++ b/pipeline/frontend/metadata/fuzz_test.go @@ -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) + }) +} diff --git a/pipeline/frontend/yaml/compiler/settings/fuzz_test.go b/pipeline/frontend/yaml/compiler/settings/fuzz_test.go new file mode 100644 index 000000000..c6b8c6caf --- /dev/null +++ b/pipeline/frontend/yaml/compiler/settings/fuzz_test.go @@ -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) + }) +} diff --git a/pipeline/frontend/yaml/constraint/fuzz_test.go b/pipeline/frontend/yaml/constraint/fuzz_test.go new file mode 100644 index 000000000..a7a7fd401 --- /dev/null +++ b/pipeline/frontend/yaml/constraint/fuzz_test.go @@ -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) + }) +} diff --git a/pipeline/frontend/yaml/fuzz_test.go b/pipeline/frontend/yaml/fuzz_test.go new file mode 100644 index 000000000..69d3dd632 --- /dev/null +++ b/pipeline/frontend/yaml/fuzz_test.go @@ -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) + }) +} diff --git a/pipeline/frontend/yaml/linter/schema/fuzz_test.go b/pipeline/frontend/yaml/linter/schema/fuzz_test.go new file mode 100644 index 000000000..ccc218b28 --- /dev/null +++ b/pipeline/frontend/yaml/linter/schema/fuzz_test.go @@ -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) + }) +} diff --git a/pipeline/frontend/yaml/matrix/fuzz_test.go b/pipeline/frontend/yaml/matrix/fuzz_test.go new file mode 100644 index 000000000..db3ae2493 --- /dev/null +++ b/pipeline/frontend/yaml/matrix/fuzz_test.go @@ -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) + }) +} diff --git a/pipeline/frontend/yaml/matrix/matrix.go b/pipeline/frontend/yaml/matrix/matrix.go index b8a4bd6a9..ebb151172 100644 --- a/pipeline/frontend/yaml/matrix/matrix.go +++ b/pipeline/frontend/yaml/matrix/matrix.go @@ -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) diff --git a/pipeline/frontend/yaml/matrix/matrix_test.go b/pipeline/frontend/yaml/matrix/matrix_test.go index 16abf3c4e..d8aeedd98 100644 --- a/pipeline/frontend/yaml/matrix/matrix_test.go +++ b/pipeline/frontend/yaml/matrix/matrix_test.go @@ -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) diff --git a/pipeline/frontend/yaml/utils/fuzz_test.go b/pipeline/frontend/yaml/utils/fuzz_test.go new file mode 100644 index 000000000..af98482e6 --- /dev/null +++ b/pipeline/frontend/yaml/utils/fuzz_test.go @@ -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) + }) +} diff --git a/server/cron/cron.go b/server/cron/cron.go index 777be65c9..192b4f756 100644 --- a/server/cron/cron.go +++ b/server/cron/cron.go @@ -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 { diff --git a/server/cron/fuzz_test.go b/server/cron/fuzz_test.go new file mode 100644 index 000000000..1928bfa7a --- /dev/null +++ b/server/cron/fuzz_test.go @@ -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) + } + }) +} diff --git a/server/forge/forgejo/fuzz_test.go b/server/forge/forgejo/fuzz_test.go new file mode 100644 index 000000000..7bade4903 --- /dev/null +++ b/server/forge/forgejo/fuzz_test.go @@ -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)) + }) +} diff --git a/server/forge/forgejo/helper.go b/server/forge/forgejo/helper.go index 2045c544b..bbdf74451 100644 --- a/server/forge/forgejo/helper.go +++ b/server/forge/forgejo/helper.go @@ -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, diff --git a/server/forge/forgejo/parse.go b/server/forge/forgejo/parse.go index 3bc16f340..8b8d40f3f 100644 --- a/server/forge/forgejo/parse.go +++ b/server/forge/forgejo/parse.go @@ -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) diff --git a/server/forge/forgejo/parse_test.go b/server/forge/forgejo/parse_test.go index a67b5e838..393bbc843 100644 --- a/server/forge/forgejo/parse_test.go +++ b/server/forge/forgejo/parse_test.go @@ -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) + }) + }) + } +} diff --git a/server/forge/forgejo/types.go b/server/forge/forgejo/types.go index a09743899..8ad6bbdf2 100644 --- a/server/forge/forgejo/types.go +++ b/server/forge/forgejo/types.go @@ -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 +} diff --git a/server/forge/gitea/fuzz_test.go b/server/forge/gitea/fuzz_test.go new file mode 100644 index 000000000..93762f7a5 --- /dev/null +++ b/server/forge/gitea/fuzz_test.go @@ -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)) + }) +} diff --git a/server/forge/gitea/helper.go b/server/forge/gitea/helper.go index bdcdbe0ba..acfe74e82 100644 --- a/server/forge/gitea/helper.go +++ b/server/forge/gitea/helper.go @@ -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, diff --git a/server/forge/gitea/parse.go b/server/forge/gitea/parse.go index ed37a4464..6c6ad47ab 100644 --- a/server/forge/gitea/parse.go +++ b/server/forge/gitea/parse.go @@ -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) diff --git a/server/forge/gitea/parse_test.go b/server/forge/gitea/parse_test.go index 5d4ade977..1e3cbcac0 100644 --- a/server/forge/gitea/parse_test.go +++ b/server/forge/gitea/parse_test.go @@ -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) + }) + }) + } +} diff --git a/server/forge/gitea/types.go b/server/forge/gitea/types.go index 2c27a324b..808590ac5 100644 --- a/server/forge/gitea/types.go +++ b/server/forge/gitea/types.go @@ -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 +} diff --git a/server/forge/github/convert.go b/server/forge/github/convert.go index a972ef0f7..918793c99 100644 --- a/server/forge/github/convert.go +++ b/server/forge/github/convert.go @@ -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 } diff --git a/server/forge/github/fuzz_test.go b/server/forge/github/fuzz_test.go new file mode 100644 index 000000000..1d070442b --- /dev/null +++ b/server/forge/github/fuzz_test.go @@ -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) + }) +} diff --git a/server/forge/github/parse.go b/server/forge/github/parse.go index f3482cb02..22db5b4d6 100644 --- a/server/forge/github/parse.go +++ b/server/forge/github/parse.go @@ -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, diff --git a/server/forge/gitlab/convert.go b/server/forge/gitlab/convert.go index 42eafc28f..1750d4719 100644 --- a/server/forge/gitlab/convert.go +++ b/server/forge/gitlab/convert.go @@ -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) } diff --git a/server/forge/gitlab/fuzz_test.go b/server/forge/gitlab/fuzz_test.go new file mode 100644 index 000000000..9a3eaeea3 --- /dev/null +++ b/server/forge/gitlab/fuzz_test.go @@ -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) + } + }) +} diff --git a/server/model/fuzz_test.go b/server/model/fuzz_test.go new file mode 100644 index 000000000..d91e0988b --- /dev/null +++ b/server/model/fuzz_test.go @@ -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) + } + }) +} diff --git a/shared/token/fuzz_test.go b/shared/token/fuzz_test.go new file mode 100644 index 000000000..47888cb79 --- /dev/null +++ b/shared/token/fuzz_test.go @@ -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) + } + }) +}