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.
client/remote_test.go +124
| @@ -0,0 +1,124 @@ | |||
| 1 | package client | ||
| 2 | |||
| 3 | // The host-key policy is the thing that decides which instance a runner trusts, | ||
| 4 | // so it is worth pinning: strict unless the caller says otherwise, and an | ||
| 5 | // environment variable cannot loosen it. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "os" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func argv(t *testing.T, host, port string, args ...string) []string { | ||
| 14 | t.Helper() | ||
| 15 | return SSHCommand(host, port, args...).Args | ||
| 16 | } | ||
| 17 | |||
| 18 | func containsOption(args []string, want string) bool { | ||
| 19 | for i := 0; i+1 < len(args); i++ { | ||
| 20 | if args[i] == "-o" && args[i+1] == want { | ||
| 21 | return true | ||
| 22 | } | ||
| 23 | } | ||
| 24 | return false | ||
| 25 | } | ||
| 26 | |||
| 27 | func TestSSHCommandVerifiesHostKeysByDefault(t *testing.T) { | ||
| 28 | SetHostKeys(HostKeys{}) | ||
| 29 | t.Cleanup(func() { SetHostKeys(HostKeys{}) }) | ||
| 30 | t.Setenv("GIT_SSH_COMMAND", "") | ||
| 31 | t.Setenv("VOLE_SSH_COMMAND", "") | ||
| 32 | |||
| 33 | got := argv(t, "git@forge.example.com", "2222", "vole-whoami") | ||
| 34 | if !containsOption(got, "StrictHostKeyChecking=yes") { | ||
| 35 | t.Fatalf("no strict host-key check in %q", got) | ||
| 36 | } | ||
| 37 | if containsOption(got, "UserKnownHostsFile=/dev/null") { | ||
| 38 | t.Fatalf("the default policy discards known_hosts: %q", got) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | func TestSSHCommandUsesTheConfiguredKnownHosts(t *testing.T) { | ||
| 43 | SetHostKeys(HostKeys{KnownHosts: "/etc/vole/known_hosts"}) | ||
| 44 | t.Cleanup(func() { SetHostKeys(HostKeys{}) }) | ||
| 45 | got := argv(t, "git@forge.example.com", "", "vole-whoami") | ||
| 46 | if !containsOption(got, "StrictHostKeyChecking=yes") || | ||
| 47 | !containsOption(got, "UserKnownHostsFile=/etc/vole/known_hosts") { | ||
| 48 | t.Fatalf("the known_hosts policy is not applied: %q", got) | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | // A configured ssh command may carry its own options; ours go first because ssh | ||
| 53 | // takes the first value for an option, so an environment variable cannot turn the | ||
| 54 | // check off behind the caller's back. | ||
| 55 | func TestSSHCommandKeepsHostKeyCheckingAheadOfTheEnvironment(t *testing.T) { | ||
| 56 | SetHostKeys(HostKeys{}) | ||
| 57 | t.Cleanup(func() { SetHostKeys(HostKeys{}) }) | ||
| 58 | t.Setenv("GIT_SSH_COMMAND", "ssh -i /tmp/key -o StrictHostKeyChecking=no") | ||
| 59 | got := argv(t, "git@forge.example.com", "", "vole-whoami") | ||
| 60 | strict, loose := -1, -1 | ||
| 61 | for i := 0; i+1 < len(got); i++ { | ||
| 62 | if got[i] != "-o" { | ||
| 63 | continue | ||
| 64 | } | ||
| 65 | switch got[i+1] { | ||
| 66 | case "StrictHostKeyChecking=yes": | ||
| 67 | if strict < 0 { | ||
| 68 | strict = i | ||
| 69 | } | ||
| 70 | case "StrictHostKeyChecking=no": | ||
| 71 | if loose < 0 { | ||
| 72 | loose = i | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
| 76 | if strict < 0 || loose < 0 { | ||
| 77 | t.Fatalf("expected both options in %q", got) | ||
| 78 | } | ||
| 79 | if strict > loose { | ||
| 80 | t.Fatalf("the environment's StrictHostKeyChecking=no comes first in %q", got) | ||
| 81 | } | ||
| 82 | if !containsOption(got, "IdentitiesOnly=yes") && !strings.Contains(strings.Join(got, " "), "-i /tmp/key") { | ||
| 83 | t.Fatalf("the configured key was dropped: %q", got) | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | func TestSSHCommandInsecureIsExplicit(t *testing.T) { | ||
| 88 | SetHostKeys(HostKeys{Insecure: true}) | ||
| 89 | t.Cleanup(func() { SetHostKeys(HostKeys{}) }) | ||
| 90 | got := argv(t, "git@forge.example.com", "", "vole-whoami") | ||
| 91 | if !containsOption(got, "StrictHostKeyChecking=no") || | ||
| 92 | !containsOption(got, "UserKnownHostsFile=/dev/null") { | ||
| 93 | t.Fatalf("the explicit override is not applied: %q", got) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | func TestHostKeysFromEnv(t *testing.T) { | ||
| 98 | t.Setenv("VOLE_KNOWN_HOSTS", " /tmp/kh ") | ||
| 99 | t.Setenv("VOLE_INSECURE_HOST_KEY", "1") | ||
| 100 | h := HostKeysFromEnv() | ||
| 101 | if h.KnownHosts != "/tmp/kh" || !h.Insecure { | ||
| 102 | t.Fatalf("HostKeysFromEnv() = %+v", h) | ||
| 103 | } | ||
| 104 | t.Setenv("VOLE_INSECURE_HOST_KEY", "") | ||
| 105 | h = HostKeysFromEnv() | ||
| 106 | if h.Insecure { | ||
| 107 | t.Fatalf("an empty VOLE_INSECURE_HOST_KEY enabled the override: %+v", h) | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | func TestGitSSHCommandCarriesThePolicy(t *testing.T) { | ||
| 112 | SetHostKeys(HostKeys{KnownHosts: "/tmp/kh"}) | ||
| 113 | t.Cleanup(func() { SetHostKeys(HostKeys{}) }) | ||
| 114 | t.Setenv("GIT_SSH_COMMAND", "ssh -i /key") | ||
| 115 | got := GitSSHCommand() | ||
| 116 | for _, want := range []string{"ssh", "-o StrictHostKeyChecking=yes", "UserKnownHostsFile=/tmp/kh", "-i /key"} { | ||
| 117 | if !strings.Contains(got, want) { | ||
| 118 | t.Fatalf("GitSSHCommand() = %q, missing %q", got, want) | ||
| 119 | } | ||
| 120 | } | ||
| 121 | if os.Getenv("GIT_SSH_COMMAND") != "ssh -i /key" { | ||
| 122 | t.Fatal("GitSSHCommand modified the environment") | ||
| 123 | } | ||
| 124 | } | ||