detached e509bea

fix(host,executor): compare origins in one canonical form with IPv6 hosts bracketed and accept a literal U+FFFD in action paths

commit
e509beab9805067edd041715e0d837ca87e235b6
author
brice · · 2026-10-14 03:14 UTC
parent
6cc6c69
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +221 -36
M internal/executor/actions_download.go +14 -15
@@ -10,14 +10,14 @@ import (
1010 "io"
1111 "math/rand/v2"
1212 "net/http"
13- "net/url"
1413 "os"
1514 "path/filepath"
1615 "slices"
17- "strings"
1816 "time"
1917
2018 "github.com/google/uuid"
19+
20+ "bnema.dev/ermite-runner/internal/urlorigin"
2121 )
2222
2323 // Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760).
@@ -44,18 +44,17 @@ type errAccessDenied struct{ url string }
4444
4545 func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }
4646
47-// originOf is scheme://host[:port] of an absolute http(s) URL, lower case and
48-// without the default port, "" for anything else.
49-func originOf(raw string) string {
50- u, err := url.Parse(raw)
51- if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") {
52- return ""
53- }
54- host, port := strings.ToLower(u.Hostname()), u.Port()
55- if port == "" || (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {
56- return u.Scheme + "://" + host
57- }
58- return u.Scheme + "://" + host + ":" + port
47+// originOf is the canonical origin of a URL (urlorigin.Canonical): the one the
48+// host and the executor both compare, scheme, host and port with the default
49+// port filled in, "" for anything that is not an absolute http(s) URL.
50+func originOf(raw string) string { return urlorigin.Canonical(raw) }
51+
52+// tokenAllowed reports whether the token of ra may be sent to its tarball URL:
53+// the URL is on exactly the origin the host named, compared in canonical form
54+// (default ports filled in, IPv6 hosts in brackets).
55+func tokenAllowed(ra ResolvedAction) bool {
56+ o := originOf(ra.AuthOrigin)
57+ return ra.Token != "" && o != "" && o == originOf(ra.TarballURL)
5958 }
6059
6160 // archiveClient returns the client that downloads the archives. It follows
@@ -151,7 +150,7 @@ func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, f
151150 func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error {
152151 url, token := ra.TarballURL, ra.Token
153152 // The token goes only to the origin the host named, never by the URL alone.
154- if token != "" && (ra.AuthOrigin == "" || ra.AuthOrigin != originOf(url)) {
153+ if !tokenAllowed(ra) {
155154 token = ""
156155 }
157156 ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
@@ -10,14 +10,14 @@ import (
10 "io"10 "io"
11 "math/rand/v2"11 "math/rand/v2"
12 "net/http"12 "net/http"
13- "net/url"
14 "os"13 "os"
15 "path/filepath"14 "path/filepath"
16 "slices"15 "slices"
17- "strings"
18 "time"16 "time"
19 17
20 "github.com/google/uuid"18 "github.com/google/uuid"
19+
20+ "bnema.dev/ermite-runner/internal/urlorigin"
21 )21 )
22 22
23 // Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760).23 // Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760).
@@ -44,18 +44,17 @@ type errAccessDenied struct{ url string }
44 44
45 func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }45 func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }
46 46
47-// originOf is scheme://host[:port] of an absolute http(s) URL, lower case and47+// originOf is the canonical origin of a URL (urlorigin.Canonical): the one the
48-// without the default port, "" for anything else.48+// host and the executor both compare, scheme, host and port with the default
49-func originOf(raw string) string {49+// port filled in, "" for anything that is not an absolute http(s) URL.
50- u, err := url.Parse(raw)50+func originOf(raw string) string { return urlorigin.Canonical(raw) }
51- if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") {51+
52- return ""52+// tokenAllowed reports whether the token of ra may be sent to its tarball URL:
53- }53+// the URL is on exactly the origin the host named, compared in canonical form
54- host, port := strings.ToLower(u.Hostname()), u.Port()54+// (default ports filled in, IPv6 hosts in brackets).
55- if port == "" || (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {55+func tokenAllowed(ra ResolvedAction) bool {
56- return u.Scheme + "://" + host56+ o := originOf(ra.AuthOrigin)
57- }57+ return ra.Token != "" && o != "" && o == originOf(ra.TarballURL)
58- return u.Scheme + "://" + host + ":" + port
59 }58 }
60 59
61 // archiveClient returns the client that downloads the archives. It follows60 // archiveClient returns the client that downloads the archives. It follows
@@ -151,7 +150,7 @@ func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, f
151 func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error {150 func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error {
152 url, token := ra.TarballURL, ra.Token151 url, token := ra.TarballURL, ra.Token
153 // The token goes only to the origin the host named, never by the URL alone.152 // The token goes only to the origin the host named, never by the URL alone.
154- if token != "" && (ra.AuthOrigin == "" || ra.AuthOrigin != originOf(url)) {153+ if !tokenAllowed(ra) {
155 token = ""154 token = ""
156 }155 }
157 ctx, cancel := context.WithTimeout(ctx, downloadTimeout)156 ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
M internal/executor/actions_test.go +31 -0
@@ -866,3 +866,34 @@ func TestArchiveRedirectChainNeverReauthorises(t *testing.T) {
866866 }
867867 }
868868 }
869+
870+// GO-012: the token goes to the exact origin, in canonical form.
871+func TestTokenAllowedOrigins(t *testing.T) {
872+ for _, tc := range []struct {
873+ url, origin string
874+ ok bool
875+ }{
876+ {"https://ermite.example/x", "https://ermite.example", true},
877+ {"https://ermite.example:443/x", "https://ermite.example", true}, // default port, either way
878+ {"https://ermite.example/x", "https://ermite.example:443", true},
879+ {"https://ERMITE.example/x", "https://ermite.example", true},
880+ {"https://[::1]:8443/x", "https://[::1]:8443", true},
881+ {"https://[::1]:8443/x", "https://[::1:8443]", false}, // the collision of the review
882+ {"https://[::1:8443]/x", "https://[::1]:8443", false},
883+ {"https://[::1]/x", "https://[::1]:443", true},
884+ {"https://[::1]/x", "https://[::1]:8443", false},
885+ {"http://ermite.example/x", "https://ermite.example", false},
886+ {"https://ermite.example:8443/x", "https://ermite.example", false},
887+ {"https://sub.ermite.example/x", "https://ermite.example", false},
888+ {"https://ermite.example/x", "", false},
889+ {"https://ermite.example/x", "not an origin", false},
890+ {"https://u:p@ermite.example/x", "https://ermite.example", false},
891+ } {
892+ if got := tokenAllowed(ResolvedAction{Token: "t", TarballURL: tc.url, AuthOrigin: tc.origin}); got != tc.ok {
893+ t.Errorf("url %q origin %q: %v, want %v", tc.url, tc.origin, got, tc.ok)
894+ }
895+ }
896+ if tokenAllowed(ResolvedAction{TarballURL: "https://a.test/x", AuthOrigin: "https://a.test"}) {
897+ t.Error("no token, nothing to allow")
898+ }
899+}
@@ -866,3 +866,34 @@ func TestArchiveRedirectChainNeverReauthorises(t *testing.T) {
866 }866 }
867 }867 }
868 }868 }
869+
870+// GO-012: the token goes to the exact origin, in canonical form.
871+func TestTokenAllowedOrigins(t *testing.T) {
872+ for _, tc := range []struct {
873+ url, origin string
874+ ok bool
875+ }{
876+ {"https://ermite.example/x", "https://ermite.example", true},
877+ {"https://ermite.example:443/x", "https://ermite.example", true}, // default port, either way
878+ {"https://ermite.example/x", "https://ermite.example:443", true},
879+ {"https://ERMITE.example/x", "https://ermite.example", true},
880+ {"https://[::1]:8443/x", "https://[::1]:8443", true},
881+ {"https://[::1]:8443/x", "https://[::1:8443]", false}, // the collision of the review
882+ {"https://[::1:8443]/x", "https://[::1]:8443", false},
883+ {"https://[::1]/x", "https://[::1]:443", true},
884+ {"https://[::1]/x", "https://[::1]:8443", false},
885+ {"http://ermite.example/x", "https://ermite.example", false},
886+ {"https://ermite.example:8443/x", "https://ermite.example", false},
887+ {"https://sub.ermite.example/x", "https://ermite.example", false},
888+ {"https://ermite.example/x", "", false},
889+ {"https://ermite.example/x", "not an origin", false},
890+ {"https://u:p@ermite.example/x", "https://ermite.example", false},
891+ } {
892+ if got := tokenAllowed(ResolvedAction{Token: "t", TarballURL: tc.url, AuthOrigin: tc.origin}); got != tc.ok {
893+ t.Errorf("url %q origin %q: %v, want %v", tc.url, tc.origin, got, tc.ok)
894+ }
895+ }
896+ if tokenAllowed(ResolvedAction{TarballURL: "https://a.test/x", AuthOrigin: "https://a.test"}) {
897+ t.Error("no token, nothing to allow")
898+ }
899+}
M internal/host/guestforge.go +6 -18
@@ -8,8 +8,9 @@ import (
88 "errors"
99 "fmt"
1010 "io"
11- "net/url"
1211 "strings"
12+
13+ "bnema.dev/ermite-runner/internal/urlorigin"
1314 )
1415
1516 // serverURLKeys are the keys of the github context that actions use to reach
@@ -110,23 +111,10 @@ func RewriteServerURLs(contexts, target string) (string, error) {
110111 }
111112
112113 // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and
113-// without the default port, or "" for anything else (including URLs with
114-// credentials, which are never rewritten).
115-func originOf(raw string) string {
116- u, err := url.Parse(raw)
117- if err != nil || u.Host == "" || u.User != nil {
118- return ""
119- }
120- scheme := strings.ToLower(u.Scheme)
121- if scheme != "http" && scheme != "https" {
122- return ""
123- }
124- host, port := strings.ToLower(u.Hostname()), u.Port()
125- if port == "" || (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
126- return scheme + "://" + host
127- }
128- return scheme + "://" + host + ":" + port
129-}
114+// without the default port, an IPv6 host in brackets, or "" for anything else
115+// (including URLs with credentials, which are never rewritten). It is
116+// urlorigin.Prefix, the form the executor compares by urlorigin.Canonical.
117+func originOf(raw string) string { return urlorigin.Prefix(raw) }
130118
131119 func marshalNoEscape(v any) (json.RawMessage, error) {
132120 var b bytes.Buffer
@@ -8,8 +8,9 @@ import (
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10 "io"10 "io"
11- "net/url"
12 "strings"11 "strings"
12+
13+ "bnema.dev/ermite-runner/internal/urlorigin"
13 )14 )
14 15
15 // serverURLKeys are the keys of the github context that actions use to reach16 // serverURLKeys are the keys of the github context that actions use to reach
@@ -110,23 +111,10 @@ func RewriteServerURLs(contexts, target string) (string, error) {
110 }111 }
111 112
112 // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and113 // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and
113-// without the default port, or "" for anything else (including URLs with114+// without the default port, an IPv6 host in brackets, or "" for anything else
114-// credentials, which are never rewritten).115+// (including URLs with credentials, which are never rewritten). It is
115-func originOf(raw string) string {116+// urlorigin.Prefix, the form the executor compares by urlorigin.Canonical.
116- u, err := url.Parse(raw)117+func originOf(raw string) string { return urlorigin.Prefix(raw) }
117- if err != nil || u.Host == "" || u.User != nil {
118- return ""
119- }
120- scheme := strings.ToLower(u.Scheme)
121- if scheme != "http" && scheme != "https" {
122- return ""
123- }
124- host, port := strings.ToLower(u.Hostname()), u.Port()
125- if port == "" || (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
126- return scheme + "://" + host
127- }
128- return scheme + "://" + host + ":" + port
129-}
130 118
131 func marshalNoEscape(v any) (json.RawMessage, error) {119 func marshalNoEscape(v any) (json.RawMessage, error) {
132 var b bytes.Buffer120 var b bytes.Buffer
M internal/host/guestforge_test.go +19 -0
@@ -112,6 +112,10 @@ func TestOriginOf(t *testing.T) {
112112 "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test",
113113 "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test",
114114 "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "",
115+ // GO-012: an IPv6 host stays in brackets, and the host "::1:8443" is not the
116+ // host "::1" on port 8443.
117+ "https://[::1]:8443/x": "https://[::1]:8443", "https://[::1:8443]/x": "https://[::1:8443]", "https://[::1]:443/": "https://[::1]",
118+ "http://[2001:DB8::1]:80": "http://[2001:db8::1]",
115119 } {
116120 if got := originOf(in); got != want {
117121 t.Errorf("originOf(%q) = %q, want %q", in, got, want)
@@ -146,3 +150,18 @@ func TestJobContextsAreRewrittenOnlyWithGuestForge(t *testing.T) {
146150 }
147151 }
148152 }
153+
154+// GO-012: guest forge access on an IPv6 address. The URLs of the contexts are
155+// rewritten to the runner's origin, the brackets and the port kept, and the
156+// origin of the other host with the look-alike spelling is not touched.
157+func TestRewriteServerURLsIPv6(t *testing.T) {
158+ in := `{"github":{"server_url":"https://[::1]:8443","api_url":"https://[::1]:8443/-/github/api","graphql_url":"https://[::1:8443]/x"}}`
159+ got, err := RewriteServerURLs(in, "http://[fd00::2]:8088")
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ want := `{"github":{"server_url":"http://[fd00::2]:8088","api_url":"http://[fd00::2]:8088/-/github/api","graphql_url":"https://[::1:8443]/x"}}`
164+ if got != want {
165+ t.Errorf("got %s\nwant %s", got, want)
166+ }
167+}
@@ -112,6 +112,10 @@ func TestOriginOf(t *testing.T) {
112 "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test",112 "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test",
113 "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test",113 "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test",
114 "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "",114 "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "",
115+ // GO-012: an IPv6 host stays in brackets, and the host "::1:8443" is not the
116+ // host "::1" on port 8443.
117+ "https://[::1]:8443/x": "https://[::1]:8443", "https://[::1:8443]/x": "https://[::1:8443]", "https://[::1]:443/": "https://[::1]",
118+ "http://[2001:DB8::1]:80": "http://[2001:db8::1]",
115 } {119 } {
116 if got := originOf(in); got != want {120 if got := originOf(in); got != want {
117 t.Errorf("originOf(%q) = %q, want %q", in, got, want)121 t.Errorf("originOf(%q) = %q, want %q", in, got, want)
@@ -146,3 +150,18 @@ func TestJobContextsAreRewrittenOnlyWithGuestForge(t *testing.T) {
146 }150 }
147 }151 }
148 }152 }
153+
154+// GO-012: guest forge access on an IPv6 address. The URLs of the contexts are
155+// rewritten to the runner's origin, the brackets and the port kept, and the
156+// origin of the other host with the look-alike spelling is not touched.
157+func TestRewriteServerURLsIPv6(t *testing.T) {
158+ in := `{"github":{"server_url":"https://[::1]:8443","api_url":"https://[::1]:8443/-/github/api","graphql_url":"https://[::1:8443]/x"}}`
159+ got, err := RewriteServerURLs(in, "http://[fd00::2]:8088")
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ want := `{"github":{"server_url":"http://[fd00::2]:8088","api_url":"http://[fd00::2]:8088/-/github/api","graphql_url":"https://[::1:8443]/x"}}`
164+ if got != want {
165+ t.Errorf("got %s\nwant %s", got, want)
166+ }
167+}
M internal/host/resolve.go +4 -2
@@ -77,8 +77,10 @@ func checkUses(u string) error {
7777 return nil
7878 }
7979
80-// badPathRune: control characters (NUL included) and the backslash.
81-func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' || r == utf8.RuneError }
80+// badPathRune: control characters (NUL included) and the backslash. Invalid
81+// UTF-8 is refused for the whole value by utf8.ValidString; a literal U+FFFD is a
82+// valid character.
83+func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' }
8284
8385 func cut(s string) string {
8486 if len(s) > 80 {
@@ -77,8 +77,10 @@ func checkUses(u string) error {
77 return nil77 return nil
78 }78 }
79 79
80-// badPathRune: control characters (NUL included) and the backslash.80+// badPathRune: control characters (NUL included) and the backslash. Invalid
81-func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' || r == utf8.RuneError }81+// UTF-8 is refused for the whole value by utf8.ValidString; a literal U+FFFD is a
82+// valid character.
83+func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' }
82 84
83 func cut(s string) string {85 func cut(s string) string {
84 if len(s) > 80 {86 if len(s) > 80 {
M internal/host/resolve_test.go +27 -1
@@ -115,10 +115,11 @@ func TestCheckUses(t *testing.T) {
115115 "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,
116116 // GO-011: the path of the action is a path, not a repository name.
117117 "o/r/action+tools@v1": true, "o/r/déploiement/日本語@v1": true, "o/r/with space/x@v1": true, "o/r/a(b)[c]{d}~e#f%g@v1": true,
118+ "o/r/a\ufffdb@v1": true, "o/r/\ufffd@v1": true, // a literal U+FFFD is a valid character
118119 "o/r/" + long(255) + "@v1": true, "o/r/.github/actions/x@v1": true, "o/r/.hidden@v1": true, "o/r/a..b@v1": true,
119120 "": 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,
120121 "o/r/a/../b@v1": false, "o/r/./a@v1": false, "o/r/a//b@v1": false, "o/r/a/@v1": false,
121- "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false,
122+ "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false, "o/r/a\xc3(b@v1": false, "o/r/\xed\xa0\x80@v1": false,
122123 "o/r/" + long(256) + "@v1": false, long(101) + "/r@v1": false, "o/" + long(101) + "@v1": false,
123124 "o/r@" + long(256): false, "o/r/" + strings.Repeat("a/", 600) + "x@v1": false,
124125 "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,
@@ -277,3 +278,28 @@ func TestResolveResponseTokenOrigin(t *testing.T) {
277278 t.Errorf("%+v", a)
278279 }
279280 }
281+
282+// GO-012 on the archive tokens: with IPv6 hosts the origin named for the token is
283+// exact, and a look-alike is not accepted.
284+func TestResolveResponseTokenOriginIPv6(t *testing.T) {
285+ j := resolveJob(&resolveStub{})
286+ j.setOrigins(`{"github":{"server_url":"https://[::1]:8443"}}`, "https://[fd00::9]", "http://[fd00::2]:8088")
287+ ermiteSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE
288+ conv := func(url string) *execpb.ResolvedAction {
289+ return j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: ermiteSrc, TarballUrl: url, Token: "t"}}}, 1).Actions[0]
290+ }
291+ // The public origin is rewritten to the guest one.
292+ if a := conv("https://[::1]:8443/-/github/api/repos/o/r/tarball/abc"); a.Token != "t" || a.AuthOrigin != "http://[fd00::2]:8088" || a.TarballUrl != "http://[fd00::2]:8088/-/github/api/repos/o/r/tarball/abc" {
293+ t.Errorf("%+v", a)
294+ }
295+ // The runner's own origin, default port.
296+ if a := conv("https://[fd00::9]:443/x"); a.Token != "t" || a.AuthOrigin != "https://[fd00::9]" {
297+ t.Errorf("%+v", a)
298+ }
299+ // The look-alike host "::1:8443" and other ports are not ermite.
300+ for _, u := range []string{"https://[::1:8443]/x", "https://[::1]:8444/x", "http://[::1]:8443/x", "https://[::1]/x"} {
301+ if a := conv(u); a.Token != "" || a.AuthOrigin != "" {
302+ t.Errorf("%s: token kept: %+v", u, a)
303+ }
304+ }
305+}
@@ -115,10 +115,11 @@ func TestCheckUses(t *testing.T) {
115 "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,115 "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,
116 // GO-011: the path of the action is a path, not a repository name.116 // GO-011: the path of the action is a path, not a repository name.
117 "o/r/action+tools@v1": true, "o/r/déploiement/日本語@v1": true, "o/r/with space/x@v1": true, "o/r/a(b)[c]{d}~e#f%g@v1": true,117 "o/r/action+tools@v1": true, "o/r/déploiement/日本語@v1": true, "o/r/with space/x@v1": true, "o/r/a(b)[c]{d}~e#f%g@v1": true,
118+ "o/r/a\ufffdb@v1": true, "o/r/\ufffd@v1": true, // a literal U+FFFD is a valid character
118 "o/r/" + long(255) + "@v1": true, "o/r/.github/actions/x@v1": true, "o/r/.hidden@v1": true, "o/r/a..b@v1": true,119 "o/r/" + long(255) + "@v1": true, "o/r/.github/actions/x@v1": true, "o/r/.hidden@v1": true, "o/r/a..b@v1": true,
119 "": 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,120 "": 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,
120 "o/r/a/../b@v1": false, "o/r/./a@v1": false, "o/r/a//b@v1": false, "o/r/a/@v1": false,121 "o/r/a/../b@v1": false, "o/r/./a@v1": false, "o/r/a//b@v1": false, "o/r/a/@v1": false,
121- "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false,122+ "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false, "o/r/a\xc3(b@v1": false, "o/r/\xed\xa0\x80@v1": false,
122 "o/r/" + long(256) + "@v1": false, long(101) + "/r@v1": false, "o/" + long(101) + "@v1": false,123 "o/r/" + long(256) + "@v1": false, long(101) + "/r@v1": false, "o/" + long(101) + "@v1": false,
123 "o/r@" + long(256): false, "o/r/" + strings.Repeat("a/", 600) + "x@v1": false,124 "o/r@" + long(256): false, "o/r/" + strings.Repeat("a/", 600) + "x@v1": false,
124 "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,125 "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,
@@ -277,3 +278,28 @@ func TestResolveResponseTokenOrigin(t *testing.T) {
277 t.Errorf("%+v", a)278 t.Errorf("%+v", a)
278 }279 }
279 }280 }
281+
282+// GO-012 on the archive tokens: with IPv6 hosts the origin named for the token is
283+// exact, and a look-alike is not accepted.
284+func TestResolveResponseTokenOriginIPv6(t *testing.T) {
285+ j := resolveJob(&resolveStub{})
286+ j.setOrigins(`{"github":{"server_url":"https://[::1]:8443"}}`, "https://[fd00::9]", "http://[fd00::2]:8088")
287+ ermiteSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE
288+ conv := func(url string) *execpb.ResolvedAction {
289+ return j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: ermiteSrc, TarballUrl: url, Token: "t"}}}, 1).Actions[0]
290+ }
291+ // The public origin is rewritten to the guest one.
292+ if a := conv("https://[::1]:8443/-/github/api/repos/o/r/tarball/abc"); a.Token != "t" || a.AuthOrigin != "http://[fd00::2]:8088" || a.TarballUrl != "http://[fd00::2]:8088/-/github/api/repos/o/r/tarball/abc" {
293+ t.Errorf("%+v", a)
294+ }
295+ // The runner's own origin, default port.
296+ if a := conv("https://[fd00::9]:443/x"); a.Token != "t" || a.AuthOrigin != "https://[fd00::9]" {
297+ t.Errorf("%+v", a)
298+ }
299+ // The look-alike host "::1:8443" and other ports are not ermite.
300+ for _, u := range []string{"https://[::1:8443]/x", "https://[::1]:8444/x", "http://[::1]:8443/x", "https://[::1]/x"} {
301+ if a := conv(u); a.Token != "" || a.AuthOrigin != "" {
302+ t.Errorf("%s: token kept: %+v", u, a)
303+ }
304+ }
305+}
A internal/urlorigin/urlorigin.go +60 -0
@@ -0,0 +1,60 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package urlorigin gives the one canonical form of the origin of an http(s)
4+// URL that the host and the executor both compare: the archive token of an
5+// action goes only to the origin the host named, so the two sides must agree on
6+// what "the same origin" means, whatever the spelling of the URL.
7+package urlorigin
8+
9+import (
10+ "net"
11+ "net/url"
12+ "strings"
13+)
14+
15+// Canonical returns "scheme://host:port" for an absolute http or https URL: the
16+// scheme and the host lower-cased, the default port (80, 443) filled in, an IPv6
17+// host in brackets (net.JoinHostPort), so that "https://[::1]:8443/x" and
18+// "https://[::1:8443]/x" (a different host, without a port) differ, and
19+// "https://a.test" and "https://a.test:443" are the same. It returns "" for
20+// anything else, including a URL with credentials, which never carries a token.
21+func Canonical(raw string) string {
22+ u, err := url.Parse(raw)
23+ if err != nil || u.Host == "" || u.User != nil {
24+ return ""
25+ }
26+ scheme := strings.ToLower(u.Scheme)
27+ port, ok := effectivePort(scheme, u.Port())
28+ if !ok || u.Hostname() == "" {
29+ return ""
30+ }
31+ return scheme + "://" + net.JoinHostPort(strings.ToLower(u.Hostname()), port)
32+}
33+
34+func effectivePort(scheme, port string) (string, bool) {
35+ switch {
36+ case port != "":
37+ if scheme != "http" && scheme != "https" {
38+ return "", false
39+ }
40+ return port, true
41+ case scheme == "http":
42+ return "80", true
43+ case scheme == "https":
44+ return "443", true
45+ }
46+ return "", false
47+}
48+
49+// Prefix returns the origin as it is written at the start of a URL: the same
50+// as Canonical without the default port, which is how a URL is built from it.
51+func Prefix(raw string) string {
52+ c := Canonical(raw)
53+ switch {
54+ case strings.HasPrefix(c, "http://") && strings.HasSuffix(c, ":80"):
55+ return strings.TrimSuffix(c, ":80")
56+ case strings.HasPrefix(c, "https://") && strings.HasSuffix(c, ":443"):
57+ return strings.TrimSuffix(c, ":443")
58+ }
59+ return c
60+}
@@ -0,0 +1,60 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package urlorigin gives the one canonical form of the origin of an http(s)
4+// URL that the host and the executor both compare: the archive token of an
5+// action goes only to the origin the host named, so the two sides must agree on
6+// what "the same origin" means, whatever the spelling of the URL.
7+package urlorigin
8+
9+import (
10+ "net"
11+ "net/url"
12+ "strings"
13+)
14+
15+// Canonical returns "scheme://host:port" for an absolute http or https URL: the
16+// scheme and the host lower-cased, the default port (80, 443) filled in, an IPv6
17+// host in brackets (net.JoinHostPort), so that "https://[::1]:8443/x" and
18+// "https://[::1:8443]/x" (a different host, without a port) differ, and
19+// "https://a.test" and "https://a.test:443" are the same. It returns "" for
20+// anything else, including a URL with credentials, which never carries a token.
21+func Canonical(raw string) string {
22+ u, err := url.Parse(raw)
23+ if err != nil || u.Host == "" || u.User != nil {
24+ return ""
25+ }
26+ scheme := strings.ToLower(u.Scheme)
27+ port, ok := effectivePort(scheme, u.Port())
28+ if !ok || u.Hostname() == "" {
29+ return ""
30+ }
31+ return scheme + "://" + net.JoinHostPort(strings.ToLower(u.Hostname()), port)
32+}
33+
34+func effectivePort(scheme, port string) (string, bool) {
35+ switch {
36+ case port != "":
37+ if scheme != "http" && scheme != "https" {
38+ return "", false
39+ }
40+ return port, true
41+ case scheme == "http":
42+ return "80", true
43+ case scheme == "https":
44+ return "443", true
45+ }
46+ return "", false
47+}
48+
49+// Prefix returns the origin as it is written at the start of a URL: the same
50+// as Canonical without the default port, which is how a URL is built from it.
51+func Prefix(raw string) string {
52+ c := Canonical(raw)
53+ switch {
54+ case strings.HasPrefix(c, "http://") && strings.HasSuffix(c, ":80"):
55+ return strings.TrimSuffix(c, ":80")
56+ case strings.HasPrefix(c, "https://") && strings.HasSuffix(c, ":443"):
57+ return strings.TrimSuffix(c, ":443")
58+ }
59+ return c
60+}
A internal/urlorigin/urlorigin_test.go +60 -0
@@ -0,0 +1,60 @@
1+// SPDX-License-Identifier: MIT
2+
3+package urlorigin
4+
5+import "testing"
6+
7+func TestCanonical(t *testing.T) {
8+ for in, want := range map[string]string{
9+ "https://Ermite.example/x": "https://ermite.example:443",
10+ "HTTPS://ermite.example": "https://ermite.example:443",
11+ "https://ermite.example:443/x": "https://ermite.example:443",
12+ "http://a.test:80": "http://a.test:80",
13+ "http://a.test": "http://a.test:80",
14+ "http://a.test:8088/p?q#f": "http://a.test:8088",
15+ "https://[::1]:8443/x": "https://[::1]:8443",
16+ "https://[::1]/x": "https://[::1]:443",
17+ "https://[::1]:443": "https://[::1]:443",
18+ "https://[2001:DB8::1]:8443": "https://[2001:db8::1]:8443",
19+ "http://[::1]:80": "http://[::1]:80",
20+ "http://10.0.2.2:8088": "http://10.0.2.2:8088",
21+ "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "https://u:p@a.test": "", "": "", "https://": "", "//a.test": "",
22+ "https://[::1:8443]/x": "https://[::1:8443]:443", // an IPv6 host with no port at all
23+ } {
24+ if got := Canonical(in); got != want {
25+ t.Errorf("Canonical(%q) = %q, want %q", in, got, want)
26+ }
27+ }
28+ // GO-012: the host ::1 on port 8443 is not the host ::1:8443.
29+ if Canonical("https://[::1]:8443/x") == Canonical("https://[::1:8443]/x") {
30+ t.Error("two different origins collide")
31+ }
32+ // Spellings of one origin agree.
33+ for _, pair := range [][2]string{
34+ {"https://a.test", "https://A.TEST:443/path"}, {"http://a.test:80/", "http://a.test"}, {"https://[::1]", "https://[::1]:443"},
35+ } {
36+ if Canonical(pair[0]) != Canonical(pair[1]) {
37+ t.Errorf("%q and %q differ: %q %q", pair[0], pair[1], Canonical(pair[0]), Canonical(pair[1]))
38+ }
39+ }
40+ // Other ports, schemes and hosts differ.
41+ for _, pair := range [][2]string{
42+ {"https://a.test", "http://a.test"}, {"https://a.test", "https://a.test:8443"}, {"https://a.test", "https://b.a.test"}, {"http://a.test", "http://a.test:443"},
43+ } {
44+ if Canonical(pair[0]) == Canonical(pair[1]) {
45+ t.Errorf("%q and %q collide", pair[0], pair[1])
46+ }
47+ }
48+}
49+
50+func TestPrefix(t *testing.T) {
51+ for in, want := range map[string]string{
52+ "https://Ermite.example:443/x": "https://ermite.example", "http://a.test:80": "http://a.test", "http://a.test:8088": "http://a.test:8088",
53+ "https://[::1]:443": "https://[::1]", "http://[::1]:80/": "http://[::1]", "https://[::1]:8443": "https://[::1]:8443",
54+ "http://10.0.2.2:8088": "http://10.0.2.2:8088", "https://a.test:80": "https://a.test:80", "ftp://x": "",
55+ } {
56+ if got := Prefix(in); got != want {
57+ t.Errorf("Prefix(%q) = %q, want %q", in, got, want)
58+ }
59+ }
60+}
@@ -0,0 +1,60 @@
1+// SPDX-License-Identifier: MIT
2+
3+package urlorigin
4+
5+import "testing"
6+
7+func TestCanonical(t *testing.T) {
8+ for in, want := range map[string]string{
9+ "https://Ermite.example/x": "https://ermite.example:443",
10+ "HTTPS://ermite.example": "https://ermite.example:443",
11+ "https://ermite.example:443/x": "https://ermite.example:443",
12+ "http://a.test:80": "http://a.test:80",
13+ "http://a.test": "http://a.test:80",
14+ "http://a.test:8088/p?q#f": "http://a.test:8088",
15+ "https://[::1]:8443/x": "https://[::1]:8443",
16+ "https://[::1]/x": "https://[::1]:443",
17+ "https://[::1]:443": "https://[::1]:443",
18+ "https://[2001:DB8::1]:8443": "https://[2001:db8::1]:8443",
19+ "http://[::1]:80": "http://[::1]:80",
20+ "http://10.0.2.2:8088": "http://10.0.2.2:8088",
21+ "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "https://u:p@a.test": "", "": "", "https://": "", "//a.test": "",
22+ "https://[::1:8443]/x": "https://[::1:8443]:443", // an IPv6 host with no port at all
23+ } {
24+ if got := Canonical(in); got != want {
25+ t.Errorf("Canonical(%q) = %q, want %q", in, got, want)
26+ }
27+ }
28+ // GO-012: the host ::1 on port 8443 is not the host ::1:8443.
29+ if Canonical("https://[::1]:8443/x") == Canonical("https://[::1:8443]/x") {
30+ t.Error("two different origins collide")
31+ }
32+ // Spellings of one origin agree.
33+ for _, pair := range [][2]string{
34+ {"https://a.test", "https://A.TEST:443/path"}, {"http://a.test:80/", "http://a.test"}, {"https://[::1]", "https://[::1]:443"},
35+ } {
36+ if Canonical(pair[0]) != Canonical(pair[1]) {
37+ t.Errorf("%q and %q differ: %q %q", pair[0], pair[1], Canonical(pair[0]), Canonical(pair[1]))
38+ }
39+ }
40+ // Other ports, schemes and hosts differ.
41+ for _, pair := range [][2]string{
42+ {"https://a.test", "http://a.test"}, {"https://a.test", "https://a.test:8443"}, {"https://a.test", "https://b.a.test"}, {"http://a.test", "http://a.test:443"},
43+ } {
44+ if Canonical(pair[0]) == Canonical(pair[1]) {
45+ t.Errorf("%q and %q collide", pair[0], pair[1])
46+ }
47+ }
48+}
49+
50+func TestPrefix(t *testing.T) {
51+ for in, want := range map[string]string{
52+ "https://Ermite.example:443/x": "https://ermite.example", "http://a.test:80": "http://a.test", "http://a.test:8088": "http://a.test:8088",
53+ "https://[::1]:443": "https://[::1]", "http://[::1]:80/": "http://[::1]", "https://[::1]:8443": "https://[::1]:8443",
54+ "http://10.0.2.2:8088": "http://10.0.2.2:8088", "https://a.test:80": "https://a.test:80", "ftp://x": "",
55+ } {
56+ if got := Prefix(in); got != want {
57+ t.Errorf("Prefix(%q) = %q, want %q", in, got, want)
58+ }
59+ }
60+}

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