detached 9f6323c

fix(executor): check archive links against the stripped root and cap the decompressed stream

commit
9f6323c18f0ccce07a3cf79710d7ca81841acb12
author
brice · · 2026-10-14 02:43 UTC
parent
8111cdb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +191 -49
M internal/executor/actions_download.go +5 -16
@@ -171,32 +171,21 @@ func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, na
171171 if c := j.cfg.Credential; c != nil {
172172 owner = &[2]int{int(c.Uid), int(c.Gid)}
173173 }
174- err = extractTarball(af, staging, owner)
174+ // The top directory is stripped while extracting, so that links are checked
175+ // against the tree they will live in (see extractTarball).
176+ err = extractTarball(ctx, af, staging, owner)
175177 _ = af.Close()
176178 if err != nil {
177179 return ghErrorf("Can't extract the archive of action %s@%s: %v", name, ref, err)
178180 }
179- ents, err := os.ReadDir(staging)
180- if err != nil {
181- return err
182- }
183- var dirs []os.DirEntry
184- for _, e := range ents {
185- if e.IsDir() {
186- dirs = append(dirs, e)
187- }
188- }
189- if len(dirs) != 1 || len(ents) != 1 {
190- return fmt.Errorf("'%s' contains '%d' directories", archive, len(dirs))
191- }
192181 _ = os.RemoveAll(dir)
193182 if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
194183 return err
195184 }
196- if err := os.Rename(filepath.Join(staging, dirs[0].Name()), dir); err != nil {
185+ if err := os.Rename(staging, dir); err != nil {
197186 return err
198187 }
199- j.debug(num, fmt.Sprintf("Unwrap '%s' to '%s'", dirs[0].Name(), dir))
188+ j.debug(num, fmt.Sprintf("Unwrap the archive to '%s'", dir))
200189 if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil {
201190 return err
202191 }
@@ -171,32 +171,21 @@ func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, na
171 if c := j.cfg.Credential; c != nil {171 if c := j.cfg.Credential; c != nil {
172 owner = &[2]int{int(c.Uid), int(c.Gid)}172 owner = &[2]int{int(c.Uid), int(c.Gid)}
173 }173 }
174- err = extractTarball(af, staging, owner)174+ // The top directory is stripped while extracting, so that links are checked
175+ // against the tree they will live in (see extractTarball).
176+ err = extractTarball(ctx, af, staging, owner)
175 _ = af.Close()177 _ = af.Close()
176 if err != nil {178 if err != nil {
177 return ghErrorf("Can't extract the archive of action %s@%s: %v", name, ref, err)179 return ghErrorf("Can't extract the archive of action %s@%s: %v", name, ref, err)
178 }180 }
179- ents, err := os.ReadDir(staging)
180- if err != nil {
181- return err
182- }
183- var dirs []os.DirEntry
184- for _, e := range ents {
185- if e.IsDir() {
186- dirs = append(dirs, e)
187- }
188- }
189- if len(dirs) != 1 || len(ents) != 1 {
190- return fmt.Errorf("'%s' contains '%d' directories", archive, len(dirs))
191- }
192 _ = os.RemoveAll(dir)181 _ = os.RemoveAll(dir)
193 if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {182 if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
194 return err183 return err
195 }184 }
196- if err := os.Rename(filepath.Join(staging, dirs[0].Name()), dir); err != nil {185+ if err := os.Rename(staging, dir); err != nil {
197 return err186 return err
198 }187 }
199- j.debug(num, fmt.Sprintf("Unwrap '%s' to '%s'", dirs[0].Name(), dir))188+ j.debug(num, fmt.Sprintf("Unwrap the archive to '%s'", dir))
200 if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil {189 if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil {
201 return err190 return err
202 }191 }
M internal/executor/actions_extract.go +78 -15
@@ -5,6 +5,7 @@ package executor
55 import (
66 "archive/tar"
77 "compress/gzip"
8+ "context"
89 "errors"
910 "fmt"
1011 "io"
@@ -14,10 +15,14 @@ import (
1415 )
1516
1617 // Limits of an action archive. The runner has none (it runs tar); these keep a
17-// hostile archive from filling the sandbox.
18-const (
18+// hostile archive from filling the sandbox. They are variables for the tests.
19+var (
1920 maxArchiveEntries = 200_000
20- maxArchiveBytes = 2 << 30 // unpacked
21+ // maxArchiveBytes bounds the sizes of the regular files; maxStreamBytes the
22+ // whole decompressed tar stream, headers and PAX/GNU long-name records
23+ // included, which the entry and size counters do not see.
24+ maxArchiveBytes = int64(2 << 30)
25+ maxStreamBytes = maxArchiveBytes + 64<<20
2126 )
2227
2328 // errArchive is returned for an archive that is not safe or not valid. Its text
@@ -27,12 +32,34 @@ type errArchive string
2732
2833 func (e errArchive) Error() string { return string(e) }
2934
35+// capReader fails once more than n bytes were read.
36+type capReader struct {
37+ r io.Reader
38+ n int64
39+}
40+
41+func (c *capReader) Read(p []byte) (int, error) {
42+ if c.n <= 0 {
43+ return 0, errArchive("the archive is too large once unpacked")
44+ }
45+ if int64(len(p)) > c.n {
46+ p = p[:c.n]
47+ }
48+ n, err := c.r.Read(p)
49+ c.n -= int64(n)
50+ return n, err
51+}
52+
3053 // extractTarball unpacks a gzip-compressed tar into dir, which must exist and
31-// be empty. Every name is checked: no absolute path, no "..", no link whose
32-// target leaves dir, and everything is written through an os.Root, so that a
33-// link planted by an earlier entry cannot redirect a later one. owner, when
34-// set, is the uid and gid of the files.
35-func extractTarball(r io.Reader, dir string, owner *[2]int) error {
54+// be empty, and strips the single top directory of the archive (GitHub's
55+// archives have exactly one, ActionManager.cs:1380-1393): the content lands in
56+// dir itself, so no file moves afterwards and a link that is valid here stays
57+// valid. Every name is checked: no absolute path, no "..", one top directory,
58+// no link whose target leaves dir, and everything is written through an
59+// os.Root, so that a link planted by an earlier entry cannot redirect a later
60+// one. The decompressed stream is capped, and ctx is checked between entries.
61+// owner, when set, is the uid and gid of the files.
62+func extractTarball(ctx context.Context, r io.Reader, dir string, owner *[2]int) error {
3663 zr, err := gzip.NewReader(r)
3764 if err != nil {
3865 return errArchive("the archive is not a gzip file: " + err.Error())
@@ -43,14 +70,21 @@ func extractTarball(r io.Reader, dir string, owner *[2]int) error {
4370 return err
4471 }
4572 defer func() { _ = root.Close() }()
46- tr := tar.NewReader(zr)
47- entries, total := 0, int64(0)
73+ tr := tar.NewReader(&capReader{r: zr, n: maxStreamBytes})
74+ entries, total, top := 0, int64(0), ""
4875 for {
76+ if err := ctx.Err(); err != nil {
77+ return err
78+ }
4979 h, err := tr.Next()
5080 if errors.Is(err, io.EOF) {
5181 return nil
5282 }
5383 if err != nil {
84+ var ea errArchive
85+ if errors.As(err, &ea) {
86+ return ea
87+ }
5488 return errArchive("the archive is not a valid tar file: " + err.Error())
5589 }
5690 if entries++; entries > maxArchiveEntries {
@@ -59,12 +93,35 @@ func extractTarball(r io.Reader, dir string, owner *[2]int) error {
5993 if total += h.Size; total > maxArchiveBytes {
6094 return errArchive("the archive is too large once unpacked")
6195 }
62- if err := extractEntry(root, tr, h, owner); err != nil {
96+ if err := extractEntry(root, tr, h, owner, &top); err != nil {
6397 return err
6498 }
6599 }
66100 }
67101
102+// stripTop validates the name of an entry, drops its top directory and returns
103+// the rest, clean and relative to the final root ("" for the top directory
104+// itself). All entries must share one top directory.
105+func stripTop(name string, top *string) (string, error) {
106+ c, err := cleanEntry(name)
107+ if err != nil {
108+ return "", err
109+ }
110+ if c == "" {
111+ return "", nil
112+ }
113+ first, rest, hasRest := strings.Cut(c, "/")
114+ if *top == "" {
115+ *top = first
116+ } else if first != *top {
117+ return "", errArchive(fmt.Sprintf("the archive has more than one top-level directory: %q and %q", *top, first))
118+ }
119+ if !hasRest {
120+ return "", nil
121+ }
122+ return rest, nil
123+}
124+
68125 // cleanEntry validates the name of an entry and returns it clean and relative.
69126 func cleanEntry(name string) (string, error) {
70127 if strings.ContainsRune(name, 0) || strings.HasPrefix(name, "/") {
@@ -97,15 +154,21 @@ func linkInside(name, target string, hard bool) bool {
97154 return t != ".." && !strings.HasPrefix(t, "../")
98155 }
99156
100-func extractEntry(root *os.Root, tr *tar.Reader, h *tar.Header, owner *[2]int) error {
157+func extractEntry(root *os.Root, tr *tar.Reader, h *tar.Header, owner *[2]int, top *string) error {
101158 switch h.Typeflag {
102159 case tar.TypeXGlobalHeader, tar.TypeXHeader:
103160 return nil // git archive's pax_global_header: the commit id
104161 }
105- name, err := cleanEntry(h.Name)
106- if err != nil || name == "" {
162+ name, err := stripTop(h.Name, top)
163+ if err != nil {
107164 return err
108165 }
166+ if name == "" {
167+ if h.Typeflag != tar.TypeDir {
168+ return errArchive(fmt.Sprintf("the archive has a file next to its top-level directory: %q", h.Name))
169+ }
170+ return nil
171+ }
109172 perm := os.FileMode(h.Mode) & 0o777
110173 chown := func() error {
111174 if owner == nil {
@@ -154,7 +217,7 @@ func extractEntry(root *os.Root, tr *tar.Reader, h *tar.Header, owner *[2]int) e
154217 }
155218 return chown()
156219 case tar.TypeLink:
157- tgt, err := cleanEntry(h.Linkname)
220+ tgt, err := stripTop(h.Linkname, top)
158221 if err != nil || tgt == "" || !linkInside(name, tgt, true) {
159222 return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname))
160223 }
@@ -5,6 +5,7 @@ package executor
5 import (5 import (
6 "archive/tar"6 "archive/tar"
7 "compress/gzip"7 "compress/gzip"
8+ "context"
8 "errors"9 "errors"
9 "fmt"10 "fmt"
10 "io"11 "io"
@@ -14,10 +15,14 @@ import (
14 )15 )
15 16
16 // Limits of an action archive. The runner has none (it runs tar); these keep a17 // Limits of an action archive. The runner has none (it runs tar); these keep a
17-// hostile archive from filling the sandbox.18+// hostile archive from filling the sandbox. They are variables for the tests.
18-const (19+var (
19 maxArchiveEntries = 200_00020 maxArchiveEntries = 200_000
20- maxArchiveBytes = 2 << 30 // unpacked21+ // maxArchiveBytes bounds the sizes of the regular files; maxStreamBytes the
22+ // whole decompressed tar stream, headers and PAX/GNU long-name records
23+ // included, which the entry and size counters do not see.
24+ maxArchiveBytes = int64(2 << 30)
25+ maxStreamBytes = maxArchiveBytes + 64<<20
21 )26 )
22 27
23 // errArchive is returned for an archive that is not safe or not valid. Its text28 // errArchive is returned for an archive that is not safe or not valid. Its text
@@ -27,12 +32,34 @@ type errArchive string
27 32
28 func (e errArchive) Error() string { return string(e) }33 func (e errArchive) Error() string { return string(e) }
29 34
35+// capReader fails once more than n bytes were read.
36+type capReader struct {
37+ r io.Reader
38+ n int64
39+}
40+
41+func (c *capReader) Read(p []byte) (int, error) {
42+ if c.n <= 0 {
43+ return 0, errArchive("the archive is too large once unpacked")
44+ }
45+ if int64(len(p)) > c.n {
46+ p = p[:c.n]
47+ }
48+ n, err := c.r.Read(p)
49+ c.n -= int64(n)
50+ return n, err
51+}
52+
30 // extractTarball unpacks a gzip-compressed tar into dir, which must exist and53 // extractTarball unpacks a gzip-compressed tar into dir, which must exist and
31-// be empty. Every name is checked: no absolute path, no "..", no link whose54+// be empty, and strips the single top directory of the archive (GitHub's
32-// target leaves dir, and everything is written through an os.Root, so that a55+// archives have exactly one, ActionManager.cs:1380-1393): the content lands in
33-// link planted by an earlier entry cannot redirect a later one. owner, when56+// dir itself, so no file moves afterwards and a link that is valid here stays
34-// set, is the uid and gid of the files.57+// valid. Every name is checked: no absolute path, no "..", one top directory,
35-func extractTarball(r io.Reader, dir string, owner *[2]int) error {58+// no link whose target leaves dir, and everything is written through an
59+// os.Root, so that a link planted by an earlier entry cannot redirect a later
60+// one. The decompressed stream is capped, and ctx is checked between entries.
61+// owner, when set, is the uid and gid of the files.
62+func extractTarball(ctx context.Context, r io.Reader, dir string, owner *[2]int) error {
36 zr, err := gzip.NewReader(r)63 zr, err := gzip.NewReader(r)
37 if err != nil {64 if err != nil {
38 return errArchive("the archive is not a gzip file: " + err.Error())65 return errArchive("the archive is not a gzip file: " + err.Error())
@@ -43,14 +70,21 @@ func extractTarball(r io.Reader, dir string, owner *[2]int) error {
43 return err70 return err
44 }71 }
45 defer func() { _ = root.Close() }()72 defer func() { _ = root.Close() }()
46- tr := tar.NewReader(zr)73+ tr := tar.NewReader(&capReader{r: zr, n: maxStreamBytes})
47- entries, total := 0, int64(0)74+ entries, total, top := 0, int64(0), ""
48 for {75 for {
76+ if err := ctx.Err(); err != nil {
77+ return err
78+ }
49 h, err := tr.Next()79 h, err := tr.Next()
50 if errors.Is(err, io.EOF) {80 if errors.Is(err, io.EOF) {
51 return nil81 return nil
52 }82 }
53 if err != nil {83 if err != nil {
84+ var ea errArchive
85+ if errors.As(err, &ea) {
86+ return ea
87+ }
54 return errArchive("the archive is not a valid tar file: " + err.Error())88 return errArchive("the archive is not a valid tar file: " + err.Error())
55 }89 }
56 if entries++; entries > maxArchiveEntries {90 if entries++; entries > maxArchiveEntries {
@@ -59,12 +93,35 @@ func extractTarball(r io.Reader, dir string, owner *[2]int) error {
59 if total += h.Size; total > maxArchiveBytes {93 if total += h.Size; total > maxArchiveBytes {
60 return errArchive("the archive is too large once unpacked")94 return errArchive("the archive is too large once unpacked")
61 }95 }
62- if err := extractEntry(root, tr, h, owner); err != nil {96+ if err := extractEntry(root, tr, h, owner, &top); err != nil {
63 return err97 return err
64 }98 }
65 }99 }
66 }100 }
67 101
102+// stripTop validates the name of an entry, drops its top directory and returns
103+// the rest, clean and relative to the final root ("" for the top directory
104+// itself). All entries must share one top directory.
105+func stripTop(name string, top *string) (string, error) {
106+ c, err := cleanEntry(name)
107+ if err != nil {
108+ return "", err
109+ }
110+ if c == "" {
111+ return "", nil
112+ }
113+ first, rest, hasRest := strings.Cut(c, "/")
114+ if *top == "" {
115+ *top = first
116+ } else if first != *top {
117+ return "", errArchive(fmt.Sprintf("the archive has more than one top-level directory: %q and %q", *top, first))
118+ }
119+ if !hasRest {
120+ return "", nil
121+ }
122+ return rest, nil
123+}
124+
68 // cleanEntry validates the name of an entry and returns it clean and relative.125 // cleanEntry validates the name of an entry and returns it clean and relative.
69 func cleanEntry(name string) (string, error) {126 func cleanEntry(name string) (string, error) {
70 if strings.ContainsRune(name, 0) || strings.HasPrefix(name, "/") {127 if strings.ContainsRune(name, 0) || strings.HasPrefix(name, "/") {
@@ -97,15 +154,21 @@ func linkInside(name, target string, hard bool) bool {
97 return t != ".." && !strings.HasPrefix(t, "../")154 return t != ".." && !strings.HasPrefix(t, "../")
98 }155 }
99 156
100-func extractEntry(root *os.Root, tr *tar.Reader, h *tar.Header, owner *[2]int) error {157+func extractEntry(root *os.Root, tr *tar.Reader, h *tar.Header, owner *[2]int, top *string) error {
101 switch h.Typeflag {158 switch h.Typeflag {
102 case tar.TypeXGlobalHeader, tar.TypeXHeader:159 case tar.TypeXGlobalHeader, tar.TypeXHeader:
103 return nil // git archive's pax_global_header: the commit id160 return nil // git archive's pax_global_header: the commit id
104 }161 }
105- name, err := cleanEntry(h.Name)162+ name, err := stripTop(h.Name, top)
106- if err != nil || name == "" {163+ if err != nil {
107 return err164 return err
108 }165 }
166+ if name == "" {
167+ if h.Typeflag != tar.TypeDir {
168+ return errArchive(fmt.Sprintf("the archive has a file next to its top-level directory: %q", h.Name))
169+ }
170+ return nil
171+ }
109 perm := os.FileMode(h.Mode) & 0o777172 perm := os.FileMode(h.Mode) & 0o777
110 chown := func() error {173 chown := func() error {
111 if owner == nil {174 if owner == nil {
@@ -154,7 +217,7 @@ func extractEntry(root *os.Root, tr *tar.Reader, h *tar.Header, owner *[2]int) e
154 }217 }
155 return chown()218 return chown()
156 case tar.TypeLink:219 case tar.TypeLink:
157- tgt, err := cleanEntry(h.Linkname)220+ tgt, err := stripTop(h.Linkname, top)
158 if err != nil || tgt == "" || !linkInside(name, tgt, true) {221 if err != nil || tgt == "" || !linkInside(name, tgt, true) {
159 return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname))222 return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname))
160 }223 }
M internal/executor/actions_extract_test.go +108 -18
@@ -6,6 +6,7 @@ import (
66 "archive/tar"
77 "bytes"
88 "compress/gzip"
9+ "context"
910 "os"
1011 "path/filepath"
1112 "strings"
@@ -48,33 +49,43 @@ func tarball(t *testing.T, ents ...tent) *bytes.Reader {
4849
4950 func TestExtractTarball(t *testing.T) {
5051 dir := t.TempDir()
51- err := extractTarball(tarball(t,
52+ err := extractTarball(context.Background(), tarball(t,
5253 tent{name: "o-r-abc/", typ: tar.TypeDir},
5354 tent{name: "o-r-abc/action.yml", body: "name: x"},
5455 tent{name: "o-r-abc/sub/main.js", body: "1"},
5556 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"},
5658 ), dir, nil)
5759 if err != nil {
5860 t.Fatal(err)
5961 }
60- if b, _ := os.ReadFile(filepath.Join(dir, "o-r-abc", "action.yml")); string(b) != "name: x" {
61- t.Errorf("action.yml: %q", b)
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+ }
6267 }
63- if b, _ := os.ReadFile(filepath.Join(dir, "o-r-abc", "link")); string(b) != "1" {
68+ if b, _ := os.ReadFile(filepath.Join(dir, "link")); string(b) != "1" {
6469 t.Errorf("link: %q", b)
6570 }
71+ if _, err := os.Stat(filepath.Join(dir, "o-r-abc")); err == nil {
72+ t.Error("the top directory must be stripped")
73+ }
6674 }
6775
6876 func TestExtractTarballRejects(t *testing.T) {
6977 cases := map[string][]tent{
70- "absolute": {{name: "/etc/passwd", body: "x"}},
71- "dotdot": {{name: "a/../../x", body: "x"}},
72- "symlink out": {{name: "a/l", typ: tar.TypeSymlink, link: "../../x"}},
73- "symlink abs": {{name: "l", typ: tar.TypeSymlink, link: "/etc"}},
74- "hardlink out": {{name: "l", typ: tar.TypeLink, link: "../x"}},
75- "hardlink abs": {{name: "l", typ: tar.TypeLink, link: "/etc/passwd"}},
76- "through link": {{name: "d", typ: tar.TypeSymlink, link: "."}, {name: "d/../../x", body: "x"}},
77- "link then dir": {{name: "l", typ: tar.TypeSymlink, link: "a/.."}, {name: "l/../x", body: "x"}},
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"}},
7889 }
7990 for name, ents := range cases {
8091 t.Run(name, func(t *testing.T) {
@@ -83,7 +94,7 @@ func TestExtractTarballRejects(t *testing.T) {
8394 if err := os.Mkdir(dir, 0o755); err != nil {
8495 t.Fatal(err)
8596 }
86- err := extractTarball(tarball(t, ents...), dir, nil)
97+ err := extractTarball(context.Background(), tarball(t, ents...), dir, nil)
8798 if err == nil {
8899 t.Fatal("accepted")
89100 }
@@ -94,8 +105,51 @@ func TestExtractTarballRejects(t *testing.T) {
94105 }
95106 }
96107
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.
112+func 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+
97151 func TestExtractTarballNotGzip(t *testing.T) {
98- err := extractTarball(strings.NewReader("not gzip"), t.TempDir(), nil)
152+ err := extractTarball(context.Background(), strings.NewReader("not gzip"), t.TempDir(), nil)
99153 if err == nil || !strings.Contains(err.Error(), "not a gzip") {
100154 t.Fatalf("%v", err)
101155 }
@@ -107,10 +161,10 @@ func TestExtractTarballLinkChain(t *testing.T) {
107161 parent := t.TempDir()
108162 dir := filepath.Join(parent, "dest")
109163 _ = os.Mkdir(dir, 0o755)
110- err := extractTarball(tarball(t,
111- tent{name: "a", typ: tar.TypeSymlink, link: "b"},
112- tent{name: "b", typ: tar.TypeSymlink, link: "."},
113- tent{name: "a/a/a/f", body: "x"},
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"},
114168 ), dir, nil)
115169 if err != nil {
116170 t.Fatal(err)
@@ -119,3 +173,39 @@ func TestExtractTarballLinkChain(t *testing.T) {
119173 t.Fatalf("f: %q", b)
120174 }
121175 }
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.
180+func 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+
204+func 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+}
@@ -6,6 +6,7 @@ import (
6 "archive/tar"6 "archive/tar"
7 "bytes"7 "bytes"
8 "compress/gzip"8 "compress/gzip"
9+ "context"
9 "os"10 "os"
10 "path/filepath"11 "path/filepath"
11 "strings"12 "strings"
@@ -48,33 +49,43 @@ func tarball(t *testing.T, ents ...tent) *bytes.Reader {
48 49
49 func TestExtractTarball(t *testing.T) {50 func TestExtractTarball(t *testing.T) {
50 dir := t.TempDir()51 dir := t.TempDir()
51- err := extractTarball(tarball(t,52+ err := extractTarball(context.Background(), tarball(t,
52 tent{name: "o-r-abc/", typ: tar.TypeDir},53 tent{name: "o-r-abc/", typ: tar.TypeDir},
53 tent{name: "o-r-abc/action.yml", body: "name: x"},54 tent{name: "o-r-abc/action.yml", body: "name: x"},
54 tent{name: "o-r-abc/sub/main.js", body: "1"},55 tent{name: "o-r-abc/sub/main.js", body: "1"},
55 tent{name: "o-r-abc/link", typ: tar.TypeSymlink, link: "sub/main.js"},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"},
56 ), dir, nil)58 ), dir, nil)
57 if err != nil {59 if err != nil {
58 t.Fatal(err)60 t.Fatal(err)
59 }61 }
60- if b, _ := os.ReadFile(filepath.Join(dir, "o-r-abc", "action.yml")); string(b) != "name: x" {62+ // The top directory is stripped: the content is in dir itself.
61- t.Errorf("action.yml: %q", b)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+ }
62 }67 }
63- if b, _ := os.ReadFile(filepath.Join(dir, "o-r-abc", "link")); string(b) != "1" {68+ if b, _ := os.ReadFile(filepath.Join(dir, "link")); string(b) != "1" {
64 t.Errorf("link: %q", b)69 t.Errorf("link: %q", b)
65 }70 }
71+ if _, err := os.Stat(filepath.Join(dir, "o-r-abc")); err == nil {
72+ t.Error("the top directory must be stripped")
73+ }
66 }74 }
67 75
68 func TestExtractTarballRejects(t *testing.T) {76 func TestExtractTarballRejects(t *testing.T) {
69 cases := map[string][]tent{77 cases := map[string][]tent{
70- "absolute": {{name: "/etc/passwd", body: "x"}},78+ "absolute": {{name: "/etc/passwd", body: "x"}},
71- "dotdot": {{name: "a/../../x", body: "x"}},79+ "dotdot": {{name: "t/a/../../x", body: "x"}},
72- "symlink out": {{name: "a/l", typ: tar.TypeSymlink, link: "../../x"}},80+ "symlink out": {{name: "t/a/l", typ: tar.TypeSymlink, link: "../../x"}},
73- "symlink abs": {{name: "l", typ: tar.TypeSymlink, link: "/etc"}},81+ "symlink abs": {{name: "t/l", typ: tar.TypeSymlink, link: "/etc"}},
74- "hardlink out": {{name: "l", typ: tar.TypeLink, link: "../x"}},82+ "hardlink out": {{name: "t/l", typ: tar.TypeLink, link: "../x"}},
75- "hardlink abs": {{name: "l", typ: tar.TypeLink, link: "/etc/passwd"}},83+ "hardlink abs": {{name: "t/l", typ: tar.TypeLink, link: "/etc/passwd"}},
76- "through link": {{name: "d", typ: tar.TypeSymlink, link: "."}, {name: "d/../../x", body: "x"}},84+ "through link": {{name: "t/d", typ: tar.TypeSymlink, link: "."}, {name: "t/d/../../x", body: "x"}},
77- "link then dir": {{name: "l", typ: tar.TypeSymlink, link: "a/.."}, {name: "l/../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"}},
78 }89 }
79 for name, ents := range cases {90 for name, ents := range cases {
80 t.Run(name, func(t *testing.T) {91 t.Run(name, func(t *testing.T) {
@@ -83,7 +94,7 @@ func TestExtractTarballRejects(t *testing.T) {
83 if err := os.Mkdir(dir, 0o755); err != nil {94 if err := os.Mkdir(dir, 0o755); err != nil {
84 t.Fatal(err)95 t.Fatal(err)
85 }96 }
86- err := extractTarball(tarball(t, ents...), dir, nil)97+ err := extractTarball(context.Background(), tarball(t, ents...), dir, nil)
87 if err == nil {98 if err == nil {
88 t.Fatal("accepted")99 t.Fatal("accepted")
89 }100 }
@@ -94,8 +105,51 @@ func TestExtractTarballRejects(t *testing.T) {
94 }105 }
95 }106 }
96 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.
112+func 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+
97 func TestExtractTarballNotGzip(t *testing.T) {151 func TestExtractTarballNotGzip(t *testing.T) {
98- err := extractTarball(strings.NewReader("not gzip"), t.TempDir(), nil)152+ err := extractTarball(context.Background(), strings.NewReader("not gzip"), t.TempDir(), nil)
99 if err == nil || !strings.Contains(err.Error(), "not a gzip") {153 if err == nil || !strings.Contains(err.Error(), "not a gzip") {
100 t.Fatalf("%v", err)154 t.Fatalf("%v", err)
101 }155 }
@@ -107,10 +161,10 @@ func TestExtractTarballLinkChain(t *testing.T) {
107 parent := t.TempDir()161 parent := t.TempDir()
108 dir := filepath.Join(parent, "dest")162 dir := filepath.Join(parent, "dest")
109 _ = os.Mkdir(dir, 0o755)163 _ = os.Mkdir(dir, 0o755)
110- err := extractTarball(tarball(t,164+ err := extractTarball(context.Background(), tarball(t,
111- tent{name: "a", typ: tar.TypeSymlink, link: "b"},165+ tent{name: "t/a", typ: tar.TypeSymlink, link: "b"},
112- tent{name: "b", typ: tar.TypeSymlink, link: "."},166+ tent{name: "t/b", typ: tar.TypeSymlink, link: "."},
113- tent{name: "a/a/a/f", body: "x"},167+ tent{name: "t/a/a/a/f", body: "x"},
114 ), dir, nil)168 ), dir, nil)
115 if err != nil {169 if err != nil {
116 t.Fatal(err)170 t.Fatal(err)
@@ -119,3 +173,39 @@ func TestExtractTarballLinkChain(t *testing.T) {
119 t.Fatalf("f: %q", b)173 t.Fatalf("f: %q", b)
120 }174 }
121 }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.
180+func 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+
204+func 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+}

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