Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Fuzz the metadata and workflow parsers

d9f5a930d9e6aa20b5dbfb00d154afd46df901ab · Keenan Nemetz · 10d ago · parent 16bf0ebf

Fuzz the metadata and workflow parsers

A push decides how much of this code runs: frontmatter becomes the object every
page renders, and workflow YAML becomes the job the runner executes. Neither was
fuzzed, and both accept bytes from anyone allowed to push.

Four targets, each asserting the property that makes the pair of reader and
writer safe rather than waiting for a panic: an accepted issue or comment must
render and re-parse identically, and a second write must not change a byte; an
accepted workflow must be one the runner can act on; an accepted image
definition must not add a line to the Dockerfile it renders.

FuzzParseComment found one asymmetry in under a second: a hand-written file
without a trailing newline is normalized on the first write. That is the format
working as designed, since every writer trims and nothing compares raw bodies, so
the property now says so and the input is committed as a seed.

The YAML bomb is pinned by a test with two assertions, because the parser refuses
it on the unknown keys before expansion is reached, and it is yaml.v3's own
aliasing limit that covers a bomb inside a field the schema knows.

Closes #97.

ci/fuzz_test.go +160

@@ -0,0 +1,160 @@
1 package ci
2
3 import (
4 "strings"
5 "testing"
6
7 "gopkg.in/yaml.v3"
8 )
9
10 // FuzzParseWorkflow drives the decoder that turns a pushed workflow file into the
11 // job the runner is handed. The file arrives from whoever can push, so a panic is
12 // an outage and a document that half-validates is worse: the runner executes what
13 // the parser produced, not what the author meant.
14 //
15 // Parse is all-or-nothing by design — a problem anywhere means no workflow — so
16 // the properties are about that contract holding for every input.
17 func FuzzParseWorkflow(f *testing.F) {
18 for _, seed := range []string{
19 "version: 1\non:\n push:\n branches: ['*']\njobs:\n verify:\n image: golang:1.27-bookworm\n steps:\n - name: build\n run: go build ./...\n",
20 "version: 1\non:\n push:\n tags: ['v*']\njobs:\n release:\n image-file: release\n steps:\n - run: make dist\n",
21 "version: 1\njobs:\n a:\n image: alpine\n steps:\n - run: echo\n",
22 "version: 1\njobs:\n a:\n steps:\n - run: echo\n",
23 // Unknown fields, wrong types, an unsupported version, and a job with no
24 // steps are the shapes a first attempt produces.
25 "version: 1\njobs:\n a:\n image: alpine\n steps: []\n srcdir: /x\n",
26 "version: 2\njobs:\n a:\n image: alpine\n steps:\n - run: echo\n",
27 "version: 1\njobs: []\n",
28 "version: 1\njobs:\n a:\n image: alpine\n steps: not-a-list\n",
29 "version: 1\njobs: {}\n",
30 // Tabs are not YAML indentation, and the message a person gets for it is the
31 // first thing they read.
32 "version: 1\njobs:\n\ta:\n\t\timage: alpine\n",
33 "",
34 "---\n---\n",
35 "version: 1\non: {push: {branches: &a ['*'], tags: [*a]}}\njobs:\n a:\n image: x\n steps: [{run: y}]\n",
36 } {
37 f.Add(seed)
38 }
39 f.Fuzz(func(t *testing.T, content string) {
40 wf, problems := Parse(".vole/workflows/fuzz.yaml", content)
41 if wf == nil {
42 if len(problems) == 0 {
43 t.Fatalf("%q was refused without saying why", content)
44 }
45 return
46 }
47 // An accepted workflow becomes a job the runner executes, so it must be one
48 // the runner can act on at all.
49 if len(problems) > 0 {
50 t.Fatalf("%q was accepted with problems: %v", content, problems)
51 }
52 if wf.Version != Version {
53 t.Fatalf("%q was accepted as version %d", content, wf.Version)
54 }
55 if len(wf.Jobs) == 0 {
56 t.Fatalf("%q was accepted with no jobs", content)
57 }
58 for _, job := range wf.Jobs {
59 // A job that names neither an image nor a definition has nothing to run
60 // in; the docs say that fails the push.
61 if job.Image == "" && job.ImageFile == "" {
62 t.Fatalf("%q was accepted with a job that names no image", content)
63 }
64 }
65 // The same file has to validate the same way twice: a push that is refused
66 // on a retry, or whose error list shuffles between runs, is a bug report
67 // nobody can reproduce.
68 wf2, problems2 := Parse(".vole/workflows/fuzz.yaml", content)
69 if (wf2 == nil) != (wf == nil) || len(problems2) != len(problems) {
70 t.Fatalf("%q validated two different ways", content)
71 }
72 // Glob matching runs on patterns this file supplied.
73 wf.Applies("main")
74 wf.AppliesToTag("v1.0.0")
75 })
76 }
77
78 // FuzzParseImage is the same for a workflow image definition, which ends up as a
79 // Dockerfile the runner builds. The property that matters is that the definition
80 // cannot add an instruction: the env and run values go through quoting, and a
81 // value that broke out of it would be one line of a Dockerfile the author did not
82 // write.
83 func FuzzParseImage(f *testing.F) {
84 for _, seed := range []string{
85 "version: 1\nfrom: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5\n",
86 "version: 1\nfrom: alpine\nenv:\n CGO_ENABLED: '1'\nrun:\n - apt-get update\n - useradd ci\n",
87 "version: 1\nfrom: alpine\nenv:\n EVIL: 'x\"\\nRUN curl http://example.invalid'\n",
88 "version: 1\nfrom: alpine\nrun:\n - 'x\"] && curl http://example.invalid'\n",
89 "version: 1\nfrom: alpine\nenv:\n ONE_LINE: 'a\nb'\n",
90 "version: 1\nfrom: ''\n",
91 "version: 1\nfrom: alpine\nenv:\n not a name: x\n",
92 "version: 2\nfrom: alpine\n",
93 "from: alpine\n",
94 "",
95 "version: 1\nfrom: [a, b]\n",
96 } {
97 f.Add(seed)
98 }
99 f.Fuzz(func(t *testing.T, content string) {
100 def, problems := ParseImage(".vole/images/fuzz.yaml", content)
101 if def == nil {
102 if len(problems) == 0 {
103 t.Fatalf("%q was refused without saying why", content)
104 }
105 return
106 }
107 if len(problems) > 0 {
108 t.Fatalf("%q was accepted with problems: %v", content, problems)
109 }
110 if def.Version != 1 {
111 t.Fatalf("%q was accepted as version %d", content, def.Version)
112 }
113 if err := ValidImageRef(def.From); err != nil {
114 t.Fatalf("%q was accepted with from %q: %v", content, def.From, err)
115 }
116 rendered, err := RenderImage(content)
117 if err != nil {
118 t.Fatalf("an accepted definition does not render: %v", err)
119 }
120 // One header, one FROM, then one line for each env entry and each run
121 // command. A value that broke out of its quoting would add a line, which is
122 // exactly the instruction nobody wrote.
123 wantLines := 2 + len(def.Env) + len(def.Run)
124 if lines := strings.Count(rendered, "\n"); lines != wantLines {
125 t.Fatalf("%q rendered %d lines, want %d:\n%s", content, lines, wantLines, rendered)
126 }
127 })
128 }
129
130 // TestParseRefusesUnboundedAliasing pins the reason a YAML bomb is not a problem
131 // here, because the protection is not visible in this file: yaml.v3 refuses a
132 // document whose aliases expand beyond a ratio it can afford.
133 //
134 // Two things are checked, and they fail for different reasons, so both are
135 // asserted rather than one standing in for the other.
136 func TestParseRefusesUnboundedAliasing(t *testing.T) {
137 t.Parallel()
138 // Nine levels of nine-fold expansion: about 387 million nodes if it were
139 // expanded, from a few hundred bytes.
140 bomb := "version: 1\n" +
141 "a: &a [x, x, x, x, x, x, x, x, x]\n" +
142 "b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a]\n" +
143 "c: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b]\n" +
144 "d: &d [*c, *c, *c, *c, *c, *c, *c, *c, *c]\n" +
145 "e: [*d, *d, *d, *d, *d, *d, *d, *d, *d]\n"
146 if wf, problems := Parse(".vole/workflows/bomb.yaml", bomb); wf != nil || len(problems) == 0 {
147 t.Fatalf("the parser accepted a document with unknown keys: %v", wf)
148 }
149 // The parser above refuses on the unknown keys before expansion is reached, so
150 // the decoder's own limit is what protects a bomb inside a field the schema
151 // does know. That limit is why this code can decode repository YAML at all.
152 var v any
153 err := yaml.NewDecoder(strings.NewReader(bomb)).Decode(&v)
154 if err == nil {
155 t.Fatal("the decoder expanded a bomb without complaint")
156 }
157 if !strings.Contains(err.Error(), "excessive aliasing") {
158 t.Fatalf("the decoder refused the bomb for another reason: %v", err)
159 }
160 }