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.

Verify the runner's host and re-check the job it is given

f5456470d22fb7ca06197a8856ed0da7fc56fdd2 · Keenan Nemetz · 12d ago · parent edf9db6e

Verify the runner's host and re-check the job it is given

A runner executes repository code, so the instance it talks to decides what
runs on it. The client built ssh command lines with no host-key policy: a
man in the middle, or a loose known_hosts, could answer in the instance's
place and feed a runner any job it liked.

Host keys are now verified against known_hosts by default, for the
runner's control channel, for git's own clone/fetch/push and for the LFS
transfer. `--known-hosts` names a file instead of the default,
VOLE_KNOWN_HOSTS does the same for every command, and
`--insecure-host-key` / VOLE_INSECURE_HOST_KEY is the explicit override
for tests and throwaway instances, with a warning when it is used. The
options are placed before any configured ones because ssh takes the first
value for an option, so GIT_SSH_COMMAND or VOLE_SSH_COMMAND cannot
quietly turn the check off. The test package opts out once, in TestMain,
rather than every fixture learning to trust everything.

The runner also re-checks the job it leases before acting on it. The
server validated the workflow at push time, but the runner is the party
that turns the payload into paths, container arguments and scripts. Run
id, repository, attempt, commit, ref, branch, tag, workflow and job names,
image (or image-file plus its pinned digest), steps, artifact patterns,
pages path and environment are all validated; a malformed job stops the
runner with the reason instead of being built from.

The artifact and pages path rules move into ci/validate.go, so the
push-time check and the runner use one implementation rather than two
that can drift. Joining them also closes the gap the runner-side check
was for: `..`, absolute paths, `**`, globs where a directory belongs, and
control characters are refused identically on both sides.

Tests: strict-by-default and known_hosts options, the environment losing
to the explicit policy, the insecure override, and every job field
refused when it climbs out, is the wrong shape, or is missing.

ci/validate.go +130

@@ -0,0 +1,130 @@
1 package ci
2
3 // Validation shared by the push-time workflow checks and the runner.
4 //
5 // The server validates a workflow when it is pushed, and then sends the runner a
6 // job. The runner is the party that acts on that job, and the channel is the trust
7 // boundary, so it re-checks the fields it is about to build from rather than
8 // trusting that the two sides agree. Sharing the checks is what stops the two
9 // copies from drifting apart.
10
11 import (
12 "fmt"
13 "path"
14 "path/filepath"
15 "strings"
16 )
17
18 // ValidObjectID reports whether s is a git object id: 40 hexadecimal characters in
19 // a SHA-1 repository, 64 in a SHA-256 one.
20 func ValidObjectID(s string) bool {
21 if len(s) != 40 && len(s) != 64 {
22 return false
23 }
24 for i := 0; i < len(s); i++ {
25 c := s[i]
26 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
27 return false
28 }
29 }
30 return true
31 }
32
33 // ValidRef reports whether ref is a full branch or tag name. The runner only ever
34 // checks out these two namespaces, and every component is a plain path segment:
35 // no empty or dot components, no characters git refuses, no control characters.
36 func ValidRef(ref string) bool {
37 var name string
38 switch {
39 case strings.HasPrefix(ref, "refs/heads/"):
40 name = strings.TrimPrefix(ref, "refs/heads/")
41 case strings.HasPrefix(ref, "refs/tags/"):
42 name = strings.TrimPrefix(ref, "refs/tags/")
43 default:
44 return false
45 }
46 return ValidRefName(name)
47 }
48
49 // ValidRefName reports whether name is a valid branch or tag name without the
50 // namespace prefix. It is deliberately stricter than "git will take it": a name
51 // that a workflow, a lease and a record can all agree on is short and plain.
52 func ValidRefName(name string) bool {
53 if name == "" || len(name) > 255 {
54 return false
55 }
56 if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") ||
57 strings.HasSuffix(name, ".") || strings.HasSuffix(name, ".lock") {
58 return false
59 }
60 if strings.Contains(name, "..") || strings.Contains(name, "//") ||
61 strings.Contains(name, "@{") || strings.Contains(name, "\\") {
62 return false
63 }
64 for _, r := range name {
65 switch r {
66 case ' ', '~', '^', ':', '?', '*', '[', '\x7f':
67 return false
68 }
69 if r < 0x20 {
70 return false
71 }
72 }
73 for _, part := range strings.Split(name, "/") {
74 if part == "" || part == "." || part == ".." {
75 return false
76 }
77 }
78 return true
79 }
80
81 // ValidEnvName reports whether name is a variable name an environment entry may
82 // use. Values, not names, are what an image or a workflow spends; the name has to
83 // be one a shell and a container runtime both accept.
84 func ValidEnvName(name string) bool { return envNameRE.MatchString(name) }
85
86 // ValidateArtifactPath reports why p is not a usable artifact pattern. A pattern is
87 // relative to the checkout, names files rather than a directory, and cannot climb
88 // out of the tree; the runner expands it, so this is the check that decides what it
89 // is allowed to expand.
90 func ValidateArtifactPath(p string) error {
91 switch {
92 case p == "":
93 return fmt.Errorf("path is required (a glob relative to the checkout, e.g. dist/*)")
94 case len(p) > MaxArtifactPathBytes:
95 return fmt.Errorf("path is %d bytes, limit %d", len(p), MaxArtifactPathBytes)
96 case strings.HasPrefix(p, "/") || filepath.IsAbs(p):
97 return fmt.Errorf("%q must be relative to the checkout", p)
98 case strings.Contains("/"+p+"/", "/../") || p == "..":
99 return fmt.Errorf("%q climbs out of the checkout", p)
100 case strings.HasSuffix(p, "/"):
101 return fmt.Errorf("%q names a directory; declare the files, e.g. %s*", p, p)
102 case strings.Contains(p, "**"):
103 return fmt.Errorf("%q uses **, which does not mean what it does elsewhere; use %s*",
104 p, strings.SplitN(p, "**", 2)[0])
105 }
106 if _, err := path.Match(p, "probe"); err != nil {
107 return fmt.Errorf("%q is not a glob", p)
108 }
109 return nil
110 }
111
112 // ValidatePagesPath reports why p is not a usable site directory. A site is one
113 // directory inside the checkout: no glob, no absolute path, no climb out.
114 func ValidatePagesPath(p string) error {
115 switch {
116 case p == "":
117 return fmt.Errorf("a directory is required, e.g. site/ or dist")
118 case len(p) > MaxArtifactPathBytes:
119 return fmt.Errorf("path is %d bytes, limit %d", len(p), MaxArtifactPathBytes)
120 case strings.HasPrefix(p, "/") || filepath.IsAbs(p):
121 return fmt.Errorf("%q must be relative to the checkout", p)
122 case strings.Contains("/"+p+"/", "/../") || p == "..":
123 return fmt.Errorf("%q climbs out of the checkout", p)
124 case strings.ContainsAny(p, "*?["):
125 return fmt.Errorf("%q is a glob; a site is one directory, not a set of them", p)
126 case strings.ContainsAny(p, "\x00\r\n:"):
127 return fmt.Errorf("%q contains a character the runtime cannot take", p)
128 }
129 return nil
130 }