1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "archive/tar"
7 "bytes"
8 "compress/gzip"
9 "context"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14)
15
16type tent struct {
17 name, body, link string
18 typ byte
19}
20
21func tarball(t *testing.T, ents ...tent) *bytes.Reader {
22 t.Helper()
23 var buf bytes.Buffer
24 zw := gzip.NewWriter(&buf)
25 tw := tar.NewWriter(zw)
26 for _, e := range ents {
27 typ := e.typ
28 if typ == 0 {
29 typ = tar.TypeReg
30 }
31 h := &tar.Header{Name: e.name, Typeflag: typ, Mode: 0o644, Linkname: e.link}
32 if typ == tar.TypeReg {
33 h.Size = int64(len(e.body))
34 }
35 if typ == tar.TypeDir {
36 h.Mode = 0o755
37 }
38 if err := tw.WriteHeader(h); err != nil {
39 t.Fatal(err)
40 }
41 if typ == tar.TypeReg {
42 _, _ = tw.Write([]byte(e.body))
43 }
44 }
45 _ = tw.Close()
46 _ = zw.Close()
47 return bytes.NewReader(buf.Bytes())
48}
49
50func TestExtractTarball(t *testing.T) {
51 dir := t.TempDir()
52 err := extractTarball(context.Background(), tarball(t,
53 tent{name: "o-r-abc/", typ: tar.TypeDir},
54 tent{name: "o-r-abc/action.yml", body: "name: x"},
55 tent{name: "o-r-abc/sub/main.js", body: "1"},
56 tent{name: "o-r-abc/link", typ: tar.TypeSymlink, link: "sub/main.js"},
57 tent{name: "o-r-abc/hard", typ: tar.TypeLink, link: "o-r-abc/sub/main.js"},
58 ), dir, nil)
59 if err != nil {
60 t.Fatal(err)
61 }
62 // The top directory is stripped: the content is in dir itself.
63 for _, f := range []string{"action.yml", "link", "hard"} {
64 if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
65 t.Errorf("%s: %v", f, err)
66 }
67 }
68 if b, _ := os.ReadFile(filepath.Join(dir, "link")); string(b) != "1" {
69 t.Errorf("link: %q", b)
70 }
71 if _, err := os.Stat(filepath.Join(dir, "o-r-abc")); err == nil {
72 t.Error("the top directory must be stripped")
73 }
74}
75
76func TestExtractTarballRejects(t *testing.T) {
77 cases := map[string][]tent{
78 "absolute": {{name: "/etc/passwd", body: "x"}},
79 "dotdot": {{name: "t/a/../../x", body: "x"}},
80 "symlink out": {{name: "t/a/l", typ: tar.TypeSymlink, link: "../../x"}},
81 "symlink abs": {{name: "t/l", typ: tar.TypeSymlink, link: "/etc"}},
82 "hardlink out": {{name: "t/l", typ: tar.TypeLink, link: "../x"}},
83 "hardlink abs": {{name: "t/l", typ: tar.TypeLink, link: "/etc/passwd"}},
84 "through link": {{name: "t/d", typ: tar.TypeSymlink, link: "."}, {name: "t/d/../../x", body: "x"}},
85 "link then dir": {{name: "t/l", typ: tar.TypeSymlink, link: "a/.."}, {name: "t/l/../x", body: "x"}},
86 "two tops": {{name: "t/a", body: "1"}, {name: "u/b", body: "2"}},
87 "file beside": {{name: "t/", typ: tar.TypeDir}, {name: "loose", body: "x"}},
88 "hardlink other": {{name: "t/a", body: "1"}, {name: "t/l", typ: tar.TypeLink, link: "u/a"}},
89 }
90 for name, ents := range cases {
91 t.Run(name, func(t *testing.T) {
92 parent := t.TempDir()
93 dir := filepath.Join(parent, "dest")
94 if err := os.Mkdir(dir, 0o755); err != nil {
95 t.Fatal(err)
96 }
97 err := extractTarball(context.Background(), tarball(t, ents...), dir, nil)
98 if err == nil {
99 t.Fatal("accepted")
100 }
101 if _, statErr := os.Lstat(filepath.Join(parent, "x")); statErr == nil {
102 t.Fatal("wrote outside")
103 }
104 })
105 }
106}
107
108// GO-001: "top/link -> ../outside" stays inside the staging directory of the
109// extraction but escapes once the top directory is stripped. The target is
110// resolved against the final root, whatever the location the files are
111// extracted to, and the relocated tree has no link that leaves it.
112func TestExtractTarballLinkEscapesStrippedRoot(t *testing.T) {
113 // From top/sub/l, i.e. sub/l once the top directory is stripped.
114 for link, ok := range map[string]bool{"../outside": true, "..": true, "../x": true, "../../outside": false, "../../top/x": false, "../../../x": false} {
115 dir := filepath.Join(t.TempDir(), "stage")
116 _ = os.Mkdir(dir, 0o755)
117 err := extractTarball(context.Background(), tarball(t,
118 tent{name: "top/x", body: "x"},
119 tent{name: "top/sub/l", typ: tar.TypeSymlink, link: link},
120 ), dir, nil)
121 if (err == nil) != ok {
122 t.Errorf("link %q: accepted=%v, want %v (%v)", link, err == nil, ok, err)
123 }
124 }
125 // The direct case of the finding.
126 dir := filepath.Join(t.TempDir(), "stage")
127 _ = os.Mkdir(dir, 0o755)
128 if err := extractTarball(context.Background(), tarball(t,
129 tent{name: "top/link", typ: tar.TypeSymlink, link: "../outside"},
130 ), dir, nil); err == nil {
131 t.Fatal("top/link -> ../outside was accepted")
132 }
133 // A tree that was accepted keeps all its links inside after relocation.
134 stage := filepath.Join(t.TempDir(), "stage")
135 _ = os.Mkdir(stage, 0o755)
136 if err := extractTarball(context.Background(), tarball(t,
137 tent{name: "top/real", body: "r"},
138 tent{name: "top/a/b/l", typ: tar.TypeSymlink, link: "../../real"},
139 ), stage, nil); err != nil {
140 t.Fatal(err)
141 }
142 final := filepath.Join(t.TempDir(), "final")
143 if err := os.Rename(stage, final); err != nil {
144 t.Fatal(err)
145 }
146 if b, err := os.ReadFile(filepath.Join(final, "a", "b", "l")); err != nil || string(b) != "r" {
147 t.Fatalf("relocated link: %q %v", b, err)
148 }
149}
150
151func TestExtractTarballNotGzip(t *testing.T) {
152 err := extractTarball(context.Background(), strings.NewReader("not gzip"), t.TempDir(), nil)
153 if err == nil || !strings.Contains(err.Error(), "not a gzip") {
154 t.Fatalf("%v", err)
155 }
156}
157
158// A symlink to a directory inside the tree must not let a later entry write
159// outside it, even through a chain of links.
160func TestExtractTarballLinkChain(t *testing.T) {
161 parent := t.TempDir()
162 dir := filepath.Join(parent, "dest")
163 _ = os.Mkdir(dir, 0o755)
164 err := extractTarball(context.Background(), tarball(t,
165 tent{name: "t/a", typ: tar.TypeSymlink, link: "b"},
166 tent{name: "t/b", typ: tar.TypeSymlink, link: "."},
167 tent{name: "t/a/a/a/f", body: "x"},
168 ), dir, nil)
169 if err != nil {
170 t.Fatal(err)
171 }
172 if b, _ := os.ReadFile(filepath.Join(dir, "f")); string(b) != "x" {
173 t.Fatalf("f: %q", b)
174 }
175}
176
177// GO-004: PAX and GNU long-name records are not entries and have no Size in the
178// header Next returns, so the counters do not see them. The decompressed stream
179// is capped.
180func TestExtractTarballMetadataFlood(t *testing.T) {
181 old := maxStreamBytes
182 maxStreamBytes = 256 << 10
183 defer func() { maxStreamBytes = old }()
184 var buf bytes.Buffer
185 zw := gzip.NewWriter(&buf)
186 tw := tar.NewWriter(zw)
187 for i := 0; i < 400; i++ {
188 // A PAX record per file with a 4 KiB value; the entries stay below
189 // every counter.
190 h := &tar.Header{Name: "t/f", Typeflag: tar.TypeReg, Mode: 0o644, Size: 0, Format: tar.FormatPAX,
191 PAXRecords: map[string]string{"comment": strings.Repeat("x", 4096)}}
192 if err := tw.WriteHeader(h); err != nil {
193 t.Fatal(err)
194 }
195 }
196 _ = tw.Close()
197 _ = zw.Close()
198 err := extractTarball(context.Background(), bytes.NewReader(buf.Bytes()), t.TempDir(), nil)
199 if err == nil || !strings.Contains(err.Error(), "too large once unpacked") {
200 t.Fatalf("%v", err)
201 }
202}
203
204func TestExtractTarballCancelled(t *testing.T) {
205 ctx, cancel := context.WithCancel(context.Background())
206 cancel()
207 err := extractTarball(ctx, tarball(t, tent{name: "t/a", body: "1"}), t.TempDir(), nil)
208 if err != context.Canceled {
209 t.Fatalf("%v", err)
210 }
211}
212
213// GO-007: where a link leads depends on the other links. "a -> ." makes
214// "link -> a/../outside" leave the tree, which a lexical check accepts.
215func TestExtractTarballLinkChainsAreResolved(t *testing.T) {
216 sym := func(name, to string) tent { return tent{name: "top/" + name, typ: tar.TypeSymlink, link: to} }
217 for name, tc := range map[string]struct {
218 ents []tent
219 ok bool
220 }{
221 "two links escape": {[]tent{sym("a", "."), sym("link", "a/../outside")}, false},
222 "two links, reverse order": {[]tent{sym("link", "a/../outside"), sym("a", ".")}, false}, // forward reference
223 "three hops": {[]tent{sym("a", "b"), sym("b", "."), sym("link", "a/../../x")}, false},
224 "through a directory link": {[]tent{{name: "top/d/e/f", body: "x"}, sym("d/up", "../.."), sym("link", "d/up/..")}, false},
225 "absolute inside a hop": {[]tent{sym("a", "/etc"), sym("link", "a/passwd")}, false},
226 "loop": {[]tent{sym("a", "b"), sym("b", "a"), sym("link", "a/x")}, false},
227 "long chain": {chain(45), false},
228 "stays inside": {[]tent{{name: "top/d/f", body: "1"}, sym("a", "."), sym("link", "a/d/f"), sym("up", "d/..")}, true},
229 "dangling is fine": {[]tent{sym("link", "nowhere/at/all")}, true},
230 "forward ref inside": {[]tent{sym("link", "a/b"), sym("a", "d"), {name: "top/d/b", body: "1"}}, true},
231 "dotdot after real dir": {[]tent{{name: "top/d/f", body: "1"}, sym("link", "d/../d/f")}, true},
232 } {
233 t.Run(name, func(t *testing.T) {
234 parent := t.TempDir()
235 dir := filepath.Join(parent, "stage")
236 _ = os.Mkdir(dir, 0o755)
237 err := extractTarball(context.Background(), tarball(t, tc.ents...), dir, nil)
238 if (err == nil) != tc.ok {
239 t.Fatalf("accepted=%v, want %v (%v)", err == nil, tc.ok, err)
240 }
241 })
242 }
243}
244
245// chain makes n links, each one to the next, the last to ".": more hops than
246// the kernel follows.
247func chain(n int) []tent {
248 var out []tent
249 for i := 0; i < n; i++ {
250 to := "l" + string(rune('A'+(i+1)%26)) + string(rune('a'+(i+1)/26))
251 if i == n-1 {
252 to = "."
253 }
254 out = append(out, tent{name: "top/l" + string(rune('A'+i%26)) + string(rune('a'+i/26)), typ: tar.TypeSymlink, link: to})
255 }
256 return append(out, tent{name: "top/start", typ: tar.TypeSymlink, link: "lAa/x"})
257}