detached 9911deb

fix(executor): read the step summary from the checked descriptor, bounded

The size check and the read opened the file twice, and the read was unbounded.
The summary is now read through io.LimitReader(limit+1) on the descriptor that
was checked, so a file that grows afterwards cannot be read whole.
commit
9911deb9cff59669c57c5fb2fe579f057cf6768d
author
brice · · 2026-10-14 02:40 UTC
parent
f0e28f8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +64 -4
M internal/executor/filecmd.go +25 -4
@@ -238,11 +238,17 @@ func readText(root *os.Root, name string) (text string, ok bool, err error) {
238238 if err != nil {
239239 return "", false, err
240240 }
241+ return decodeText(b), true, nil
242+}
243+
244+// decodeText is the decoding of File.ReadAllText: a UTF-8 BOM is dropped and
245+// invalid bytes become U+FFFD.
246+func decodeText(b []byte) string {
241247 b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf"))
242248 if !utf8.Valid(b) {
243249 b = bytes.ToValidUTF8(b, []byte("\ufffd"))
244250 }
245- return string(b), true, nil
251+ return string(b)
246252 }
247253
248254 // createExcl creates name in root with the content, failing if it exists (an
@@ -354,6 +360,10 @@ func readEnvFile(sink Sink, root *os.Root, name string) ([]Pair, error) {
354360 return ParseEnvFile(text)
355361 }
356362
363+// afterSummaryStat is called between the size check of the summary and its
364+// read; tests use it to make the file grow.
365+var afterSummaryStat func()
366+
357367 // processStepSummary is CreateStepSummaryCommand.ProcessCommand
358368 // (FileCommandManager.cs:230-275): a missing or empty file is ignored, a file
359369 // above 1 MiB is an error; otherwise the lines are masked one by one and the
@@ -366,8 +376,8 @@ func processStepSummary(sink Sink, root *os.Root, name string) error {
366376 if err != nil {
367377 return err
368378 }
379+ defer func() { _ = fh.Close() }()
369380 st, err := fh.Stat()
370- _ = fh.Close()
371381 if err != nil || st.Size() == 0 {
372382 return nil
373383 }
@@ -382,10 +392,21 @@ func processStepSummary(sink Sink, root *os.Root, name string) error {
382392 if !ok {
383393 return nil
384394 }
385- text, ok, err := readText(root, name)
386- if err != nil || !ok {
395+ if afterSummaryStat != nil {
396+ afterSummaryStat()
397+ }
398+ // The file belongs to the user of the step, who can still write to it (a
399+ // background process of the step): what is read is bounded by the limit on
400+ // the descriptor that was checked, not by the size seen a moment ago.
401+ b, err := io.ReadAll(io.LimitReader(fh, stepSummaryLimit+1))
402+ if err != nil {
387403 return err
388404 }
405+ if len(b) > stepSummaryLimit {
406+ tooLarge(int64(len(b)))
407+ return nil
408+ }
409+ text := decodeText(b)
389410 var mask func(string) string
390411 if m, has := sink.(interface{ MaskText(string) string }); has {
391412 mask = m.MaskText
@@ -238,11 +238,17 @@ func readText(root *os.Root, name string) (text string, ok bool, err error) {
238 if err != nil {238 if err != nil {
239 return "", false, err239 return "", false, err
240 }240 }
241+ return decodeText(b), true, nil
242+}
243+
244+// decodeText is the decoding of File.ReadAllText: a UTF-8 BOM is dropped and
245+// invalid bytes become U+FFFD.
246+func decodeText(b []byte) string {
241 b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf"))247 b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf"))
242 if !utf8.Valid(b) {248 if !utf8.Valid(b) {
243 b = bytes.ToValidUTF8(b, []byte("\ufffd"))249 b = bytes.ToValidUTF8(b, []byte("\ufffd"))
244 }250 }
245- return string(b), true, nil251+ return string(b)
246 }252 }
247 253
248 // createExcl creates name in root with the content, failing if it exists (an254 // createExcl creates name in root with the content, failing if it exists (an
@@ -354,6 +360,10 @@ func readEnvFile(sink Sink, root *os.Root, name string) ([]Pair, error) {
354 return ParseEnvFile(text)360 return ParseEnvFile(text)
355 }361 }
356 362
363+// afterSummaryStat is called between the size check of the summary and its
364+// read; tests use it to make the file grow.
365+var afterSummaryStat func()
366+
357 // processStepSummary is CreateStepSummaryCommand.ProcessCommand367 // processStepSummary is CreateStepSummaryCommand.ProcessCommand
358 // (FileCommandManager.cs:230-275): a missing or empty file is ignored, a file368 // (FileCommandManager.cs:230-275): a missing or empty file is ignored, a file
359 // above 1 MiB is an error; otherwise the lines are masked one by one and the369 // above 1 MiB is an error; otherwise the lines are masked one by one and the
@@ -366,8 +376,8 @@ func processStepSummary(sink Sink, root *os.Root, name string) error {
366 if err != nil {376 if err != nil {
367 return err377 return err
368 }378 }
379+ defer func() { _ = fh.Close() }()
369 st, err := fh.Stat()380 st, err := fh.Stat()
370- _ = fh.Close()
371 if err != nil || st.Size() == 0 {381 if err != nil || st.Size() == 0 {
372 return nil382 return nil
373 }383 }
@@ -382,10 +392,21 @@ func processStepSummary(sink Sink, root *os.Root, name string) error {
382 if !ok {392 if !ok {
383 return nil393 return nil
384 }394 }
385- text, ok, err := readText(root, name)395+ if afterSummaryStat != nil {
386- if err != nil || !ok {396+ afterSummaryStat()
397+ }
398+ // The file belongs to the user of the step, who can still write to it (a
399+ // background process of the step): what is read is bounded by the limit on
400+ // the descriptor that was checked, not by the size seen a moment ago.
401+ b, err := io.ReadAll(io.LimitReader(fh, stepSummaryLimit+1))
402+ if err != nil {
387 return err403 return err
388 }404 }
405+ if len(b) > stepSummaryLimit {
406+ tooLarge(int64(len(b)))
407+ return nil
408+ }
409+ text := decodeText(b)
389 var mask func(string) string410 var mask func(string) string
390 if m, has := sink.(interface{ MaskText(string) string }); has {411 if m, has := sink.(interface{ MaskText(string) string }); has {
391 mask = m.MaskText412 mask = m.MaskText
M internal/executor/filecmd_test.go +39 -0
@@ -379,3 +379,42 @@ func TestCreateExclChownsTheOpenFile(t *testing.T) {
379379 }
380380 }
381381 }
382+
383+// The summary is read from the descriptor that was checked, at most the limit
384+// plus one byte: a file that grows after the size check (a background process
385+// of the step) cannot make the executor read it whole (GO-005).
386+func TestStepSummaryGrowingAfterTheSizeCheckIsBounded(t *testing.T) {
387+ fc, _ := NewFileCommands(t.TempDir())
388+ env, _ := fc.Init()
389+ path := env["GITHUB_STEP_SUMMARY"]
390+ if err := os.WriteFile(path, []byte("small\n"), 0o644); err != nil {
391+ t.Fatal(err)
392+ }
393+ afterSummaryStat = func() {
394+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
395+ if err != nil {
396+ t.Error(err)
397+ return
398+ }
399+ defer func() { _ = f.Close() }()
400+ if _, err := f.Write(make([]byte, 8<<20)); err != nil {
401+ t.Error(err)
402+ }
403+ }
404+ t.Cleanup(func() { afterSummaryStat = nil })
405+ s := &summarySinkFake{fakeSink: newSink()}
406+ fc.Process(s)
407+ if s.got != "" {
408+ t.Errorf("a summary of %d bytes was kept", len(s.got))
409+ }
410+ if len(s.errors) != 1 || !strings.HasPrefix(s.errors[0], "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of 1024k, got 1024k.") {
411+ t.Fatalf("%q", s.errors)
412+ }
413+}
414+
415+type summarySinkFake struct {
416+ *fakeSink
417+ got string
418+}
419+
420+func (s *summarySinkFake) Summary(m string) { s.got = m }
@@ -379,3 +379,42 @@ func TestCreateExclChownsTheOpenFile(t *testing.T) {
379 }379 }
380 }380 }
381 }381 }
382+
383+// The summary is read from the descriptor that was checked, at most the limit
384+// plus one byte: a file that grows after the size check (a background process
385+// of the step) cannot make the executor read it whole (GO-005).
386+func TestStepSummaryGrowingAfterTheSizeCheckIsBounded(t *testing.T) {
387+ fc, _ := NewFileCommands(t.TempDir())
388+ env, _ := fc.Init()
389+ path := env["GITHUB_STEP_SUMMARY"]
390+ if err := os.WriteFile(path, []byte("small\n"), 0o644); err != nil {
391+ t.Fatal(err)
392+ }
393+ afterSummaryStat = func() {
394+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
395+ if err != nil {
396+ t.Error(err)
397+ return
398+ }
399+ defer func() { _ = f.Close() }()
400+ if _, err := f.Write(make([]byte, 8<<20)); err != nil {
401+ t.Error(err)
402+ }
403+ }
404+ t.Cleanup(func() { afterSummaryStat = nil })
405+ s := &summarySinkFake{fakeSink: newSink()}
406+ fc.Process(s)
407+ if s.got != "" {
408+ t.Errorf("a summary of %d bytes was kept", len(s.got))
409+ }
410+ if len(s.errors) != 1 || !strings.HasPrefix(s.errors[0], "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of 1024k, got 1024k.") {
411+ t.Fatalf("%q", s.errors)
412+ }
413+}
414+
415+type summarySinkFake struct {
416+ *fakeSink
417+ got string
418+}
419+
420+func (s *summarySinkFake) Summary(m string) { s.got = m }

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