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/workflow.go +5−35

@@ -410,29 +410,8 @@ func Parse(file string, content string) (*Workflow, []string) {
410 410 for i, ar := range jr.Artifacts {
411 411 where := fmt.Sprintf("jobs.%s.artifacts[%d]", id, i)
412 412 p := strings.TrimSpace(ar.Path)
413 switch {
414 case p == "":
415 bad("%s: path is required (a glob relative to the checkout, e.g. dist/*)", where)
416 continue
417 case len(p) > MaxArtifactPathBytes:
418 bad("%s: path is %d bytes, limit %d", where, len(p), MaxArtifactPathBytes)
419 continue
420 case strings.HasPrefix(p, "/") || filepath.IsAbs(p):
421 bad("%s: %q must be relative to the checkout", where, p)
422 continue
423 case strings.Contains("/"+p+"/", "/../") || p == "..":
424 bad("%s: %q climbs out of the checkout", where, p)
425 continue
426 case strings.HasSuffix(p, "/"):
427 bad("%s: %q names a directory; declare the files, e.g. %s*", where, p, p)
428 continue
429 case strings.Contains(p, "**"):
430 bad("%s: %q uses **, which does not mean what it does elsewhere; use %s*",
431 where, p, strings.SplitN(p, "**", 2)[0])
432 continue
433 }
434 if _, err := path.Match(p, "probe"); err != nil {
435 bad("%s: %q is not a glob", where, p)
413 if err := ValidateArtifactPath(p); err != nil {
414 bad("%s: %v", where, err)
436 415 continue
437 416 }
438 417 if ar.Release && len(wf.Tags) == 0 {
@@ -478,18 +457,9 @@ func Parse(file string, content string) (*Workflow, []string) {
478 457 if jr.Pages != nil {
479 458 where := fmt.Sprintf("jobs.%s.pages.path", id)
480 459 p := strings.TrimSpace(jr.Pages.Path)
481 switch {
482 case p == "":
483 bad("%s: a directory is required, e.g. site/ or dist", where)
484 case len(p) > MaxArtifactPathBytes:
485 bad("%s: path is %d bytes, limit %d", where, len(p), MaxArtifactPathBytes)
486 case strings.HasPrefix(p, "/") || filepath.IsAbs(p):
487 bad("%s: %q must be relative to the checkout", where, p)
488 case strings.Contains("/"+p+"/", "/../") || p == "..":
489 bad("%s: %q climbs out of the checkout", where, p)
490 case strings.ContainsAny(p, "*?["):
491 bad("%s: %q is a glob; a site is one directory, not a set of them", where, p)
492 default:
460 if err := ValidatePagesPath(p); err != nil {
461 bad("%s: %v", where, err)
462 } else {
493 463 job.Pages = &Pages{Path: strings.TrimSuffix(p, "/")}
494 464 if pagesJobs++; pagesJobs > 1 {
495 465 bad("jobs.%s: only one job per workflow may publish the site; two jobs would "+