Commit b15d84acd6

b15d84acd6bcd5b5f907dd22625750e342874f37

parent: c1ecc59d1b

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 15:37 UTC

runner: a key scope that reaches the runner protocol and nothing else

Runner commands required an instance admin, so the key a CI host holds
was an admin key: a build step could read it and run admin commands.
On gitbay.org the runner polled as the admin account with no repository
scope while registration was open.

keys add --scope runner confines a key to runner next/log/done and
read-only git. requireRunner accepts that scope or, still, an admin.
The systemd drop-in sandboxes the runner process. TestRunnerScopedKey
covers claim, every other command refused, the account's full key
refused as a runner, clone allowed, push refused.

Ref #92

Layout: unified · split

deploy/gitbay-runner.override.conf +11
@@ -5,7 +5,18 @@
5# daemon instances, and with nothing holding it back a deploy's scp on 5# daemon instances, and with nothing holding it back a deploy's scp on
6# the admin sshd stalled at 1%. Lower CPU and IO weight keep sshd, 6# the admin sshd stalled at 1%. Lower CPU and IO weight keep sshd,
7# gitbayd and the backup timers responsive while a build runs. 7# gitbayd and the backup timers responsive while a build runs.
8#
9# A build runs whatever the repository's ci.yml says, as the runner's
10# own user. Keep that user unprivileged: its key is added with
11# `keys add --scope runner`, which confines it to the runner protocol
12# and read-only git, and the sandboxing below keeps a step from
13# touching the system outside its workspace.
8[Service] 14[Service]
9Nice=10 15Nice=10
10CPUWeight=30 16CPUWeight=30
11IOWeight=30 17IOWeight=30
18NoNewPrivileges=yes
19ProtectSystem=full
20ProtectKernelTunables=yes
21ProtectControlGroups=yes
22RestrictSUIDSGID=yes
e2e/runner_scope_test.go +54
@@ -2,6 +2,7 @@ package e2e
2 2
3import ( 3import (
4 "os" 4 "os"
5 "os/exec"
5 "path/filepath" 6 "path/filepath"
6 "strings" 7 "strings"
7 "testing" 8 "testing"
@@ -58,3 +59,56 @@ func TestRunnerNextScopedToRepos(t *testing.T) {
58 t.Fatalf("unscoped claim did not take the remaining build:\n%s", out) 59 t.Fatalf("unscoped claim did not take the remaining build:\n%s", out)
59 } 60 }
60} 61}
62
63// A runner host holds a key added with --scope runner: it can claim and
64// report builds and clone what it builds, and nothing else. Neither the
65// same account's full key nor the runner key reaches the wrong side, so a
66// key stolen from a build step cannot administer the instance (#92).
67func TestRunnerScopedKey(t *testing.T) {
68 inst := startInstance(t)
69 aliceKey := inst.newKey(t, "alice")
70 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
71 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
72 t.Fatalf("repo create: %s", errOut)
73 }
74
75 // ci is an ordinary account. Its runner key is self-added.
76 ciKey := inst.newKey(t, "ci")
77 inst.admin(t, "admin", "user", "create", "ci", "--key", ciKey+".pub")
78 runnerKey := inst.newKey(t, "ci-runner")
79 pub, _ := os.ReadFile(runnerKey + ".pub")
80 if _, errOut, code := inst.ssh(t, ciKey, string(pub), "keys", "add", "--scope", "runner"); code != 0 {
81 t.Fatalf("keys add --scope runner: %s", errOut)
82 }
83
84 // The runner key claims (nothing is queued, which is a success).
85 out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "--json")
86 if code != 0 || !strings.Contains(out, "{}") {
87 t.Fatalf("runner key cannot claim: exit %d\n%s%s", code, out, errOut)
88 }
89 // The runner key reaches no other command, whoami included.
90 for _, argv := range [][]string{{"whoami"}, {"repo", "create", "ci/evil"}, {"admin", "user", "list"}} {
91 if _, _, code := inst.ssh(t, runnerKey, "", argv...); code != 4 {
92 t.Fatalf("runner key ran %v: exit %d, want 4", argv, code)
93 }
94 }
95 // The account's full key is not a runner.
96 if _, _, code := inst.ssh(t, ciKey, "", "runner", "next"); code != 4 {
97 t.Fatalf("full key of a non-admin claimed a build: exit %d, want 4", code)
98 }
99
100 // Git: the runner key clones and cannot push.
101 work := t.TempDir()
102 env := inst.gitEnv(runnerKey)
103 mustGit(t, work, env, "clone", "-q", inst.sshURL("alice/app"), "w")
104 dir := filepath.Join(work, "w")
105 os.WriteFile(filepath.Join(dir, "f"), []byte("x\n"), 0o644)
106 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
107 mustGit(t, dir, env, "add", ".")
108 mustGit(t, dir, env, "commit", "-q", "-m", "x")
109 push := exec.Command("git", "push", "-q", "origin", "main")
110 push.Dir, push.Env = dir, env
111 if pushOut, err := push.CombinedOutput(); err == nil || !strings.Contains(string(pushOut), "scope") {
112 t.Fatalf("runner key pushed, or was refused for another reason: err=%v\n%s", err, pushOut)
113 }
114}
internal/control/build.go +7 −5
@@ -53,9 +53,11 @@ func init() {
53 Summary: "list build secret names", 53 Summary: "list build secret names",
54 Usage: "repo secret list <owner/name>", ReadOnly: true, Run: runSecretList}) 54 Usage: "repo secret list <owner/name>", ReadOnly: true, Run: runSecretList})
55 55
56 // Runner commands: the claim/report loop for gitbay-runner. Admin-only — 56 // Runner commands: the claim/report loop for gitbay-runner. A runner
57 // a runner executes arbitrary repo code, so handing out jobs is the 57 // executes arbitrary repo code, so handing out jobs is the instance
58 // instance operator's call. 58 // operator's call: a key added with --scope runner, which the
59 // dispatcher confines to these three commands and read-only git, or
60 // an admin key, which a runner host should not hold (#92).
59 register(Command{Path: []string{"runner", "next"}, 61 register(Command{Path: []string{"runner", "next"},
60 Summary: "claim the oldest pending build (runner protocol)", 62 Summary: "claim the oldest pending build (runner protocol)",
61 Usage: "runner next [<owner/name>...]", SSHOnly: true, Run: runRunnerNext}) 63 Usage: "runner next [<owner/name>...]", SSHOnly: true, Run: runRunnerNext})
@@ -307,8 +309,8 @@ func runSecretList(c *Ctx, args []string) int {
307} 309}
308 310
309func requireRunner(c *Ctx) int { 311func requireRunner(c *Ctx) int {
310 if !c.User.IsAdmin { 312 if c.Scope != "runner" && !c.User.IsAdmin {
311 return c.fail(protocol.ExitDenied, "runner commands are for instance-admin runner accounts") 313 return c.fail(protocol.ExitDenied, "runner commands need a key added with --scope runner")
312 } 314 }
313 return -1 315 return -1
314} 316}
internal/control/control.go +3 −1
@@ -83,7 +83,9 @@ func Dispatch(c *Ctx, argv []string) int {
83 if !ok { 83 if !ok {
84 return c.fail(protocol.ExitUsage, "unknown command %q", argv[0]) 84 return c.fail(protocol.ExitUsage, "unknown command %q", argv[0])
85 } 85 }
86 if c.Scope != "full" { 86 // A runner-scoped key reaches the runner protocol and nothing else, so
87 // the key a CI host holds cannot administer the instance.
88 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
87 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) 89 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope)
88 } 90 }
89 if c.ViaAPI && cmd.SSHOnly { 91 if c.ViaAPI && cmd.SSHOnly {
internal/control/identity.go +4 −4
@@ -29,7 +29,7 @@ func init() {
29 register(Command{ 29 register(Command{
30 Path: []string{"keys", "add"}, 30 Path: []string{"keys", "add"},
31 Summary: "register an SSH public key (authorized_keys format)", 31 Summary: "register an SSH public key (authorized_keys format)",
32 Usage: "keys add [--scope full|git] < key.pub", 32 Usage: "keys add [--scope full|git|runner] < key.pub",
33 ReadsStdin: true, 33 ReadsStdin: true,
34 Run: runKeysAdd, 34 Run: runKeysAdd,
35 }) 35 })
@@ -91,12 +91,12 @@ func runKeysAdd(c *Ctx, args []string) int {
91 scope = args[i+1] 91 scope = args[i+1]
92 i++ 92 i++
93 default: 93 default:
94 return c.fail(protocol.ExitUsage, "usage: keys add [--scope full|git] < key.pub") 94 return c.fail(protocol.ExitUsage, "usage: keys add [--scope full|git|runner] < key.pub")
95 } 95 }
96 } 96 }
97 if scope != "full" && scope != "git" { 97 if scope != "full" && scope != "git" && scope != "runner" {
98 // deploy:* scopes are granted via repo settings, not self-service. 98 // deploy:* scopes are granted via repo settings, not self-service.
99 return c.fail(protocol.ExitUsage, "scope must be full or git") 99 return c.fail(protocol.ExitUsage, "scope must be full, git or runner")
100 } 100 }
101 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) 101 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
102 if err != nil { 102 if err != nil {
internal/policy/access.go +3
@@ -46,6 +46,9 @@ func ScopeAllowsGit(scope, repoPath string, write bool) bool {
46 switch scope { 46 switch scope {
47 case "full", "git": 47 case "full", "git":
48 return true 48 return true
49 case "runner":
50 // A CI runner clones what it builds and pushes nothing.
51 return !write
49 } 52 }
50 return false 53 return false
51} 54}