detached 0a7462c

fix(host): bound action resolution and send archive tokens only to the origin the host names

commit
0a7462c42f8014ee70da3a6c1d0ede6fb9605734
author
brice · · 2026-10-14 02:47 UTC
parent
dd71bec
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +590 -53
M internal/execstream/exec.proto +5 -0
@@ -176,4 +176,9 @@ message ResolvedAction {
176176 string token = 6;
177177 string source = 7;
178178 string error = 8;
179+ // auth_origin (scheme://host[:port], as the sandbox reaches it) is the only
180+ // origin the token may be sent to; empty means the token is never sent. The
181+ // host sets it for archives served by the ermite server, from its own
182+ // configuration, never from the URL alone.
183+ string auth_origin = 9;
179184 }
@@ -176,4 +176,9 @@ message ResolvedAction {
176 string token = 6;176 string token = 6;
177 string source = 7;177 string source = 7;
178 string error = 8;178 string error = 8;
179+ // auth_origin (scheme://host[:port], as the sandbox reaches it) is the only
180+ // origin the token may be sent to; empty means the token is never sent. The
181+ // host sets it for archives served by the ermite server, from its own
182+ // configuration, never from the URL alone.
183+ string auth_origin = 9;
179 }184 }
M internal/execstream/execpb/exec.pb.go +16 -2 generated file: collapsed
M internal/execstream/frame.go +45 -12
@@ -13,6 +13,7 @@ package execstream
1313
1414 import (
1515 "bufio"
16+ "context"
1617 "encoding/binary"
1718 "errors"
1819 "fmt"
@@ -105,6 +106,13 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu
105106 return DriveJob(rw, run, cancel, JobHandler{Output: onOutput})
106107 }
107108
109+// resolveQueue is how many ResolveRequest frames may wait for the worker.
110+const resolveQueue = 2
111+
112+func resolveFrame(r *execpb.ResolveResponse) *execpb.Frame {
113+ return &execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: r}}
114+}
115+
108116 // JobHandler receives the frames of a session. Nil functions ignore their
109117 // frames. The functions are called from the goroutine of DriveJob, in the
110118 // order of the stream.
@@ -115,10 +123,13 @@ type JobHandler struct {
115123 StepCompleted func(*execpb.StepCompleted)
116124 Log func(*execpb.Log)
117125 JobResult func(*execpb.JobResult)
118- // Resolve answers the executor's ResolveRequest frames. It is called in its
119- // own goroutine (the call goes to the server), the answer is sent as a
120- // ResolveResponse frame carrying the id of the request.
121- Resolve func(*execpb.ResolveRequest) *execpb.ResolveResponse
126+ // Resolve answers the executor's ResolveRequest frames, one at a time and in
127+ // order, from a single worker (the call goes to the server); the answer is
128+ // sent as a ResolveResponse frame carrying the id of the request. ctx ends
129+ // with the session: a call still running is cancelled and DriveJob waits for
130+ // it before it returns. At most resolveQueue requests wait; the next ones are
131+ // answered with an error at once, without a call.
132+ Resolve func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse
122133 }
123134
124135 // DriveJob is Drive for a session that carries a job: besides Output frames it
@@ -142,6 +153,27 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h
142153
143154 done := make(chan struct{})
144155 defer close(done)
156+
157+ // One worker answers the resolutions; it ends, and is waited for, with the
158+ // session.
159+ resolveCh := make(chan *execpb.ResolveRequest, resolveQueue)
160+ rctx, rcancel := context.WithCancel(context.Background())
161+ var workers sync.WaitGroup
162+ workers.Add(1)
163+ go func() {
164+ defer workers.Done()
165+ for {
166+ select {
167+ case req := <-resolveCh:
168+ resp := h.Resolve(rctx, req)
169+ resp.Id = req.Id
170+ _ = c.Send(resolveFrame(resp))
171+ case <-rctx.Done():
172+ return
173+ }
174+ }
175+ }()
176+ defer func() { rcancel(); workers.Wait() }()
145177 if cancel != nil {
146178 go func() {
147179 select {
@@ -185,14 +217,15 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h
185217 }
186218 case *execpb.Frame_ResolveRequest:
187219 req := b.ResolveRequest
188- go func() {
189- resp := &execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"}
190- if h.Resolve != nil {
191- resp = h.Resolve(req)
192- resp.Id = req.Id
193- }
194- _ = c.Send(&execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: resp}})
195- }()
220+ if h.Resolve == nil {
221+ _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"}))
222+ break
223+ }
224+ select {
225+ case resolveCh <- req:
226+ default:
227+ _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "too many action resolutions in flight"}))
228+ }
196229 case *execpb.Frame_Exit:
197230 return hello, b.Exit, nil
198231 }
@@ -13,6 +13,7 @@ package execstream
13 13
14 import (14 import (
15 "bufio"15 "bufio"
16+ "context"
16 "encoding/binary"17 "encoding/binary"
17 "errors"18 "errors"
18 "fmt"19 "fmt"
@@ -105,6 +106,13 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu
105 return DriveJob(rw, run, cancel, JobHandler{Output: onOutput})106 return DriveJob(rw, run, cancel, JobHandler{Output: onOutput})
106 }107 }
107 108
109+// resolveQueue is how many ResolveRequest frames may wait for the worker.
110+const resolveQueue = 2
111+
112+func resolveFrame(r *execpb.ResolveResponse) *execpb.Frame {
113+ return &execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: r}}
114+}
115+
108 // JobHandler receives the frames of a session. Nil functions ignore their116 // JobHandler receives the frames of a session. Nil functions ignore their
109 // frames. The functions are called from the goroutine of DriveJob, in the117 // frames. The functions are called from the goroutine of DriveJob, in the
110 // order of the stream.118 // order of the stream.
@@ -115,10 +123,13 @@ type JobHandler struct {
115 StepCompleted func(*execpb.StepCompleted)123 StepCompleted func(*execpb.StepCompleted)
116 Log func(*execpb.Log)124 Log func(*execpb.Log)
117 JobResult func(*execpb.JobResult)125 JobResult func(*execpb.JobResult)
118- // Resolve answers the executor's ResolveRequest frames. It is called in its126+ // Resolve answers the executor's ResolveRequest frames, one at a time and in
119- // own goroutine (the call goes to the server), the answer is sent as a127+ // order, from a single worker (the call goes to the server); the answer is
120- // ResolveResponse frame carrying the id of the request.128+ // sent as a ResolveResponse frame carrying the id of the request. ctx ends
121- Resolve func(*execpb.ResolveRequest) *execpb.ResolveResponse129+ // with the session: a call still running is cancelled and DriveJob waits for
130+ // it before it returns. At most resolveQueue requests wait; the next ones are
131+ // answered with an error at once, without a call.
132+ Resolve func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse
122 }133 }
123 134
124 // DriveJob is Drive for a session that carries a job: besides Output frames it135 // DriveJob is Drive for a session that carries a job: besides Output frames it
@@ -142,6 +153,27 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h
142 153
143 done := make(chan struct{})154 done := make(chan struct{})
144 defer close(done)155 defer close(done)
156+
157+ // One worker answers the resolutions; it ends, and is waited for, with the
158+ // session.
159+ resolveCh := make(chan *execpb.ResolveRequest, resolveQueue)
160+ rctx, rcancel := context.WithCancel(context.Background())
161+ var workers sync.WaitGroup
162+ workers.Add(1)
163+ go func() {
164+ defer workers.Done()
165+ for {
166+ select {
167+ case req := <-resolveCh:
168+ resp := h.Resolve(rctx, req)
169+ resp.Id = req.Id
170+ _ = c.Send(resolveFrame(resp))
171+ case <-rctx.Done():
172+ return
173+ }
174+ }
175+ }()
176+ defer func() { rcancel(); workers.Wait() }()
145 if cancel != nil {177 if cancel != nil {
146 go func() {178 go func() {
147 select {179 select {
@@ -185,14 +217,15 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h
185 }217 }
186 case *execpb.Frame_ResolveRequest:218 case *execpb.Frame_ResolveRequest:
187 req := b.ResolveRequest219 req := b.ResolveRequest
188- go func() {220+ if h.Resolve == nil {
189- resp := &execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"}221+ _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"}))
190- if h.Resolve != nil {222+ break
191- resp = h.Resolve(req)223+ }
192- resp.Id = req.Id224+ select {
193- }225+ case resolveCh <- req:
194- _ = c.Send(&execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: resp}})226+ default:
195- }()227+ _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "too many action resolutions in flight"}))
228+ }
196 case *execpb.Frame_Exit:229 case *execpb.Frame_Exit:
197 return hello, b.Exit, nil230 return hello, b.Exit, nil
198 }231 }
M internal/execstream/frame_test.go +88 -0
@@ -4,11 +4,13 @@ package execstream
44
55 import (
66 "bytes"
7+ "context"
78 "encoding/binary"
89 "errors"
910 "io"
1011 "net"
1112 "strings"
13+ "sync/atomic"
1214 "testing"
1315 "testing/iotest"
1416 "time"
@@ -172,3 +174,89 @@ func TestDriveJobDeliversJobFrames(t *testing.T) {
172174 t.Fatalf("%v", got)
173175 }
174176 }
177+
178+// GO-003: a flood of ResolveRequest frames is answered by one worker, the ones
179+// beyond the queue with an error and no call, and DriveJob does not return
180+// before the worker is done (a call in flight is cancelled).
181+func TestDriveJobResolveBurst(t *testing.T) {
182+ ln, err := net.Listen("tcp", "127.0.0.1:0")
183+ if err != nil {
184+ t.Skip(err)
185+ }
186+ defer func() { _ = ln.Close() }()
187+ g, err := net.Dial("tcp", ln.Addr().String())
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ h, err := ln.Accept()
192+ if err != nil {
193+ t.Fatal(err)
194+ }
195+ defer func() { _ = g.Close() }()
196+ gc := NewConn(g)
197+ if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}); err != nil {
198+ t.Fatal(err)
199+ }
200+ var running, maxRunning, started atomic.Int32
201+ release := make(chan struct{})
202+ var exited atomic.Bool
203+ done := make(chan struct{})
204+ go func() {
205+ defer close(done)
206+ _, _, _ = DriveJob(h, &execpb.Run{}, nil, JobHandler{Resolve: func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse {
207+ n := running.Add(1)
208+ defer running.Add(-1)
209+ for {
210+ m := maxRunning.Load()
211+ if n <= m || maxRunning.CompareAndSwap(m, n) {
212+ break
213+ }
214+ }
215+ started.Add(1)
216+ select {
217+ case <-release:
218+ case <-ctx.Done():
219+ if exited.Load() {
220+ t.Error("the worker outlived DriveJob")
221+ }
222+ }
223+ return &execpb.ResolveResponse{}
224+ }})
225+ exited.Store(true)
226+ }()
227+ // 50 requests at once while the first call is held.
228+ for i := int64(1); i <= 50; i++ {
229+ if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: &execpb.ResolveRequest{Id: i, Uses: []string{"a/b@v1"}}}}); err != nil {
230+ t.Fatal(err)
231+ }
232+ }
233+ // Collect the refusals, which are immediate: until the stream is quiet. The
234+ // worker may or may not have taken the first request before the queue filled,
235+ // so the queue and the running call hold 2 or 3 of the 50.
236+ refused := 0
237+ for {
238+ _ = g.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
239+ f, err := gc.Recv()
240+ if err != nil {
241+ break
242+ }
243+ if r := f.GetResolveResponse(); r != nil && r.Error != "" {
244+ refused++
245+ }
246+ }
247+ _ = g.SetReadDeadline(time.Time{})
248+ if refused < 50-1-resolveQueue || refused > 50-resolveQueue {
249+ t.Fatalf("%d refused of 50, want %d or %d", refused, 50-1-resolveQueue, 50-resolveQueue)
250+ }
251+ if maxRunning.Load() != 1 || started.Load() != 1 {
252+ t.Errorf("running %d, started %d: resolutions are serial", maxRunning.Load(), started.Load())
253+ }
254+ // End the session with a call still in flight: it is cancelled and joined.
255+ _ = g.Close()
256+ select {
257+ case <-done:
258+ case <-time.After(5 * time.Second):
259+ t.Fatal("DriveJob did not return")
260+ }
261+ close(release)
262+}
@@ -4,11 +4,13 @@ package execstream
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "context"
7 "encoding/binary"8 "encoding/binary"
8 "errors"9 "errors"
9 "io"10 "io"
10 "net"11 "net"
11 "strings"12 "strings"
13+ "sync/atomic"
12 "testing"14 "testing"
13 "testing/iotest"15 "testing/iotest"
14 "time"16 "time"
@@ -172,3 +174,89 @@ func TestDriveJobDeliversJobFrames(t *testing.T) {
172 t.Fatalf("%v", got)174 t.Fatalf("%v", got)
173 }175 }
174 }176 }
177+
178+// GO-003: a flood of ResolveRequest frames is answered by one worker, the ones
179+// beyond the queue with an error and no call, and DriveJob does not return
180+// before the worker is done (a call in flight is cancelled).
181+func TestDriveJobResolveBurst(t *testing.T) {
182+ ln, err := net.Listen("tcp", "127.0.0.1:0")
183+ if err != nil {
184+ t.Skip(err)
185+ }
186+ defer func() { _ = ln.Close() }()
187+ g, err := net.Dial("tcp", ln.Addr().String())
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ h, err := ln.Accept()
192+ if err != nil {
193+ t.Fatal(err)
194+ }
195+ defer func() { _ = g.Close() }()
196+ gc := NewConn(g)
197+ if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}); err != nil {
198+ t.Fatal(err)
199+ }
200+ var running, maxRunning, started atomic.Int32
201+ release := make(chan struct{})
202+ var exited atomic.Bool
203+ done := make(chan struct{})
204+ go func() {
205+ defer close(done)
206+ _, _, _ = DriveJob(h, &execpb.Run{}, nil, JobHandler{Resolve: func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse {
207+ n := running.Add(1)
208+ defer running.Add(-1)
209+ for {
210+ m := maxRunning.Load()
211+ if n <= m || maxRunning.CompareAndSwap(m, n) {
212+ break
213+ }
214+ }
215+ started.Add(1)
216+ select {
217+ case <-release:
218+ case <-ctx.Done():
219+ if exited.Load() {
220+ t.Error("the worker outlived DriveJob")
221+ }
222+ }
223+ return &execpb.ResolveResponse{}
224+ }})
225+ exited.Store(true)
226+ }()
227+ // 50 requests at once while the first call is held.
228+ for i := int64(1); i <= 50; i++ {
229+ if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: &execpb.ResolveRequest{Id: i, Uses: []string{"a/b@v1"}}}}); err != nil {
230+ t.Fatal(err)
231+ }
232+ }
233+ // Collect the refusals, which are immediate: until the stream is quiet. The
234+ // worker may or may not have taken the first request before the queue filled,
235+ // so the queue and the running call hold 2 or 3 of the 50.
236+ refused := 0
237+ for {
238+ _ = g.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
239+ f, err := gc.Recv()
240+ if err != nil {
241+ break
242+ }
243+ if r := f.GetResolveResponse(); r != nil && r.Error != "" {
244+ refused++
245+ }
246+ }
247+ _ = g.SetReadDeadline(time.Time{})
248+ if refused < 50-1-resolveQueue || refused > 50-resolveQueue {
249+ t.Fatalf("%d refused of 50, want %d or %d", refused, 50-1-resolveQueue, 50-resolveQueue)
250+ }
251+ if maxRunning.Load() != 1 || started.Load() != 1 {
252+ t.Errorf("running %d, started %d: resolutions are serial", maxRunning.Load(), started.Load())
253+ }
254+ // End the session with a call still in flight: it is cancelled and joined.
255+ _ = g.Close()
256+ select {
257+ case <-done:
258+ case <-time.After(5 * time.Second):
259+ t.Fatal("DriveJob did not return")
260+ }
261+ close(release)
262+}
M internal/executor/actions_download.go +53 -9
@@ -10,8 +10,10 @@ import (
1010 "io"
1111 "math/rand/v2"
1212 "net/http"
13+ "net/url"
1314 "os"
1415 "path/filepath"
16+ "strings"
1517 "time"
1618
1719 "github.com/google/uuid"
@@ -41,11 +43,47 @@ type errAccessDenied struct{ url string }
4143
4244 func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }
4345
44-func (j *job) httpClient() *http.Client {
45- if c := j.cfg.Actions.HTTPClient; c != nil {
46- return c
46+// originOf is scheme://host[:port] of an absolute http(s) URL, lower case and
47+// without the default port, "" for anything else.
48+func originOf(raw string) string {
49+ u, err := url.Parse(raw)
50+ if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") {
51+ return ""
4752 }
48- return &http.Client{}
53+ host, port := strings.ToLower(u.Hostname()), u.Port()
54+ if port == "" || (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {
55+ return u.Scheme + "://" + host
56+ }
57+ return u.Scheme + "://" + host + ":" + port
58+}
59+
60+// archiveClient returns the client that downloads the archives. It follows
61+// redirects (GitHub's codeload does redirect) but never carries credentials
62+// across an origin: the Authorization header is dropped as soon as the scheme,
63+// the host or the port changes, and a redirect from https to http is refused.
64+// (net/http alone would keep the header for subdomains and ignore the scheme and
65+// the port.) Tests may supply their own transport through Actions.HTTPClient;
66+// its redirect policy is replaced.
67+func (j *job) archiveClient() *http.Client {
68+ c := &http.Client{}
69+ if base := j.cfg.Actions.HTTPClient; base != nil {
70+ cp := *base
71+ c = &cp
72+ }
73+ c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
74+ if len(via) >= 10 {
75+ return errors.New("stopped after 10 redirects")
76+ }
77+ prev := via[len(via)-1]
78+ if prev.URL.Scheme == "https" && req.URL.Scheme != "https" {
79+ return fmt.Errorf("refusing to follow a redirect from https to %s", req.URL.Scheme)
80+ }
81+ if originOf(prev.URL.String()) != originOf(req.URL.String()) {
82+ req.Header.Del("Authorization")
83+ }
84+ return nil
85+ }
86+ return c
4987 }
5088
5189 // sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper.
@@ -62,10 +100,11 @@ func (j *job) sleepBackoff(ctx context.Context) time.Duration {
62100 // The final error is the one of the last attempt, which is what the runner
63101 // shows (PrepareActionsAsync prints the inner exception of
64102 // FailedToDownloadActionException).
65-func (j *job) downloadArchive(ctx context.Context, num int, url, token, file string) error {
103+func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, file string) error {
104+ url := ra.TarballURL
66105 var last error
67106 for attempt := 1; attempt <= downloadAttempts; attempt++ {
68- err := j.fetch(ctx, url, token, file)
107+ err := j.fetch(ctx, ra, file)
69108 if err == nil {
70109 return nil
71110 }
@@ -95,7 +134,12 @@ func (j *job) downloadArchive(ctx context.Context, num int, url, token, file str
95134 return last
96135 }
97136
98-func (j *job) fetch(ctx context.Context, url, token, file string) error {
137+func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error {
138+ url, token := ra.TarballURL, ra.Token
139+ // The token goes only to the origin the host named, never by the URL alone.
140+ if token != "" && (ra.AuthOrigin == "" || ra.AuthOrigin != originOf(url)) {
141+ token = ""
142+ }
99143 ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
100144 defer cancel()
101145 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -106,7 +150,7 @@ func (j *job) fetch(ctx context.Context, url, token, file string) error {
106150 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
107151 }
108152 req.Header.Set("User-Agent", "ermite-runner")
109- resp, err := j.httpClient().Do(req)
153+ resp, err := j.archiveClient().Do(req)
110154 if err != nil {
111155 return err
112156 }
@@ -156,7 +200,7 @@ func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, na
156200 }
157201 defer func() { _ = os.RemoveAll(tmp) }()
158202 archive := filepath.Join(tmp, uuid.NewString()+".tar.gz")
159- if err := j.downloadArchive(ctx, num, ra.TarballURL, ra.Token, archive); err != nil {
203+ if err := j.downloadArchive(ctx, num, ra, archive); err != nil {
160204 return err
161205 }
162206 staging := filepath.Join(tmp, "_staging")
@@ -10,8 +10,10 @@ import (
10 "io"10 "io"
11 "math/rand/v2"11 "math/rand/v2"
12 "net/http"12 "net/http"
13+ "net/url"
13 "os"14 "os"
14 "path/filepath"15 "path/filepath"
16+ "strings"
15 "time"17 "time"
16 18
17 "github.com/google/uuid"19 "github.com/google/uuid"
@@ -41,11 +43,47 @@ type errAccessDenied struct{ url string }
41 43
42 func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }44 func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }
43 45
44-func (j *job) httpClient() *http.Client {46+// originOf is scheme://host[:port] of an absolute http(s) URL, lower case and
45- if c := j.cfg.Actions.HTTPClient; c != nil {47+// without the default port, "" for anything else.
46- return c48+func originOf(raw string) string {
49+ u, err := url.Parse(raw)
50+ if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") {
51+ return ""
47 }52 }
48- return &http.Client{}53+ host, port := strings.ToLower(u.Hostname()), u.Port()
54+ if port == "" || (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {
55+ return u.Scheme + "://" + host
56+ }
57+ return u.Scheme + "://" + host + ":" + port
58+}
59+
60+// archiveClient returns the client that downloads the archives. It follows
61+// redirects (GitHub's codeload does redirect) but never carries credentials
62+// across an origin: the Authorization header is dropped as soon as the scheme,
63+// the host or the port changes, and a redirect from https to http is refused.
64+// (net/http alone would keep the header for subdomains and ignore the scheme and
65+// the port.) Tests may supply their own transport through Actions.HTTPClient;
66+// its redirect policy is replaced.
67+func (j *job) archiveClient() *http.Client {
68+ c := &http.Client{}
69+ if base := j.cfg.Actions.HTTPClient; base != nil {
70+ cp := *base
71+ c = &cp
72+ }
73+ c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
74+ if len(via) >= 10 {
75+ return errors.New("stopped after 10 redirects")
76+ }
77+ prev := via[len(via)-1]
78+ if prev.URL.Scheme == "https" && req.URL.Scheme != "https" {
79+ return fmt.Errorf("refusing to follow a redirect from https to %s", req.URL.Scheme)
80+ }
81+ if originOf(prev.URL.String()) != originOf(req.URL.String()) {
82+ req.Header.Del("Authorization")
83+ }
84+ return nil
85+ }
86+ return c
49 }87 }
50 88
51 // sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper.89 // sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper.
@@ -62,10 +100,11 @@ func (j *job) sleepBackoff(ctx context.Context) time.Duration {
62 // The final error is the one of the last attempt, which is what the runner100 // The final error is the one of the last attempt, which is what the runner
63 // shows (PrepareActionsAsync prints the inner exception of101 // shows (PrepareActionsAsync prints the inner exception of
64 // FailedToDownloadActionException).102 // FailedToDownloadActionException).
65-func (j *job) downloadArchive(ctx context.Context, num int, url, token, file string) error {103+func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, file string) error {
104+ url := ra.TarballURL
66 var last error105 var last error
67 for attempt := 1; attempt <= downloadAttempts; attempt++ {106 for attempt := 1; attempt <= downloadAttempts; attempt++ {
68- err := j.fetch(ctx, url, token, file)107+ err := j.fetch(ctx, ra, file)
69 if err == nil {108 if err == nil {
70 return nil109 return nil
71 }110 }
@@ -95,7 +134,12 @@ func (j *job) downloadArchive(ctx context.Context, num int, url, token, file str
95 return last134 return last
96 }135 }
97 136
98-func (j *job) fetch(ctx context.Context, url, token, file string) error {137+func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error {
138+ url, token := ra.TarballURL, ra.Token
139+ // The token goes only to the origin the host named, never by the URL alone.
140+ if token != "" && (ra.AuthOrigin == "" || ra.AuthOrigin != originOf(url)) {
141+ token = ""
142+ }
99 ctx, cancel := context.WithTimeout(ctx, downloadTimeout)143 ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
100 defer cancel()144 defer cancel()
101 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)145 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -106,7 +150,7 @@ func (j *job) fetch(ctx context.Context, url, token, file string) error {
106 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))150 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
107 }151 }
108 req.Header.Set("User-Agent", "ermite-runner")152 req.Header.Set("User-Agent", "ermite-runner")
109- resp, err := j.httpClient().Do(req)153+ resp, err := j.archiveClient().Do(req)
110 if err != nil {154 if err != nil {
111 return err155 return err
112 }156 }
@@ -156,7 +200,7 @@ func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, na
156 }200 }
157 defer func() { _ = os.RemoveAll(tmp) }()201 defer func() { _ = os.RemoveAll(tmp) }()
158 archive := filepath.Join(tmp, uuid.NewString()+".tar.gz")202 archive := filepath.Join(tmp, uuid.NewString()+".tar.gz")
159- if err := j.downloadArchive(ctx, num, ra.TarballURL, ra.Token, archive); err != nil {203+ if err := j.downloadArchive(ctx, num, ra, archive); err != nil {
160 return err204 return err
161 }205 }
162 staging := filepath.Join(tmp, "_staging")206 staging := filepath.Join(tmp, "_staging")
M internal/executor/actions_test.go +102 -1
@@ -495,7 +495,7 @@ func (f *fakeForge) resolver(token string) Resolver {
495495 continue
496496 }
497497 out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40),
498- TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token})
498+ TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token, AuthOrigin: tokenOrigin(token, f.srv.URL)})
499499 }
500500 return out, nil
501501 }
@@ -657,3 +657,104 @@ func TestRepositoryActionInputsAreMaskedAndTokenNotInEnv(t *testing.T) {
657657 t.Errorf("the archive token is not in the environment of actions:\n%s", got)
658658 }
659659 }
660+
661+func tokenOrigin(token, u string) string {
662+ if token == "" {
663+ return ""
664+ }
665+ return originOf(u)
666+}
667+
668+// GO-002: the token goes to the origin the host named and nowhere else, and
669+// a redirect never carries it to another origin.
670+func TestArchiveTokenOnlyToTheNamedOrigin(t *testing.T) {
671+ var gotAuth atomic.Value
672+ other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
673+ gotAuth.Store(r.Header.Get("Authorization"))
674+ _, _ = w.Write(tarOf(t, "o-x-1", files{"action.yml": nodeAction, "main.sh": "echo ok\n"}))
675+ }))
676+ defer other.Close()
677+ var firstAuth atomic.Value
678+ redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
679+ firstAuth.Store(r.Header.Get("Authorization"))
680+ http.Redirect(w, r, other.URL+"/t", http.StatusFound)
681+ }))
682+ defer redir.Close()
683+ mk := func(u, token, origin string) Resolver {
684+ return func(context.Context, []string) ([]ResolvedAction, error) {
685+ return []ResolvedAction{{Ref: "o/x@v1", NameWithOwner: "o/x", ResolvedSHA: strings.Repeat("d", 40), TarballURL: u, Token: token, AuthOrigin: origin}}, nil
686+ }
687+ }
688+ run := func(r Resolver) Result {
689+ _, res := actionRun(t, files{}, " - uses: o/x@v1\n", func(c *Config) {
690+ c.Actions.Resolver = r
691+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
692+ })
693+ return res
694+ }
695+ // Redirect to another origin (another port on the same host): the header is
696+ // sent to the first hop and dropped for the second.
697+ if res := run(mk(redir.URL+"/t", "tok", originOf(redir.URL))); res.Conclusion != ConclusionSuccess {
698+ t.Fatalf("%+v", res)
699+ }
700+ if firstAuth.Load() == "" || gotAuth.Load() != "" {
701+ t.Errorf("first hop %q, second hop %q", firstAuth.Load(), gotAuth.Load())
702+ }
703+ // A token without an AuthOrigin, or for another origin, is never sent.
704+ gotAuth.Store("unset")
705+ if res := run(mk(other.URL+"/t", "tok", "")); res.Conclusion != ConclusionSuccess {
706+ t.Fatalf("%+v", res)
707+ }
708+ if gotAuth.Load() != "" {
709+ t.Errorf("no AuthOrigin: sent %q", gotAuth.Load())
710+ }
711+ gotAuth.Store("unset")
712+ _ = run(mk(other.URL+"/t", "tok", "https://other.example"))
713+ if gotAuth.Load() != "" {
714+ t.Errorf("another origin: sent %q", gotAuth.Load())
715+ }
716+ // Without a token (github.com) nothing is sent either.
717+ gotAuth.Store("unset")
718+ _ = run(mk(other.URL+"/t", "", ""))
719+ if gotAuth.Load() != "" {
720+ t.Errorf("anonymous: sent %q", gotAuth.Load())
721+ }
722+ // The named origin, with the token.
723+ gotAuth.Store("unset")
724+ _ = run(mk(other.URL+"/t", "tok", originOf(other.URL)))
725+ if got, _ := gotAuth.Load().(string); !strings.HasPrefix(got, "Basic ") {
726+ t.Errorf("named origin: %q", got)
727+ }
728+}
729+
730+func TestArchiveRedirectPolicy(t *testing.T) {
731+ j := &job{}
732+ c := j.archiveClient()
733+ mkreq := func(u string) *http.Request {
734+ r, _ := http.NewRequest(http.MethodGet, u, nil)
735+ r.Header.Set("Authorization", "Basic x")
736+ return r
737+ }
738+ for _, tc := range []struct {
739+ from, to string
740+ keep bool
741+ refused bool
742+ }{
743+ {"https://a.example/x", "https://a.example/y", true, false},
744+ {"https://a.example/x", "https://cdn.a.example/y", false, false}, // subdomain
745+ {"https://a.example/x", "https://a.example:8443/y", false, false}, // port
746+ {"https://a.example/x", "http://a.example/y", false, true}, // downgrade
747+ {"http://a.example/x", "https://a.example/y", false, false}, // scheme change
748+ {"https://a.example:443/x", "https://a.example/y", true, false}, // same origin
749+ {"https://A.example/x", "https://a.example/y", true, false},
750+ } {
751+ req := mkreq(tc.to)
752+ err := c.CheckRedirect(req, []*http.Request{mkreq(tc.from)})
753+ if (err != nil) != tc.refused || (req.Header.Get("Authorization") != "") != tc.keep && !tc.refused {
754+ t.Errorf("%s -> %s: err=%v header=%q", tc.from, tc.to, err, req.Header.Get("Authorization"))
755+ }
756+ }
757+ if err := c.CheckRedirect(mkreq("https://a.example/"), make([]*http.Request, 10)); err == nil {
758+ t.Error("redirect loops must stop")
759+ }
760+}
@@ -495,7 +495,7 @@ func (f *fakeForge) resolver(token string) Resolver {
495 continue495 continue
496 }496 }
497 out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40),497 out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40),
498- TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token})498+ TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token, AuthOrigin: tokenOrigin(token, f.srv.URL)})
499 }499 }
500 return out, nil500 return out, nil
501 }501 }
@@ -657,3 +657,104 @@ func TestRepositoryActionInputsAreMaskedAndTokenNotInEnv(t *testing.T) {
657 t.Errorf("the archive token is not in the environment of actions:\n%s", got)657 t.Errorf("the archive token is not in the environment of actions:\n%s", got)
658 }658 }
659 }659 }
660+
661+func tokenOrigin(token, u string) string {
662+ if token == "" {
663+ return ""
664+ }
665+ return originOf(u)
666+}
667+
668+// GO-002: the token goes to the origin the host named and nowhere else, and
669+// a redirect never carries it to another origin.
670+func TestArchiveTokenOnlyToTheNamedOrigin(t *testing.T) {
671+ var gotAuth atomic.Value
672+ other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
673+ gotAuth.Store(r.Header.Get("Authorization"))
674+ _, _ = w.Write(tarOf(t, "o-x-1", files{"action.yml": nodeAction, "main.sh": "echo ok\n"}))
675+ }))
676+ defer other.Close()
677+ var firstAuth atomic.Value
678+ redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
679+ firstAuth.Store(r.Header.Get("Authorization"))
680+ http.Redirect(w, r, other.URL+"/t", http.StatusFound)
681+ }))
682+ defer redir.Close()
683+ mk := func(u, token, origin string) Resolver {
684+ return func(context.Context, []string) ([]ResolvedAction, error) {
685+ return []ResolvedAction{{Ref: "o/x@v1", NameWithOwner: "o/x", ResolvedSHA: strings.Repeat("d", 40), TarballURL: u, Token: token, AuthOrigin: origin}}, nil
686+ }
687+ }
688+ run := func(r Resolver) Result {
689+ _, res := actionRun(t, files{}, " - uses: o/x@v1\n", func(c *Config) {
690+ c.Actions.Resolver = r
691+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
692+ })
693+ return res
694+ }
695+ // Redirect to another origin (another port on the same host): the header is
696+ // sent to the first hop and dropped for the second.
697+ if res := run(mk(redir.URL+"/t", "tok", originOf(redir.URL))); res.Conclusion != ConclusionSuccess {
698+ t.Fatalf("%+v", res)
699+ }
700+ if firstAuth.Load() == "" || gotAuth.Load() != "" {
701+ t.Errorf("first hop %q, second hop %q", firstAuth.Load(), gotAuth.Load())
702+ }
703+ // A token without an AuthOrigin, or for another origin, is never sent.
704+ gotAuth.Store("unset")
705+ if res := run(mk(other.URL+"/t", "tok", "")); res.Conclusion != ConclusionSuccess {
706+ t.Fatalf("%+v", res)
707+ }
708+ if gotAuth.Load() != "" {
709+ t.Errorf("no AuthOrigin: sent %q", gotAuth.Load())
710+ }
711+ gotAuth.Store("unset")
712+ _ = run(mk(other.URL+"/t", "tok", "https://other.example"))
713+ if gotAuth.Load() != "" {
714+ t.Errorf("another origin: sent %q", gotAuth.Load())
715+ }
716+ // Without a token (github.com) nothing is sent either.
717+ gotAuth.Store("unset")
718+ _ = run(mk(other.URL+"/t", "", ""))
719+ if gotAuth.Load() != "" {
720+ t.Errorf("anonymous: sent %q", gotAuth.Load())
721+ }
722+ // The named origin, with the token.
723+ gotAuth.Store("unset")
724+ _ = run(mk(other.URL+"/t", "tok", originOf(other.URL)))
725+ if got, _ := gotAuth.Load().(string); !strings.HasPrefix(got, "Basic ") {
726+ t.Errorf("named origin: %q", got)
727+ }
728+}
729+
730+func TestArchiveRedirectPolicy(t *testing.T) {
731+ j := &job{}
732+ c := j.archiveClient()
733+ mkreq := func(u string) *http.Request {
734+ r, _ := http.NewRequest(http.MethodGet, u, nil)
735+ r.Header.Set("Authorization", "Basic x")
736+ return r
737+ }
738+ for _, tc := range []struct {
739+ from, to string
740+ keep bool
741+ refused bool
742+ }{
743+ {"https://a.example/x", "https://a.example/y", true, false},
744+ {"https://a.example/x", "https://cdn.a.example/y", false, false}, // subdomain
745+ {"https://a.example/x", "https://a.example:8443/y", false, false}, // port
746+ {"https://a.example/x", "http://a.example/y", false, true}, // downgrade
747+ {"http://a.example/x", "https://a.example/y", false, false}, // scheme change
748+ {"https://a.example:443/x", "https://a.example/y", true, false}, // same origin
749+ {"https://A.example/x", "https://a.example/y", true, false},
750+ } {
751+ req := mkreq(tc.to)
752+ err := c.CheckRedirect(req, []*http.Request{mkreq(tc.from)})
753+ if (err != nil) != tc.refused || (req.Header.Get("Authorization") != "") != tc.keep && !tc.refused {
754+ t.Errorf("%s -> %s: err=%v header=%q", tc.from, tc.to, err, req.Header.Get("Authorization"))
755+ }
756+ }
757+ if err := c.CheckRedirect(mkreq("https://a.example/"), make([]*http.Request, 10)); err == nil {
758+ t.Error("redirect loops must stop")
759+ }
760+}
M internal/executor/actions_types.go +3 -0
@@ -16,6 +16,9 @@ import (
1616 // the host). The Token is a secret: the executor masks it.
1717 type ResolvedAction struct {
1818 Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string
19+ // AuthOrigin is the only origin (scheme://host[:port]) the Token is sent to;
20+ // the host sets it. Without it the token is not sent.
21+ AuthOrigin string
1922 }
2023
2124 // Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It
@@ -16,6 +16,9 @@ import (
16 // the host). The Token is a secret: the executor masks it.16 // the host). The Token is a secret: the executor masks it.
17 type ResolvedAction struct {17 type ResolvedAction struct {
18 Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string18 Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string
19+ // AuthOrigin is the only origin (scheme://host[:port]) the Token is sent to;
20+ // the host sets it. Without it the token is not sent.
21+ AuthOrigin string
19 }22 }
20 23
21 // Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It24 // Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It
M internal/guest/resolver.go +1 -1
@@ -58,7 +58,7 @@ func (r *resolver) resolve(ctx context.Context, uses []string) ([]executor.Resol
5858 for i, a := range resp.Actions {
5959 out[i] = executor.ResolvedAction{
6060 Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha,
61- TarballURL: a.TarballUrl, Token: a.Token, Error: a.Error,
61+ TarballURL: a.TarballUrl, Token: a.Token, AuthOrigin: a.AuthOrigin, Error: a.Error,
6262 }
6363 }
6464 return out, nil
@@ -58,7 +58,7 @@ func (r *resolver) resolve(ctx context.Context, uses []string) ([]executor.Resol
58 for i, a := range resp.Actions {58 for i, a := range resp.Actions {
59 out[i] = executor.ResolvedAction{59 out[i] = executor.ResolvedAction{
60 Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha,60 Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha,
61- TarballURL: a.TarballUrl, Token: a.Token, Error: a.Error,61+ TarballURL: a.TarballUrl, Token: a.Token, AuthOrigin: a.AuthOrigin, Error: a.Error,
62 }62 }
63 }63 }
64 return out, nil64 return out, nil
M internal/guest/resolver_test.go +1 -1
@@ -52,7 +52,7 @@ func TestResolverRoundTrip(t *testing.T) {
5252 go func() {
5353 defer close(done)
5454 _, _, _ = execstream.DriveJob(hostSide, &execpb.Run{}, nil, execstream.JobHandler{
55- Resolve: func(r *execpb.ResolveRequest) *execpb.ResolveResponse {
55+ Resolve: func(_ context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse {
5656 if len(r.Uses) == 1 && r.Uses[0] == "bad@v1" {
5757 return &execpb.ResolveResponse{Error: "no"}
5858 }
@@ -52,7 +52,7 @@ func TestResolverRoundTrip(t *testing.T) {
52 go func() {52 go func() {
53 defer close(done)53 defer close(done)
54 _, _, _ = execstream.DriveJob(hostSide, &execpb.Run{}, nil, execstream.JobHandler{54 _, _, _ = execstream.DriveJob(hostSide, &execpb.Run{}, nil, execstream.JobHandler{
55- Resolve: func(r *execpb.ResolveRequest) *execpb.ResolveResponse {55+ Resolve: func(_ context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse {
56 if len(r.Uses) == 1 && r.Uses[0] == "bad@v1" {56 if len(r.Uses) == 1 && r.Uses[0] == "bad@v1" {
57 return &execpb.ResolveResponse{Error: "no"}57 return &execpb.ResolveResponse{Error: "no"}
58 }58 }
M internal/host/job.go +5 -0
@@ -71,6 +71,10 @@ type jobRun struct {
7171 // contexts is Job.Contexts as handed to the executor: with the server URLs
7272 // pointed at the runner's URL when guest forge access is on.
7373 contexts string
74+ // Origins that are ermite for the archive tokens (see resolveResponse), and
75+ // the budget of resolution requests of the job (see resolveActions).
76+ publicOrigin, serverOrigin, guestForge string
77+ resolveRounds, resolveRefs int
7478 // hard stops the sandbox at once; set before the session starts.
7579 hard func()
7680 result *execpb.JobResult
@@ -567,6 +571,7 @@ func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCL
567571 // execute starts the sandbox, runs the job in it and returns how it ended.
568572 func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome {
569573 var err error
574+ j.setOrigins(j.job.Contexts, rt.Cfg.URL, rt.Cfg.GuestForgeOrigin)
570575 if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil {
571576 j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))
572577 return failure()
@@ -71,6 +71,10 @@ type jobRun struct {
71 // contexts is Job.Contexts as handed to the executor: with the server URLs71 // contexts is Job.Contexts as handed to the executor: with the server URLs
72 // pointed at the runner's URL when guest forge access is on.72 // pointed at the runner's URL when guest forge access is on.
73 contexts string73 contexts string
74+ // Origins that are ermite for the archive tokens (see resolveResponse), and
75+ // the budget of resolution requests of the job (see resolveActions).
76+ publicOrigin, serverOrigin, guestForge string
77+ resolveRounds, resolveRefs int
74 // hard stops the sandbox at once; set before the session starts.78 // hard stops the sandbox at once; set before the session starts.
75 hard func()79 hard func()
76 result *execpb.JobResult80 result *execpb.JobResult
@@ -567,6 +571,7 @@ func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCL
567 // execute starts the sandbox, runs the job in it and returns how it ended.571 // execute starts the sandbox, runs the job in it and returns how it ended.
568 func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome {572 func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome {
569 var err error573 var err error
574+ j.setOrigins(j.job.Contexts, rt.Cfg.URL, rt.Cfg.GuestForgeOrigin)
570 if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil {575 if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil {
571 j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))576 j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))
572 return failure()577 return failure()
M internal/host/resolve.go +165 -20
@@ -6,6 +6,7 @@ import (
66 "context"
77 "encoding/json"
88 "fmt"
9+ "strings"
910
1011 "connectrpc.com/connect"
1112
@@ -14,62 +15,197 @@ import (
1415 "bnema.dev/ermite-runner/internal/execstream/execpb"
1516 )
1617
17-// maxResolveRefs is the limit of ResolveActionsRequest.refs.
18-const maxResolveRefs = 100
18+// Limits of what the sandbox may ask the server through the host. The sandbox
19+// runs untrusted code, so the host does not trust its requests.
20+//
21+// A job asks once per level of the actions it uses (the runner's depth limit is
22+// 9, so at most 10 rounds for the steps of the job and the repository
23+// composites below them), and once more for every local composite action that
24+// has repository actions in its steps, when its step runs: a workflow has at
25+// most 1,000 steps. The budget below is generous for that and bounded:
26+// maxResolveRounds requests and maxResolveRefsTotal references per job, whatever
27+// they ask. (Local composites restart the depth at 1 as in the runner; if the
28+// recording of ermite-conformance corpus/act-composite-depth shows that
29+// github.com limits them, the executor will count them, and these limits stay
30+// as the safety net.)
31+const (
32+ maxResolveRefs = 100 // per request: ResolveActionsRequest.refs
33+ maxResolveRounds = 128 // per job
34+ maxResolveRefsTotal = 2048 // per job
35+ maxRefLen = 512
36+ maxSegmentLen = 100
37+ maxRefNameLen = 255
38+)
39+
40+// checkUses validates a "uses" value of a request: owner/repo[/path]@ref with
41+// the characters repository names, paths and refs have. The server decides
42+// whether the action exists; this keeps garbage and oversized strings from
43+// reaching it.
44+func checkUses(u string) error {
45+ if u == "" || len(u) > maxRefLen {
46+ return fmt.Errorf("%q is not a valid action reference", cut(u))
47+ }
48+ name, ref, ok := strings.Cut(u, "@")
49+ if !ok || ref == "" || len(ref) > maxRefNameLen || strings.ContainsFunc(ref, badRefRune) {
50+ return fmt.Errorf("%q is not a valid action reference", cut(u))
51+ }
52+ segs := strings.Split(name, "/")
53+ if len(segs) < 2 {
54+ return fmt.Errorf("%q is not a valid action reference", cut(u))
55+ }
56+ for _, seg := range segs {
57+ if seg == "" || seg == "." || seg == ".." || len(seg) > maxSegmentLen || strings.ContainsFunc(seg, badSegmentRune) {
58+ return fmt.Errorf("%q is not a valid action reference", cut(u))
59+ }
60+ }
61+ return nil
62+}
63+
64+func cut(s string) string {
65+ if len(s) > 80 {
66+ return s[:80] + "..."
67+ }
68+ return s
69+}
70+
71+func badSegmentRune(r rune) bool {
72+ return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.'
73+}
74+
75+// badRefRune: a ref is a branch, a tag or a sha; no control characters, spaces
76+// or characters git refuses.
77+func badRefRune(r rune) bool {
78+ return r <= ' ' || r == 0x7f || strings.ContainsRune("~^:?*[\\@", r) // "@": a "uses" has exactly one (action.ParseUses)
79+}
1980
2081 // resolveActions answers a ResolveRequest of the executor with
2182 // RunnerService.ResolveActions. The job token stays here: the executor only
2283 // gets what the server returns (the per-archive tokens included, which it
23-// masks). A call that fails without a verdict is retried like the other job
24-// calls; a failure of the call itself is reported to the executor, which fails
25-// "Set up job" (ActionManager: FailedToResolveActionDownloadInfoException).
26-func (j *jobRun) resolveActions(req *execpb.ResolveRequest) *execpb.ResolveResponse {
84+// masks). Requests come one at a time (see execstream.JobHandler.Resolve). A
85+// call that fails without a verdict is retried like the other job calls; a
86+// failure of the call itself is reported to the executor, which fails "Set up
87+// job" (ActionManager: FailedToResolveActionDownloadInfoException). ctx ends
88+// with the session.
89+func (j *jobRun) resolveActions(ctx context.Context, req *execpb.ResolveRequest) *execpb.ResolveResponse {
90+ fail := func(format string, a ...any) *execpb.ResolveResponse {
91+ return &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)}
92+ }
93+ if len(req.Uses) == 0 {
94+ return fail("an empty request for action resolution")
95+ }
2796 if len(req.Uses) > maxResolveRefs {
28- return &execpb.ResolveResponse{Error: fmt.Sprintf("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs)}
97+ return fail("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs)
98+ }
99+ for _, u := range req.Uses {
100+ if err := checkUses(u); err != nil {
101+ return fail("%v", err)
102+ }
103+ }
104+ j.mu.Lock()
105+ j.resolveRounds++
106+ j.resolveRefs += len(req.Uses)
107+ over := j.resolveRounds > maxResolveRounds || j.resolveRefs > maxResolveRefsTotal
108+ j.mu.Unlock()
109+ if over {
110+ return fail("this job asked to resolve too many actions (the limits are %d requests and %d references)", maxResolveRounds, maxResolveRefsTotal)
29111 }
30112 refs := make([]*ermite.ActionRef, len(req.Uses))
31113 for i, u := range req.Uses {
32114 refs[i] = &ermite.ActionRef{Uses: u}
33115 }
116+ // The job ending, or the session, stops the call.
117+ ctx, cancel := context.WithCancel(ctx)
118+ defer cancel()
119+ stop := context.AfterFunc(j.ctx, cancel)
120+ defer stop()
34121 b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax}
35122 for attempt := 1; ; attempt++ {
36- cctx, cancel := context.WithTimeout(j.ctx, j.t.CallTimeout)
123+ cctx, ccancel := context.WithTimeout(ctx, j.t.CallTimeout)
37124 resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs}))
38- cancel()
125+ ccancel()
39126 if err == nil {
40- return resolveResponse(resp.Msg, len(req.Uses))
127+ return j.resolveResponse(resp.Msg, len(req.Uses))
41128 }
42- if j.ctx.Err() != nil {
43- return &execpb.ResolveResponse{Error: "the job was cancelled while its actions were resolved"}
129+ if ctx.Err() != nil {
130+ return fail("the job was cancelled while its actions were resolved")
44131 }
45132 switch classify(err) {
46133 case kindEnded, kindAbandoned:
47134 j.abandon(err)
48- return &execpb.ResolveResponse{Error: "the server ended the job"}
135+ return fail("the server ended the job")
49136 case kindPermanent:
50- return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))}
137+ return fail("resolving actions failed: %s", connect.CodeOf(err))
51138 }
52139 j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")
53- if attempt >= 3 || !sleep(j.ctx, b.next()) {
54- return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))}
140+ if attempt >= 3 || !sleep(ctx, b.next()) {
141+ return fail("resolving actions failed: %s", connect.CodeOf(err))
55142 }
56143 }
57144 }
58145
59-func resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse {
146+// resolveResponse converts the answer of the server. The token of an archive is
147+// a secret that the sandbox sends in an Authorization header, so it only goes
148+// with an archive of the ermite source whose tarball URL is on an origin this
149+// host knows to be ermite: the public origin of the job (github.server_url) or
150+// the origin of the runner's own URL. The origin the sandbox must send it to is
151+// AuthOrigin; with guest forge access the tarball URL on the public origin is
152+// rewritten to the runner's, like the URLs of the contexts. A token that does
153+// not satisfy this is dropped (the download then fails like an unauthorised
154+// one).
155+func (j *jobRun) resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse {
60156 if len(m.Actions) != want {
61157 return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)}
62158 }
63159 out := &execpb.ResolveResponse{}
64160 for _, a := range m.Actions {
65- out.Actions = append(out.Actions, &execpb.ResolvedAction{
161+ ra := &execpb.ResolvedAction{
66162 Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha,
67- TarballUrl: a.TarballUrl, Token: a.Token, Source: a.Source.String(), Error: a.Error,
68- })
163+ TarballUrl: a.TarballUrl, Source: a.Source.String(), Error: a.Error,
164+ }
165+ if a.Source == ermite.ActionSource_ACTION_SOURCE_ERMITE {
166+ if origin := originOf(a.TarballUrl); origin != "" {
167+ if j.guestForge != "" && origin == j.publicOrigin {
168+ ra.TarballUrl = j.guestForge + strings.TrimPrefix(a.TarballUrl, a.TarballUrl[:strings.Index(a.TarballUrl, "://")+3+hostLen(a.TarballUrl)])
169+ origin = j.guestForge
170+ }
171+ if a.Token != "" && (origin == j.guestForge || origin == j.publicOrigin || origin == j.serverOrigin) {
172+ ra.Token, ra.AuthOrigin = a.Token, origin
173+ }
174+ }
175+ }
176+ out.Actions = append(out.Actions, ra)
69177 }
70178 return out
71179 }
72180
181+// setOrigins records the origins that are ermite for this job: the public one
182+// (the origin of github.server_url as the server sent it), the runner's own
183+// (ERMITE_RUNNER_URL) and the guest forge origin, "" when it is off.
184+func (j *jobRun) setOrigins(contexts, serverURL, guestForge string) {
185+ j.serverOrigin = originOf(serverURL)
186+ j.guestForge = guestForge
187+ if guestForge != "" {
188+ j.guestForge = originOf(guestForge)
189+ }
190+ if top, err := parseObject([]byte(contexts)); err == nil {
191+ for _, m := range top {
192+ if m.key != "github" {
193+ continue
194+ }
195+ gh, err := parseObject(m.val)
196+ if err != nil {
197+ break
198+ }
199+ for _, g := range gh {
200+ var s string
201+ if g.key == "server_url" && json.Unmarshal(g.val, &s) == nil {
202+ j.publicOrigin = originOf(s)
203+ }
204+ }
205+ }
206+ }
207+}
208+
73209 // SetGitHubToken returns the contexts JSON of a job with github.token set to
74210 // token (the job repository token, Job.github_token). The key is added first,
75211 // like the runner does (ExecutionContext.cs:1023: token, then what the server
@@ -119,3 +255,12 @@ func SetGitHubToken(contexts, token string) (string, error) {
119255 out, err := encodeObject(top)
120256 return string(out), err
121257 }
258+
259+// hostLen is the length of the authority of an absolute URL.
260+func hostLen(u string) int {
261+ rest := u[strings.Index(u, "://")+3:]
262+ if i := strings.IndexAny(rest, "/?#"); i >= 0 {
263+ return i
264+ }
265+ return len(rest)
266+}
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "encoding/json"7 "encoding/json"
8 "fmt"8 "fmt"
9+ "strings"
9 10
10 "connectrpc.com/connect"11 "connectrpc.com/connect"
11 12
@@ -14,62 +15,197 @@ import (
14 "bnema.dev/ermite-runner/internal/execstream/execpb"15 "bnema.dev/ermite-runner/internal/execstream/execpb"
15 )16 )
16 17
17-// maxResolveRefs is the limit of ResolveActionsRequest.refs.18+// Limits of what the sandbox may ask the server through the host. The sandbox
18-const maxResolveRefs = 10019+// runs untrusted code, so the host does not trust its requests.
20+//
21+// A job asks once per level of the actions it uses (the runner's depth limit is
22+// 9, so at most 10 rounds for the steps of the job and the repository
23+// composites below them), and once more for every local composite action that
24+// has repository actions in its steps, when its step runs: a workflow has at
25+// most 1,000 steps. The budget below is generous for that and bounded:
26+// maxResolveRounds requests and maxResolveRefsTotal references per job, whatever
27+// they ask. (Local composites restart the depth at 1 as in the runner; if the
28+// recording of ermite-conformance corpus/act-composite-depth shows that
29+// github.com limits them, the executor will count them, and these limits stay
30+// as the safety net.)
31+const (
32+ maxResolveRefs = 100 // per request: ResolveActionsRequest.refs
33+ maxResolveRounds = 128 // per job
34+ maxResolveRefsTotal = 2048 // per job
35+ maxRefLen = 512
36+ maxSegmentLen = 100
37+ maxRefNameLen = 255
38+)
39+
40+// checkUses validates a "uses" value of a request: owner/repo[/path]@ref with
41+// the characters repository names, paths and refs have. The server decides
42+// whether the action exists; this keeps garbage and oversized strings from
43+// reaching it.
44+func checkUses(u string) error {
45+ if u == "" || len(u) > maxRefLen {
46+ return fmt.Errorf("%q is not a valid action reference", cut(u))
47+ }
48+ name, ref, ok := strings.Cut(u, "@")
49+ if !ok || ref == "" || len(ref) > maxRefNameLen || strings.ContainsFunc(ref, badRefRune) {
50+ return fmt.Errorf("%q is not a valid action reference", cut(u))
51+ }
52+ segs := strings.Split(name, "/")
53+ if len(segs) < 2 {
54+ return fmt.Errorf("%q is not a valid action reference", cut(u))
55+ }
56+ for _, seg := range segs {
57+ if seg == "" || seg == "." || seg == ".." || len(seg) > maxSegmentLen || strings.ContainsFunc(seg, badSegmentRune) {
58+ return fmt.Errorf("%q is not a valid action reference", cut(u))
59+ }
60+ }
61+ return nil
62+}
63+
64+func cut(s string) string {
65+ if len(s) > 80 {
66+ return s[:80] + "..."
67+ }
68+ return s
69+}
70+
71+func badSegmentRune(r rune) bool {
72+ return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.'
73+}
74+
75+// badRefRune: a ref is a branch, a tag or a sha; no control characters, spaces
76+// or characters git refuses.
77+func badRefRune(r rune) bool {
78+ return r <= ' ' || r == 0x7f || strings.ContainsRune("~^:?*[\\@", r) // "@": a "uses" has exactly one (action.ParseUses)
79+}
19 80
20 // resolveActions answers a ResolveRequest of the executor with81 // resolveActions answers a ResolveRequest of the executor with
21 // RunnerService.ResolveActions. The job token stays here: the executor only82 // RunnerService.ResolveActions. The job token stays here: the executor only
22 // gets what the server returns (the per-archive tokens included, which it83 // gets what the server returns (the per-archive tokens included, which it
23-// masks). A call that fails without a verdict is retried like the other job84+// masks). Requests come one at a time (see execstream.JobHandler.Resolve). A
24-// calls; a failure of the call itself is reported to the executor, which fails85+// call that fails without a verdict is retried like the other job calls; a
25-// "Set up job" (ActionManager: FailedToResolveActionDownloadInfoException).86+// failure of the call itself is reported to the executor, which fails "Set up
26-func (j *jobRun) resolveActions(req *execpb.ResolveRequest) *execpb.ResolveResponse {87+// job" (ActionManager: FailedToResolveActionDownloadInfoException). ctx ends
88+// with the session.
89+func (j *jobRun) resolveActions(ctx context.Context, req *execpb.ResolveRequest) *execpb.ResolveResponse {
90+ fail := func(format string, a ...any) *execpb.ResolveResponse {
91+ return &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)}
92+ }
93+ if len(req.Uses) == 0 {
94+ return fail("an empty request for action resolution")
95+ }
27 if len(req.Uses) > maxResolveRefs {96 if len(req.Uses) > maxResolveRefs {
28- return &execpb.ResolveResponse{Error: fmt.Sprintf("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs)}97+ return fail("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs)
98+ }
99+ for _, u := range req.Uses {
100+ if err := checkUses(u); err != nil {
101+ return fail("%v", err)
102+ }
103+ }
104+ j.mu.Lock()
105+ j.resolveRounds++
106+ j.resolveRefs += len(req.Uses)
107+ over := j.resolveRounds > maxResolveRounds || j.resolveRefs > maxResolveRefsTotal
108+ j.mu.Unlock()
109+ if over {
110+ return fail("this job asked to resolve too many actions (the limits are %d requests and %d references)", maxResolveRounds, maxResolveRefsTotal)
29 }111 }
30 refs := make([]*ermite.ActionRef, len(req.Uses))112 refs := make([]*ermite.ActionRef, len(req.Uses))
31 for i, u := range req.Uses {113 for i, u := range req.Uses {
32 refs[i] = &ermite.ActionRef{Uses: u}114 refs[i] = &ermite.ActionRef{Uses: u}
33 }115 }
116+ // The job ending, or the session, stops the call.
117+ ctx, cancel := context.WithCancel(ctx)
118+ defer cancel()
119+ stop := context.AfterFunc(j.ctx, cancel)
120+ defer stop()
34 b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax}121 b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax}
35 for attempt := 1; ; attempt++ {122 for attempt := 1; ; attempt++ {
36- cctx, cancel := context.WithTimeout(j.ctx, j.t.CallTimeout)123+ cctx, ccancel := context.WithTimeout(ctx, j.t.CallTimeout)
37 resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs}))124 resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs}))
38- cancel()125+ ccancel()
39 if err == nil {126 if err == nil {
40- return resolveResponse(resp.Msg, len(req.Uses))127+ return j.resolveResponse(resp.Msg, len(req.Uses))
41 }128 }
42- if j.ctx.Err() != nil {129+ if ctx.Err() != nil {
43- return &execpb.ResolveResponse{Error: "the job was cancelled while its actions were resolved"}130+ return fail("the job was cancelled while its actions were resolved")
44 }131 }
45 switch classify(err) {132 switch classify(err) {
46 case kindEnded, kindAbandoned:133 case kindEnded, kindAbandoned:
47 j.abandon(err)134 j.abandon(err)
48- return &execpb.ResolveResponse{Error: "the server ended the job"}135+ return fail("the server ended the job")
49 case kindPermanent:136 case kindPermanent:
50- return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))}137+ return fail("resolving actions failed: %s", connect.CodeOf(err))
51 }138 }
52 j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")139 j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")
53- if attempt >= 3 || !sleep(j.ctx, b.next()) {140+ if attempt >= 3 || !sleep(ctx, b.next()) {
54- return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))}141+ return fail("resolving actions failed: %s", connect.CodeOf(err))
55 }142 }
56 }143 }
57 }144 }
58 145
59-func resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse {146+// resolveResponse converts the answer of the server. The token of an archive is
147+// a secret that the sandbox sends in an Authorization header, so it only goes
148+// with an archive of the ermite source whose tarball URL is on an origin this
149+// host knows to be ermite: the public origin of the job (github.server_url) or
150+// the origin of the runner's own URL. The origin the sandbox must send it to is
151+// AuthOrigin; with guest forge access the tarball URL on the public origin is
152+// rewritten to the runner's, like the URLs of the contexts. A token that does
153+// not satisfy this is dropped (the download then fails like an unauthorised
154+// one).
155+func (j *jobRun) resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse {
60 if len(m.Actions) != want {156 if len(m.Actions) != want {
61 return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)}157 return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)}
62 }158 }
63 out := &execpb.ResolveResponse{}159 out := &execpb.ResolveResponse{}
64 for _, a := range m.Actions {160 for _, a := range m.Actions {
65- out.Actions = append(out.Actions, &execpb.ResolvedAction{161+ ra := &execpb.ResolvedAction{
66 Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha,162 Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha,
67- TarballUrl: a.TarballUrl, Token: a.Token, Source: a.Source.String(), Error: a.Error,163+ TarballUrl: a.TarballUrl, Source: a.Source.String(), Error: a.Error,
68- })164+ }
165+ if a.Source == ermite.ActionSource_ACTION_SOURCE_ERMITE {
166+ if origin := originOf(a.TarballUrl); origin != "" {
167+ if j.guestForge != "" && origin == j.publicOrigin {
168+ ra.TarballUrl = j.guestForge + strings.TrimPrefix(a.TarballUrl, a.TarballUrl[:strings.Index(a.TarballUrl, "://")+3+hostLen(a.TarballUrl)])
169+ origin = j.guestForge
170+ }
171+ if a.Token != "" && (origin == j.guestForge || origin == j.publicOrigin || origin == j.serverOrigin) {
172+ ra.Token, ra.AuthOrigin = a.Token, origin
173+ }
174+ }
175+ }
176+ out.Actions = append(out.Actions, ra)
69 }177 }
70 return out178 return out
71 }179 }
72 180
181+// setOrigins records the origins that are ermite for this job: the public one
182+// (the origin of github.server_url as the server sent it), the runner's own
183+// (ERMITE_RUNNER_URL) and the guest forge origin, "" when it is off.
184+func (j *jobRun) setOrigins(contexts, serverURL, guestForge string) {
185+ j.serverOrigin = originOf(serverURL)
186+ j.guestForge = guestForge
187+ if guestForge != "" {
188+ j.guestForge = originOf(guestForge)
189+ }
190+ if top, err := parseObject([]byte(contexts)); err == nil {
191+ for _, m := range top {
192+ if m.key != "github" {
193+ continue
194+ }
195+ gh, err := parseObject(m.val)
196+ if err != nil {
197+ break
198+ }
199+ for _, g := range gh {
200+ var s string
201+ if g.key == "server_url" && json.Unmarshal(g.val, &s) == nil {
202+ j.publicOrigin = originOf(s)
203+ }
204+ }
205+ }
206+ }
207+}
208+
73 // SetGitHubToken returns the contexts JSON of a job with github.token set to209 // SetGitHubToken returns the contexts JSON of a job with github.token set to
74 // token (the job repository token, Job.github_token). The key is added first,210 // token (the job repository token, Job.github_token). The key is added first,
75 // like the runner does (ExecutionContext.cs:1023: token, then what the server211 // like the runner does (ExecutionContext.cs:1023: token, then what the server
@@ -119,3 +255,12 @@ func SetGitHubToken(contexts, token string) (string, error) {
119 out, err := encodeObject(top)255 out, err := encodeObject(top)
120 return string(out), err256 return string(out), err
121 }257 }
258+
259+// hostLen is the length of the authority of an absolute URL.
260+func hostLen(u string) int {
261+ rest := u[strings.Index(u, "://")+3:]
262+ if i := strings.IndexAny(rest, "/?#"); i >= 0 {
263+ return i
264+ }
265+ return len(rest)
266+}
M internal/host/resolve_test.go +106 -7
@@ -33,23 +33,25 @@ func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermit
3333 }
3434
3535 func resolveJob(c jobClient) *jobRun {
36- return &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{},
36+ j := &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{},
3737 t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}}
38+ j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://ermite.example", "")
39+ return j
3840 }
3941
4042 func TestResolveActions(t *testing.T) {
4143 c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{
42- {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://x/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE},
44+ {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://ermite.example/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE},
4345 {Ref: "a/c@v1", Error: "boom"},
4446 }}}
45- resp := resolveJob(c).resolveActions(&execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}})
47+ resp := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}})
4648 if resp.Error != "" || len(resp.Actions) != 2 {
4749 t.Fatalf("%+v", resp)
4850 }
4951 if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" {
5052 t.Errorf("request %+v", c.got)
5153 }
52- if a := resp.Actions[0]; a.Token != "tok" || a.TarballUrl != "https://x/t" || a.NameWithOwner != "a/b" {
54+ if a := resp.Actions[0]; a.Token != "tok" || a.AuthOrigin != "https://ermite.example" || a.TarballUrl != "https://ermite.example/t" || a.NameWithOwner != "a/b" {
5355 t.Errorf("%+v", a)
5456 }
5557 if resp.Actions[1].Error != "boom" {
@@ -59,15 +61,15 @@ func TestResolveActions(t *testing.T) {
5961
6062 func TestResolveActionsFailures(t *testing.T) {
6163 c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)}
62- if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") {
64+ if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") {
6365 t.Errorf("%+v", r)
6466 }
6567 c = &resolveStub{resp: &ermite.ResolveActionsResponse{}}
66- if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" {
68+ if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" {
6769 t.Errorf("a short answer must fail: %+v", r)
6870 }
6971 many := make([]string, maxResolveRefs+1)
70- if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: many}); r.Error == "" {
72+ if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many}); r.Error == "" {
7173 t.Error("more than 100 refs must fail")
7274 }
7375 }
@@ -95,3 +97,100 @@ func TestSetGitHubToken(t *testing.T) {
9597 t.Errorf("invalid JSON: %s", got)
9698 }
9799 }
100+
101+func TestCheckUses(t *testing.T) {
102+ for u, ok := range map[string]bool{
103+ "actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true,
104+ "": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false,
105+ "o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false,
106+ "o/r\x00@v1": false, strings.Repeat("a", 101) + "/r@v1": false, "o/r@" + strings.Repeat("a", 256): false,
107+ "o/" + strings.Repeat("a/", 300) + "r@v1": false, "ö/r@v1": false,
108+ } {
109+ if err := checkUses(u); (err == nil) != ok {
110+ t.Errorf("%q: %v, want ok=%v", u, err, ok)
111+ }
112+ }
113+}
114+
115+// GO-003: whatever the sandbox sends, the server is called with valid
116+// references only, and with a bounded number of calls and references.
117+func TestResolveActionsRejectsAndBudgets(t *testing.T) {
118+ c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: "a/b@v1"}}}}
119+ j := resolveJob(c)
120+ for _, uses := range [][]string{nil, {}, {""}, {"nonsense"}, {"a/b@v1", "bad ref"}} {
121+ c.got = nil
122+ if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: uses}); r.Error == "" || c.got != nil {
123+ t.Errorf("%q: error %q, called=%v", uses, r.Error, c.got != nil)
124+ }
125+ }
126+ var calls int
127+ for i := 0; i < maxResolveRounds+20; i++ {
128+ c.got = nil
129+ r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}})
130+ if c.got != nil {
131+ calls++
132+ }
133+ if i >= maxResolveRounds && r.Error == "" {
134+ t.Fatalf("round %d is over the budget and was served", i)
135+ }
136+ }
137+ if calls != maxResolveRounds {
138+ t.Errorf("%d calls, the budget is %d", calls, maxResolveRounds)
139+ }
140+ // The references count too.
141+ j = resolveJob(c)
142+ c.resp = &ermite.ResolveActionsResponse{}
143+ for i := 0; i < maxResolveRefs; i++ {
144+ c.resp.Actions = append(c.resp.Actions, &ermite.ResolvedAction{Ref: "a/b@v1"})
145+ }
146+ big := make([]string, maxResolveRefs)
147+ for i := range big {
148+ big[i] = "a/b@v1"
149+ }
150+ served := 0
151+ for i := 0; i < 40; i++ {
152+ if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: big}); r.Error == "" {
153+ served++
154+ }
155+ }
156+ if served != maxResolveRefsTotal/maxResolveRefs {
157+ t.Errorf("served %d requests of %d refs, the budget is %d refs", served, maxResolveRefs, maxResolveRefsTotal)
158+ }
159+}
160+
161+// GO-002 on the host side: the token only goes with an ermite archive whose URL
162+// is on an origin of ermite; with guest forge access the URL is rewritten.
163+func TestResolveResponseTokenOrigin(t *testing.T) {
164+ j := resolveJob(&resolveStub{})
165+ ermiteSrc, ghSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE, ermite.ActionSource_ACTION_SOURCE_GITHUB
166+ conv := func(j *jobRun, src ermite.ActionSource, url, tok string) *execpb.ResolvedAction {
167+ r := j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: src, TarballUrl: url, Token: tok}}}, 1)
168+ return r.Actions[0]
169+ }
170+ if a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t"); a.Token != "t" || a.AuthOrigin != "https://ermite.example" {
171+ t.Errorf("%+v", a)
172+ }
173+ for name, a := range map[string]*execpb.ResolvedAction{
174+ "github source": conv(j, ghSrc, "https://ermite.example/x", "t"),
175+ "foreign origin": conv(j, ermiteSrc, "https://evil.example/x", "t"),
176+ "subdomain": conv(j, ermiteSrc, "https://x.ermite.example/x", "t"),
177+ "other port": conv(j, ermiteSrc, "https://ermite.example:8443/x", "t"),
178+ "other scheme": conv(j, ermiteSrc, "http://ermite.example/x", "t"),
179+ "not a url": conv(j, ermiteSrc, "ermite.example/x", "t"),
180+ "credentials in it": conv(j, ermiteSrc, "https://u:p@ermite.example/x", "t"),
181+ } {
182+ if a.Token != "" || a.AuthOrigin != "" {
183+ t.Errorf("%s: token kept: %+v", name, a)
184+ }
185+ }
186+ // No token, no origin: github.com archives are anonymous.
187+ if a := conv(j, ghSrc, "https://codeload.github.com/o/r/tar.gz/abc", ""); a.Token != "" || a.AuthOrigin != "" {
188+ t.Errorf("%+v", a)
189+ }
190+ // Guest forge access: the sandbox reaches ermite at the runner's origin.
191+ j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://runner.internal:8088", "http://10.0.2.2:8088")
192+ a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t")
193+ if a.TarballUrl != "http://10.0.2.2:8088/-/github/api/repos/o/r/tarball/abc" || a.AuthOrigin != "http://10.0.2.2:8088" || a.Token != "t" {
194+ t.Errorf("%+v", a)
195+ }
196+}
@@ -33,23 +33,25 @@ func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermit
33 }33 }
34 34
35 func resolveJob(c jobClient) *jobRun {35 func resolveJob(c jobClient) *jobRun {
36- return &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{},36+ j := &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{},
37 t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}}37 t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}}
38+ j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://ermite.example", "")
39+ return j
38 }40 }
39 41
40 func TestResolveActions(t *testing.T) {42 func TestResolveActions(t *testing.T) {
41 c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{43 c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{
42- {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://x/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE},44+ {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://ermite.example/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE},
43 {Ref: "a/c@v1", Error: "boom"},45 {Ref: "a/c@v1", Error: "boom"},
44 }}}46 }}}
45- resp := resolveJob(c).resolveActions(&execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}})47+ resp := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}})
46 if resp.Error != "" || len(resp.Actions) != 2 {48 if resp.Error != "" || len(resp.Actions) != 2 {
47 t.Fatalf("%+v", resp)49 t.Fatalf("%+v", resp)
48 }50 }
49 if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" {51 if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" {
50 t.Errorf("request %+v", c.got)52 t.Errorf("request %+v", c.got)
51 }53 }
52- if a := resp.Actions[0]; a.Token != "tok" || a.TarballUrl != "https://x/t" || a.NameWithOwner != "a/b" {54+ if a := resp.Actions[0]; a.Token != "tok" || a.AuthOrigin != "https://ermite.example" || a.TarballUrl != "https://ermite.example/t" || a.NameWithOwner != "a/b" {
53 t.Errorf("%+v", a)55 t.Errorf("%+v", a)
54 }56 }
55 if resp.Actions[1].Error != "boom" {57 if resp.Actions[1].Error != "boom" {
@@ -59,15 +61,15 @@ func TestResolveActions(t *testing.T) {
59 61
60 func TestResolveActionsFailures(t *testing.T) {62 func TestResolveActionsFailures(t *testing.T) {
61 c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)}63 c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)}
62- if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") {64+ if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") {
63 t.Errorf("%+v", r)65 t.Errorf("%+v", r)
64 }66 }
65 c = &resolveStub{resp: &ermite.ResolveActionsResponse{}}67 c = &resolveStub{resp: &ermite.ResolveActionsResponse{}}
66- if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" {68+ if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" {
67 t.Errorf("a short answer must fail: %+v", r)69 t.Errorf("a short answer must fail: %+v", r)
68 }70 }
69 many := make([]string, maxResolveRefs+1)71 many := make([]string, maxResolveRefs+1)
70- if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: many}); r.Error == "" {72+ if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many}); r.Error == "" {
71 t.Error("more than 100 refs must fail")73 t.Error("more than 100 refs must fail")
72 }74 }
73 }75 }
@@ -95,3 +97,100 @@ func TestSetGitHubToken(t *testing.T) {
95 t.Errorf("invalid JSON: %s", got)97 t.Errorf("invalid JSON: %s", got)
96 }98 }
97 }99 }
100+
101+func TestCheckUses(t *testing.T) {
102+ for u, ok := range map[string]bool{
103+ "actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true,
104+ "": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false,
105+ "o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false,
106+ "o/r\x00@v1": false, strings.Repeat("a", 101) + "/r@v1": false, "o/r@" + strings.Repeat("a", 256): false,
107+ "o/" + strings.Repeat("a/", 300) + "r@v1": false, "ö/r@v1": false,
108+ } {
109+ if err := checkUses(u); (err == nil) != ok {
110+ t.Errorf("%q: %v, want ok=%v", u, err, ok)
111+ }
112+ }
113+}
114+
115+// GO-003: whatever the sandbox sends, the server is called with valid
116+// references only, and with a bounded number of calls and references.
117+func TestResolveActionsRejectsAndBudgets(t *testing.T) {
118+ c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: "a/b@v1"}}}}
119+ j := resolveJob(c)
120+ for _, uses := range [][]string{nil, {}, {""}, {"nonsense"}, {"a/b@v1", "bad ref"}} {
121+ c.got = nil
122+ if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: uses}); r.Error == "" || c.got != nil {
123+ t.Errorf("%q: error %q, called=%v", uses, r.Error, c.got != nil)
124+ }
125+ }
126+ var calls int
127+ for i := 0; i < maxResolveRounds+20; i++ {
128+ c.got = nil
129+ r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}})
130+ if c.got != nil {
131+ calls++
132+ }
133+ if i >= maxResolveRounds && r.Error == "" {
134+ t.Fatalf("round %d is over the budget and was served", i)
135+ }
136+ }
137+ if calls != maxResolveRounds {
138+ t.Errorf("%d calls, the budget is %d", calls, maxResolveRounds)
139+ }
140+ // The references count too.
141+ j = resolveJob(c)
142+ c.resp = &ermite.ResolveActionsResponse{}
143+ for i := 0; i < maxResolveRefs; i++ {
144+ c.resp.Actions = append(c.resp.Actions, &ermite.ResolvedAction{Ref: "a/b@v1"})
145+ }
146+ big := make([]string, maxResolveRefs)
147+ for i := range big {
148+ big[i] = "a/b@v1"
149+ }
150+ served := 0
151+ for i := 0; i < 40; i++ {
152+ if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: big}); r.Error == "" {
153+ served++
154+ }
155+ }
156+ if served != maxResolveRefsTotal/maxResolveRefs {
157+ t.Errorf("served %d requests of %d refs, the budget is %d refs", served, maxResolveRefs, maxResolveRefsTotal)
158+ }
159+}
160+
161+// GO-002 on the host side: the token only goes with an ermite archive whose URL
162+// is on an origin of ermite; with guest forge access the URL is rewritten.
163+func TestResolveResponseTokenOrigin(t *testing.T) {
164+ j := resolveJob(&resolveStub{})
165+ ermiteSrc, ghSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE, ermite.ActionSource_ACTION_SOURCE_GITHUB
166+ conv := func(j *jobRun, src ermite.ActionSource, url, tok string) *execpb.ResolvedAction {
167+ r := j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: src, TarballUrl: url, Token: tok}}}, 1)
168+ return r.Actions[0]
169+ }
170+ if a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t"); a.Token != "t" || a.AuthOrigin != "https://ermite.example" {
171+ t.Errorf("%+v", a)
172+ }
173+ for name, a := range map[string]*execpb.ResolvedAction{
174+ "github source": conv(j, ghSrc, "https://ermite.example/x", "t"),
175+ "foreign origin": conv(j, ermiteSrc, "https://evil.example/x", "t"),
176+ "subdomain": conv(j, ermiteSrc, "https://x.ermite.example/x", "t"),
177+ "other port": conv(j, ermiteSrc, "https://ermite.example:8443/x", "t"),
178+ "other scheme": conv(j, ermiteSrc, "http://ermite.example/x", "t"),
179+ "not a url": conv(j, ermiteSrc, "ermite.example/x", "t"),
180+ "credentials in it": conv(j, ermiteSrc, "https://u:p@ermite.example/x", "t"),
181+ } {
182+ if a.Token != "" || a.AuthOrigin != "" {
183+ t.Errorf("%s: token kept: %+v", name, a)
184+ }
185+ }
186+ // No token, no origin: github.com archives are anonymous.
187+ if a := conv(j, ghSrc, "https://codeload.github.com/o/r/tar.gz/abc", ""); a.Token != "" || a.AuthOrigin != "" {
188+ t.Errorf("%+v", a)
189+ }
190+ // Guest forge access: the sandbox reaches ermite at the runner's origin.
191+ j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://runner.internal:8088", "http://10.0.2.2:8088")
192+ a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t")
193+ if a.TarballUrl != "http://10.0.2.2:8088/-/github/api/repos/o/r/tarball/abc" || a.AuthOrigin != "http://10.0.2.2:8088" || a.Token != "t" {
194+ t.Errorf("%+v", a)
195+ }
196+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close