detached 63e2006

fix(executor): normalize container paths before translating and read matcher files only below shared directories (GO-006)

commit
63e200617332e3cbfc38cf2851cd2e6e6d3c5bba
author
brice · · 2026-10-14 06:19 UTC
parent
6c6f5f6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +76 -1
M internal/executor/containers_spec.go +25 -1
@@ -5,6 +5,7 @@ package executor
55 import (
66 "context"
77 "fmt"
8+ "path"
89 "regexp"
910 "strings"
1011
@@ -96,11 +97,20 @@ func (c *container) translateToContainer(p string) string {
9697 return p
9798 }
9899
99-// translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268).
100+// translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268) for a path
101+// the container printed. An absolute path is normalised first, so that
102+// "/__w/../outside/x" is "/outside/x" and is not found below /__w: the runner
103+// joins the text as it is, which would read <work>/../outside/x on the host.
104+// What comes out of a mapping is the host directory and a cleaned relative
105+// part, so it is always inside the host directory of that mapping. Paths that
106+// match no mapping are returned as given (relative ones included).
100107 func (c *container) translateToHost(p string) string {
101108 if p == "" {
102109 return p
103110 }
111+ if strings.HasPrefix(p, "/") {
112+ p = path.Clean(p)
113+ }
104114 for _, m := range c.maps {
105115 if p == m.container {
106116 return m.host
@@ -112,6 +122,20 @@ func (c *container) translateToHost(p string) string {
112122 return p
113123 }
114124
125+// insideMappings reports whether a host path (cleaned) is a mapped host
126+// directory or below one: the only host files a path printed by the container
127+// can stand for. A path of the container that no mount covers (/etc/passwd of
128+// the image) is not a file of the host.
129+func (c *container) insideMappings(host string) bool {
130+ host = path.Clean(host)
131+ for _, m := range c.maps {
132+ if host == m.host || strings.HasPrefix(host, m.host+"/") {
133+ return true
134+ }
135+ }
136+ return false
137+}
138+
115139 // resolvePathForStepHost is ContainerStepHost.ResolvePathForStepHost
116140 // (StepHost.cs:127-152): the path is translated when it starts with the host
117141 // path of one of the mounts (a plain string prefix, as in the runner).
@@ -5,6 +5,7 @@ package executor
5 import (5 import (
6 "context"6 "context"
7 "fmt"7 "fmt"
8+ "path"
8 "regexp"9 "regexp"
9 "strings"10 "strings"
10 11
@@ -96,11 +97,20 @@ func (c *container) translateToContainer(p string) string {
96 return p97 return p
97 }98 }
98 99
99-// translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268).100+// translateToHost is TranslateToHostPath (ContainerInfo.cs:230-268) for a path
101+// the container printed. An absolute path is normalised first, so that
102+// "/__w/../outside/x" is "/outside/x" and is not found below /__w: the runner
103+// joins the text as it is, which would read <work>/../outside/x on the host.
104+// What comes out of a mapping is the host directory and a cleaned relative
105+// part, so it is always inside the host directory of that mapping. Paths that
106+// match no mapping are returned as given (relative ones included).
100 func (c *container) translateToHost(p string) string {107 func (c *container) translateToHost(p string) string {
101 if p == "" {108 if p == "" {
102 return p109 return p
103 }110 }
111+ if strings.HasPrefix(p, "/") {
112+ p = path.Clean(p)
113+ }
104 for _, m := range c.maps {114 for _, m := range c.maps {
105 if p == m.container {115 if p == m.container {
106 return m.host116 return m.host
@@ -112,6 +122,20 @@ func (c *container) translateToHost(p string) string {
112 return p122 return p
113 }123 }
114 124
125+// insideMappings reports whether a host path (cleaned) is a mapped host
126+// directory or below one: the only host files a path printed by the container
127+// can stand for. A path of the container that no mount covers (/etc/passwd of
128+// the image) is not a file of the host.
129+func (c *container) insideMappings(host string) bool {
130+ host = path.Clean(host)
131+ for _, m := range c.maps {
132+ if host == m.host || strings.HasPrefix(host, m.host+"/") {
133+ return true
134+ }
135+ }
136+ return false
137+}
138+
115 // resolvePathForStepHost is ContainerStepHost.ResolvePathForStepHost139 // resolvePathForStepHost is ContainerStepHost.ResolvePathForStepHost
116 // (StepHost.cs:127-152): the path is translated when it starts with the host140 // (StepHost.cs:127-152): the path is translated when it starts with the host
117 // path of one of the mounts (a plain string prefix, as in the runner).141 // path of one of the mounts (a plain string prefix, as in the runner).
M internal/executor/containers_spec_test.go +44 -0
@@ -3,6 +3,8 @@
33 package executor
44
55 import (
6+ "path"
7+ "path/filepath"
68 "strings"
79 "testing"
810
@@ -141,3 +143,45 @@ func TestServiceNamesAndContainerIDs(t *testing.T) {
141143 }
142144 }
143145 }
146+
147+func TestTranslateToHostDoesNotEscapeTheMapping(t *testing.T) {
148+ c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}}
149+ for in, want := range map[string]string{
150+ "/__w/a/b": "/h/work/a/b",
151+ "/__w/a/../b": "/h/work/b",
152+ "/__w/../outside/x": "/outside/x",
153+ "/__w/a/../../../etc": "/etc",
154+ "/__w//a///b/": "/h/work/a/b",
155+ "/__w": "/h/work",
156+ "/__t/../__w/x": "/h/work/x",
157+ "/__wx/y": "/__wx/y",
158+ "rel/../x": "rel/../x",
159+ } {
160+ got := c.translateToHost(in)
161+ if got != want {
162+ t.Errorf("%q -> %q, want %q", in, got, want)
163+ }
164+ if strings.HasPrefix(in, "/__w") && !strings.HasPrefix(got, "/h/work") && got != path.Clean(in) {
165+ t.Errorf("%q escaped to %q", in, got)
166+ }
167+ }
168+}
169+
170+// A matcher file named by a command of the container is read only when it is
171+// in a directory shared with the host: ".." cannot reach another host file.
172+func TestMatcherPathFromTheContainerStaysInside(t *testing.T) {
173+ f := newFakeCtr(t)
174+ var outside string
175+ rec, res := ctrRun(t, f, ctrJob, " - name: s\n run: echo\n", func(c *Config) {
176+ outside = filepath.Join(filepath.Dir(c.Layout.WorkRoot), "outside")
177+ writeFiles(t, filepath.Dir(c.Layout.WorkRoot), files{"outside/m.json": `{"problemMatcher":[{"owner":"leak","pattern":[{"regexp":"x"}]}]}`})
178+ f.set(t, "exec_out", "::add-matcher::/__w/../outside/m.json\n::add-matcher::"+outside+"/m.json\n::add-matcher::/etc/hostname\n")
179+ })
180+ if res.Conclusion != ConclusionFailure {
181+ t.Fatalf("%+v", res)
182+ }
183+ log := logOf(rec.step(t, "s"))
184+ if strings.Contains(log, "leak") || strings.Count(log, "##[error]") < 2 || !strings.Contains(log, "Could not find file") && !strings.Contains(log, "ould not find") {
185+ t.Errorf("log:\n%s", log)
186+ }
187+}
@@ -3,6 +3,8 @@
3 package executor3 package executor
4 4
5 import (5 import (
6+ "path"
7+ "path/filepath"
6 "strings"8 "strings"
7 "testing"9 "testing"
8 10
@@ -141,3 +143,45 @@ func TestServiceNamesAndContainerIDs(t *testing.T) {
141 }143 }
142 }144 }
143 }145 }
146+
147+func TestTranslateToHostDoesNotEscapeTheMapping(t *testing.T) {
148+ c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}}
149+ for in, want := range map[string]string{
150+ "/__w/a/b": "/h/work/a/b",
151+ "/__w/a/../b": "/h/work/b",
152+ "/__w/../outside/x": "/outside/x",
153+ "/__w/a/../../../etc": "/etc",
154+ "/__w//a///b/": "/h/work/a/b",
155+ "/__w": "/h/work",
156+ "/__t/../__w/x": "/h/work/x",
157+ "/__wx/y": "/__wx/y",
158+ "rel/../x": "rel/../x",
159+ } {
160+ got := c.translateToHost(in)
161+ if got != want {
162+ t.Errorf("%q -> %q, want %q", in, got, want)
163+ }
164+ if strings.HasPrefix(in, "/__w") && !strings.HasPrefix(got, "/h/work") && got != path.Clean(in) {
165+ t.Errorf("%q escaped to %q", in, got)
166+ }
167+ }
168+}
169+
170+// A matcher file named by a command of the container is read only when it is
171+// in a directory shared with the host: ".." cannot reach another host file.
172+func TestMatcherPathFromTheContainerStaysInside(t *testing.T) {
173+ f := newFakeCtr(t)
174+ var outside string
175+ rec, res := ctrRun(t, f, ctrJob, " - name: s\n run: echo\n", func(c *Config) {
176+ outside = filepath.Join(filepath.Dir(c.Layout.WorkRoot), "outside")
177+ writeFiles(t, filepath.Dir(c.Layout.WorkRoot), files{"outside/m.json": `{"problemMatcher":[{"owner":"leak","pattern":[{"regexp":"x"}]}]}`})
178+ f.set(t, "exec_out", "::add-matcher::/__w/../outside/m.json\n::add-matcher::"+outside+"/m.json\n::add-matcher::/etc/hostname\n")
179+ })
180+ if res.Conclusion != ConclusionFailure {
181+ t.Fatalf("%+v", res)
182+ }
183+ log := logOf(rec.step(t, "s"))
184+ if strings.Contains(log, "leak") || strings.Count(log, "##[error]") < 2 || !strings.Contains(log, "Could not find file") && !strings.Contains(log, "ould not find") {
185+ t.Errorf("log:\n%s", log)
186+ }
187+}
M internal/executor/results.go +7 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "fmt"
88 "io"
9+ "io/fs"
910 "os"
1011 "strings"
1112 "sync/atomic"
@@ -79,6 +80,12 @@ const maxMatcherFile = 1 << 20
7980
8081 // ReadMatcherFile reads a problem matcher file.
8182 func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) {
83+ // With a job container the path came from a command of the container: it
84+ // is read only when it is a file of a directory the container shares with
85+ // the host, never the host's own file of the same name.
86+ if c := s.j.stepContainer(); c != nil && !c.insideMappings(path) {
87+ return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
88+ }
8289 f, err := os.Open(path)
8390 if err != nil {
8491 return nil, err
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "fmt"7 "fmt"
8 "io"8 "io"
9+ "io/fs"
9 "os"10 "os"
10 "strings"11 "strings"
11 "sync/atomic"12 "sync/atomic"
@@ -79,6 +80,12 @@ const maxMatcherFile = 1 << 20
79 80
80 // ReadMatcherFile reads a problem matcher file.81 // ReadMatcherFile reads a problem matcher file.
81 func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) {82 func (s *stepRun) ReadMatcherFile(path string) ([]byte, error) {
83+ // With a job container the path came from a command of the container: it
84+ // is read only when it is a file of a directory the container shares with
85+ // the host, never the host's own file of the same name.
86+ if c := s.j.stepContainer(); c != nil && !c.insideMappings(path) {
87+ return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
88+ }
82 f, err := os.Open(path)89 f, err := os.Open(path)
83 if err != nil {90 if err != nil {
84 return nil, err91 return nil, err

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