1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "archive/tar"
7 "compress/gzip"
8 "context"
9 "errors"
10 "fmt"
11 "io"
12 "io/fs"
13 "os"
14 "path"
15 "path/filepath"
16 "strings"
17)
18
19// Limits of an action archive. The runner has none (it runs tar); these keep a
20// hostile archive from filling the sandbox. They are variables for the tests.
21var (
22 maxArchiveEntries = 200_000
23 // maxArchiveBytes bounds the sizes of the regular files; maxStreamBytes the
24 // whole decompressed tar stream, headers and PAX/GNU long-name records
25 // included, which the entry and size counters do not see.
26 maxArchiveBytes = int64(2 << 30)
27 maxStreamBytes = maxArchiveBytes + 64<<20
28)
29
30// errArchive is returned for an archive that is not safe or not valid. Its text
31// is ermite's: GitHub's runner runs tar, which accepts absolute paths and links
32// out of the tree (Runner.Worker/ActionManager.cs:1358-1394).
33type errArchive string
34
35func (e errArchive) Error() string { return string(e) }
36
37// capReader fails once more than n bytes were read.
38type capReader struct {
39 r io.Reader
40 n int64
41}
42
43func (c *capReader) Read(p []byte) (int, error) {
44 if c.n <= 0 {
45 return 0, errArchive("the archive is too large once unpacked")
46 }
47 if int64(len(p)) > c.n {
48 p = p[:c.n]
49 }
50 n, err := c.r.Read(p)
51 c.n -= int64(n)
52 return n, err
53}
54
55// extractTarball unpacks a gzip-compressed tar into dir, which must exist and
56// be empty, and strips the single top directory of the archive (GitHub's
57// archives have exactly one, ActionManager.cs:1380-1393): the content lands in
58// dir itself, so no file moves afterwards and a link that is valid here stays
59// valid. Every name is checked: no absolute path, no "..", one top directory,
60// no link whose target leaves dir, and everything is written through an
61// os.Root, so that a link planted by an earlier entry cannot redirect a later
62// one. The decompressed stream is capped, and ctx is checked between entries.
63// Links are validated at install time: a running step can change the action
64// tree like any file of its own sandbox, so this is not a confinement guarantee
65// for what happens later.
66// owner, when set, is the uid and gid of the files.
67func extractTarball(ctx context.Context, r io.Reader, dir string, owner *[2]int) error {
68 zr, err := gzip.NewReader(r)
69 if err != nil {
70 return errArchive("the archive is not a gzip file: " + err.Error())
71 }
72 defer func() { _ = zr.Close() }()
73 root, err := os.OpenRoot(dir)
74 if err != nil {
75 return err
76 }
77 defer func() { _ = root.Close() }()
78 tr := tar.NewReader(&capReader{r: zr, n: maxStreamBytes})
79 entries, total, top := 0, int64(0), ""
80 for {
81 if err := ctx.Err(); err != nil {
82 return err
83 }
84 h, err := tr.Next()
85 if errors.Is(err, io.EOF) {
86 return verifyLinks(ctx, root, dir)
87 }
88 if err != nil {
89 var ea errArchive
90 if errors.As(err, &ea) {
91 return ea
92 }
93 return errArchive("the archive is not a valid tar file: " + err.Error())
94 }
95 if entries++; entries > maxArchiveEntries {
96 return errArchive("the archive has too many entries")
97 }
98 if total += h.Size; total > maxArchiveBytes {
99 return errArchive("the archive is too large once unpacked")
100 }
101 if err := extractEntry(root, tr, h, owner, &top); err != nil {
102 return err
103 }
104 }
105}
106
107// stripTop validates the name of an entry, drops its top directory and returns
108// the rest, clean and relative to the final root ("" for the top directory
109// itself). All entries must share one top directory.
110func stripTop(name string, top *string) (string, error) {
111 c, err := cleanEntry(name)
112 if err != nil {
113 return "", err
114 }
115 if c == "" {
116 return "", nil
117 }
118 first, rest, hasRest := strings.Cut(c, "/")
119 if *top == "" {
120 *top = first
121 } else if first != *top {
122 return "", errArchive(fmt.Sprintf("the archive has more than one top-level directory: %q and %q", *top, first))
123 }
124 if !hasRest {
125 return "", nil
126 }
127 return rest, nil
128}
129
130// cleanEntry validates the name of an entry and returns it clean and relative.
131func cleanEntry(name string) (string, error) {
132 if strings.ContainsRune(name, 0) || strings.HasPrefix(name, "/") {
133 return "", errArchive(fmt.Sprintf("the archive has an absolute or invalid path: %q", name))
134 }
135 for _, seg := range strings.Split(name, "/") {
136 if seg == ".." {
137 return "", errArchive(fmt.Sprintf("the archive has a path that leaves its directory: %q", name))
138 }
139 }
140 c := path.Clean(name)
141 if c == "." {
142 return "", nil
143 }
144 return c, nil
145}
146
147// linkInside reports whether a link at name pointing to target stays in the
148// tree: target is relative to the directory of name for a symlink, and to the
149// root for a hard link.
150func linkInside(name, target string, hard bool) bool {
151 if target == "" || strings.HasPrefix(target, "/") || strings.ContainsRune(target, 0) {
152 return false
153 }
154 base := path.Dir(name)
155 if hard {
156 base = "."
157 }
158 t := path.Join(base, target)
159 return t != ".." && !strings.HasPrefix(t, "../")
160}
161
162func extractEntry(root *os.Root, tr *tar.Reader, h *tar.Header, owner *[2]int, top *string) error {
163 switch h.Typeflag {
164 case tar.TypeXGlobalHeader, tar.TypeXHeader:
165 return nil // git archive's pax_global_header: the commit id
166 }
167 name, err := stripTop(h.Name, top)
168 if err != nil {
169 return err
170 }
171 if name == "" {
172 if h.Typeflag != tar.TypeDir {
173 return errArchive(fmt.Sprintf("the archive has a file next to its top-level directory: %q", h.Name))
174 }
175 return nil
176 }
177 perm := os.FileMode(h.Mode) & 0o777
178 chown := func() error {
179 if owner == nil {
180 return nil
181 }
182 return root.Lchown(name, owner[0], owner[1])
183 }
184 if dir := path.Dir(name); dir != "." {
185 if err := root.MkdirAll(dir, 0o755); err != nil {
186 return errArchive(fmt.Sprintf("cannot create the directory of %q: %v", name, err))
187 }
188 }
189 switch h.Typeflag {
190 case tar.TypeDir:
191 if err := root.MkdirAll(name, 0o755); err != nil {
192 return err
193 }
194 if err := root.Chmod(name, perm|0o700); err != nil {
195 return err
196 }
197 return chown()
198 case tar.TypeReg:
199 _ = root.Remove(name)
200 f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm|0o600)
201 if err != nil {
202 return errArchive(fmt.Sprintf("cannot create %q: %v", name, err))
203 }
204 if _, err := io.Copy(f, tr); err != nil {
205 _ = f.Close()
206 return errArchive("the archive is not a valid tar file: " + err.Error())
207 }
208 if err := f.Close(); err != nil {
209 return err
210 }
211 if err := root.Chmod(name, perm|0o600); err != nil {
212 return err
213 }
214 return chown()
215 case tar.TypeSymlink:
216 // Only the shape is checked here: where the link leads depends on the links
217 // that come before and after it, so verifyLinks resolves every symlink of
218 // the finished tree.
219 if h.Linkname == "" || strings.HasPrefix(h.Linkname, "/") || strings.ContainsRune(h.Linkname, 0) {
220 return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname))
221 }
222 _ = root.Remove(name)
223 if err := root.Symlink(h.Linkname, name); err != nil {
224 return err
225 }
226 return chown()
227 case tar.TypeLink:
228 tgt, err := stripTop(h.Linkname, top)
229 if err != nil || tgt == "" || !linkInside(name, tgt, true) {
230 return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname))
231 }
232 _ = root.Remove(name)
233 return root.Link(tgt, name)
234 }
235 return nil // devices, fifos: skipped
236}
237
238// maxLinkHops bounds the symbolic links followed to resolve one link (Linux
239// stops at 40).
240const maxLinkHops = 40
241
242// verifyLinks resolves every symbolic link of the extracted tree component by
243// component, following the links met on the way, and rejects the archive when
244// one leads out of the tree or loops. A lexical check is not enough: with
245// "a -> ." a link "link -> a/../outside" looks like it stays in the tree, and
246// leaves it. A link to something that does not exist is allowed (git archives
247// have such links); the rest of its target is still resolved.
248func verifyLinks(ctx context.Context, root *os.Root, dir string) error {
249 return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
250 if err != nil {
251 return err
252 }
253 if err := ctx.Err(); err != nil {
254 return err
255 }
256 if d.Type()&fs.ModeSymlink == 0 {
257 return nil
258 }
259 rel, err := filepath.Rel(dir, p)
260 if err != nil {
261 return err
262 }
263 target, err := root.Readlink(filepath.ToSlash(rel))
264 if err != nil {
265 return err
266 }
267 if err := resolveInside(root, path.Dir(filepath.ToSlash(rel)), target); err != nil {
268 return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q (%v)", filepath.ToSlash(rel), target, err))
269 }
270 return nil
271 })
272}
273
274// resolveInside resolves target, relative to the directory dirRel of the tree
275// (slash separated, "." for the root), the way the kernel would, and fails when
276// the path leaves the root, is absolute, or needs more than maxLinkHops links.
277func resolveInside(root *os.Root, dirRel, target string) error {
278 var cur []string // resolved directory components, none of them a link
279 if dirRel != "." && dirRel != "" {
280 cur = strings.Split(dirRel, "/")
281 }
282 pending := strings.Split(target, "/")
283 hops := 0
284 for len(pending) > 0 {
285 c := pending[0]
286 pending = pending[1:]
287 switch c {
288 case "", ".":
289 continue
290 case "..":
291 if len(cur) == 0 {
292 return errors.New("leaves the tree")
293 }
294 cur = cur[:len(cur)-1]
295 continue
296 }
297 p := strings.Join(append(append([]string(nil), cur...), c), "/")
298 fi, err := root.Lstat(p)
299 switch {
300 case errors.Is(err, os.ErrNotExist):
301 cur = append(cur, c)
302 continue
303 case err != nil:
304 return err
305 case fi.Mode()&fs.ModeSymlink == 0:
306 cur = append(cur, c)
307 continue
308 }
309 if hops++; hops > maxLinkHops {
310 return errors.New("too many levels of links")
311 }
312 next, err := root.Readlink(p)
313 if err != nil {
314 return err
315 }
316 if strings.HasPrefix(next, "/") || next == "" {
317 return errors.New("an absolute or empty link")
318 }
319 pending = append(strings.Split(next, "/"), pending...)
320 }
321 return nil
322}