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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 23m ago 4b8c9e1d
history blame raw 8.2 KiB
1package agent
2
3// Validation of a leased job.
4//
5// A job arrives from the instance over SSH. The server validated the workflow when
6// it was pushed, but the runner is the party that acts on the job, and the
7// channel is a trust boundary: a compromised or buggy instance, or a MITM that
8// got past a loose host-key check, would otherwise be able to name a repository
9// outside the cache, point the worktree at a commit that is not a commit, or aim
10// a site deploy at a directory above the checkout. So every field the runner
11// builds a path or a command out of is checked again here, before any of it is
12// used.
13
14import (
15 "encoding/json"
16 "errors"
17 "fmt"
18 "strings"
19 "time"
20
21 "rodent.software/vole/ci"
22 "rodent.software/vole/core/id"
23 "rodent.software/vole/forge/repo"
24)
25
26// validateJob reports every reason a leased job must not be run. It is called
27// before the checkout, the container, the cache mount and the artifact patterns
28// exist, so a refusal costs nothing but the message.
29func validateJob(job *runnerJob) error {
30 if job == nil {
31 return errors.New("the lease carried no job")
32 }
33 var problems []string
34 add := func(format string, args ...any) {
35 problems = append(problems, fmt.Sprintf(format, args...))
36 }
37
38 if !id.Valid(job.ID) {
39 add("run id %q is not a run id", job.ID)
40 }
41 if err := repo.ValidateName(job.Repo); err != nil {
42 add("repository %q: %v", job.Repo, err)
43 }
44 if job.Attempt < 1 {
45 add("attempt %d is not a positive attempt number", job.Attempt)
46 }
47 if !ci.ValidObjectID(job.SHA) {
48 add("commit %q is not a commit id", job.SHA)
49 }
50 if !ci.ValidRef(job.Ref) {
51 add("ref %q is not a branch or tag", job.Ref)
52 }
53 if job.Branch != "" && !ci.ValidRefName(job.Branch) {
54 add("branch %q is not a branch name", job.Branch)
55 }
56 if job.Tag != "" && !ci.ValidRefName(job.Tag) {
57 add("tag %q is not a tag name", job.Tag)
58 }
59 if job.WorkflowSHA != "" && !ci.ValidObjectID(job.WorkflowSHA) {
60 add("workflow revision %q is not a commit id", job.WorkflowSHA)
61 }
62 if job.StepsSHA != "" && !isDigest(job.StepsSHA) {
63 add("steps digest %q is not a digest", job.StepsSHA)
64 }
65 for _, field := range []struct{ name, value string }{
66 {"workflow", job.Workflow},
67 {"job", job.Job},
68 } {
69 if field.value == "" || len(field.value) > 256 || strings.ContainsAny(field.value, "\x00\r\n") {
70 add("%s name %q is not a name", field.name, field.value)
71 }
72 }
73 switch job.Trigger {
74 case "", "push", "change_request", "issue", "comment", "schedule", "workflow_dispatch":
75 default:
76 add("trigger %q is not an event this runner understands", job.Trigger)
77 }
78 if job.TriggerAction != "" && (len(job.TriggerAction) > 64 || strings.ContainsAny(job.TriggerAction, "\x00\r\n")) {
79 add("trigger action %q is not one line of text", job.TriggerAction)
80 }
81 if job.Timeout != "" {
82 d, err := time.ParseDuration(job.Timeout)
83 if err != nil || d <= 0 || d > ci.MaxTimeout {
84 add("timeout %q is not a duration between 0 and %s", job.Timeout, ci.MaxTimeout)
85 }
86 }
87 if job.EventPayload != "" {
88 switch {
89 case len(job.EventPayload) > ci.MaxEventPayloadBytes:
90 add("event payload is %d bytes, limit %d", len(job.EventPayload), ci.MaxEventPayloadBytes)
91 case !json.Valid([]byte(job.EventPayload)):
92 add("event payload is not JSON")
93 }
94 }
95
96 if job.Image == "" {
97 add("the job names no image")
98 } else if name, ok := strings.CutPrefix(job.Image, "file:"); ok {
99 // An image-file definition is named, not pathed: the runner resolves the name
100 // under .vole/images itself, so a name with a slash or a dot-dot could not
101 // reach outside the checkout and is refused anyway.
102 if _, err := ci.ImageName(name); err != nil {
103 add("image: %v", err)
104 }
105 if job.ImageFile != name {
106 add("image file %q disagrees with the image reference %q", job.ImageFile, job.Image)
107 }
108 if !isDigest(job.ImageSHA) {
109 add("image file %q has no pinned digest", job.ImageFile)
110 }
111 } else if err := ci.ValidImageRef(job.Image); err != nil {
112 add("image: %v", err)
113 } else if job.ImageFile != "" || job.ImageSHA != "" {
114 add("image %q names a definition but is not a file: reference", job.Image)
115 }
116
117 if len(job.Steps) > ci.MaxSteps {
118 add("%d steps, limit %d", len(job.Steps), ci.MaxSteps)
119 }
120 for i, step := range job.Steps {
121 if strings.TrimSpace(step.Name) == "" || len(step.Name) > 256 ||
122 strings.ContainsAny(step.Name, "\x00\r\n") {
123 add("steps[%d]: name %q is not a name", i, step.Name)
124 }
125 if strings.TrimSpace(step.Run) == "" || len(step.Run) > ci.MaxStepBytes {
126 add("steps[%d]: script is empty or longer than %d bytes", i, ci.MaxStepBytes)
127 }
128 if step.Timeout != "" {
129 d, err := time.ParseDuration(step.Timeout)
130 if err != nil || d <= 0 || d > ci.MaxTimeout {
131 add("steps[%d]: timeout %q is not a duration between 0 and %s", i, step.Timeout, ci.MaxTimeout)
132 }
133 }
134 }
135
136 for i, pattern := range job.Artifacts {
137 if err := ci.ValidateArtifactPath(pattern); err != nil {
138 add("artifacts[%d]: %v", i, err)
139 }
140 }
141 for i, pattern := range job.ReleaseArtifacts {
142 if err := ci.ValidateArtifactPath(pattern); err != nil {
143 add("release_artifacts[%d]: %v", i, err)
144 }
145 }
146 if job.Pages != nil {
147 if err := ci.ValidatePagesPath(job.Pages.Path); err != nil {
148 add("pages: %v", err)
149 }
150 if job.Pages.Ref != "" && !ci.ValidRefName(job.Pages.Ref) {
151 add("pages: ref %q is not a branch name", job.Pages.Ref)
152 }
153 }
154
155 if len(job.Env) > ci.MaxEnv {
156 add("%d environment variables, limit %d", len(job.Env), ci.MaxEnv)
157 }
158 for name, value := range job.Env {
159 if !ci.ValidEnvName(name) {
160 add("env: %q is not a variable name", name)
161 continue
162 }
163 if len(value) > ci.MaxEnvValue || strings.ContainsAny(value, "\r\n\x00") {
164 add("env.%s: the value is too long or not one line", name)
165 }
166 }
167
168 if len(job.Services) > ci.MaxServices {
169 add("%d services, limit %d", len(job.Services), ci.MaxServices)
170 }
171 seenServices := map[string]bool{}
172 for i, svc := range job.Services {
173 where := fmt.Sprintf("services[%d]", i)
174 if err := ci.ValidServiceName(svc.ID); err != nil {
175 add("%s: %v", where, err)
176 continue
177 }
178 if seenServices[svc.ID] {
179 add("%s: service %q is listed twice", where, svc.ID)
180 continue
181 }
182 seenServices[svc.ID] = true
183 if err := ci.ValidImageRef(svc.Image); err != nil {
184 add("%s.image: %v", where, err)
185 }
186 if len(svc.Env) > ci.MaxEnv {
187 add("%s: %d environment variables, limit %d", where, len(svc.Env), ci.MaxEnv)
188 }
189 for name, value := range svc.Env {
190 if !ci.ValidEnvName(name) {
191 add("%s.env: %q is not a variable name", where, name)
192 continue
193 }
194 if len(value) > ci.MaxEnvValue || strings.ContainsAny(value, "\r\n\x00") {
195 add("%s.env.%s: the value is too long or not one line", where, name)
196 }
197 }
198 }
199 if len(job.Services) > 0 && job.Network != ci.NetworkBridge {
200 add("services need network: bridge")
201 }
202
203 // The values are written into a file the step's shell sources and then into the
204 // step's environment, so the runner checks what it is about to write rather than
205 // trusting the instance to have checked it. A name that could not be a variable,
206 // or a value that no process environment can carry, is refused before the
207 // checkout exists.
208 for name, delivered := range job.Secrets {
209 if err := ci.ValidateSecretName(name); err != nil {
210 add("secret: %v", err)
211 continue
212 }
213 switch {
214 case ci.ReservedEnvName(name):
215 add("secret %s would replace a variable the runner sets", name)
216 case delivered.Version < 1:
217 add("secret %s has no version", name)
218 case strings.ContainsRune(delivered.Value, 0):
219 add("secret %s contains a NUL byte", name)
220 case len(name)+1+len(delivered.Value) > ci.MaxSecretBytes:
221 add("secret %s is larger than one environment variable can hold", name)
222 }
223 }
224 for _, issue := range job.Missing {
225 if ci.ValidateSecretName(issue.Name) != nil || strings.ContainsAny(issue.Reason, "\x00\r\n") {
226 add("missing secret %q is not a name the runner can report", issue.Name)
227 }
228 }
229
230 if len(problems) == 0 {
231 return nil
232 }
233 return fmt.Errorf("refusing job %s from the instance: %s", job.ID, strings.Join(problems, "; "))
234}
235
236// isDigest reports whether s is a hex SHA-256 digest, which is what the step
237// digest the instance sends is.
238func isDigest(s string) bool {
239 if len(s) != 64 {
240 return false
241 }
242 for i := 0; i < len(s); i++ {
243 c := s[i]
244 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
245 return false
246 }
247 }
248 return true
249}
250