From b3e8f5360706e8ad00413dca0ff9325d6b393e9c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 15:48:34 +0000 Subject: [PATCH] internal/format: bound header size during Parse --- internal/format/format.go | 30 +++++++++++++++++++-------- internal/format/format_test.go | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/internal/format/format.go b/internal/format/format.go index cc788c37..525db90c 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -229,11 +229,31 @@ func errorf(format string, a ...any) error { return &ParseError{fmt.Errorf(format, a...)} } +// maxHeaderSize bounds the number of bytes Parse will consume while reading +// the header, to protect automated decryption of untrusted files against +// memory-exhaustion attacks via unbounded stanzas or lines. +const maxHeaderSize = 1 << 24 // 16 MiB + +type headerLimitedReader struct { + r io.Reader + read int64 +} + +func (r *headerLimitedReader) Read(p []byte) (int, error) { + if r.read >= maxHeaderSize { + return 0, errorf("header exceeds %d bytes", maxHeaderSize) + } + n, err := r.r.Read(p) + r.read += int64(n) + return n, err +} + // Parse returns the header and a Reader that begins at the start of the // payload. func Parse(input io.Reader) (*Header, io.Reader, error) { h := &Header{} - rr := bufio.NewReader(input) + lr := &headerLimitedReader{r: input} + rr := bufio.NewReader(lr) line, err := rr.ReadString('\n') if err == io.EOF { @@ -276,13 +296,7 @@ func Parse(input io.Reader) (*Header, io.Reader, error) { h.Recipients = append(h.Recipients, s) } - // If input is a bufio.Reader, rr might be equal to input because - // bufio.NewReader short-circuits. In this case we can just return it (and - // we would end up reading the buffer twice if we prepended the peek below). - if rr == input { - return h, rr, nil - } - // Otherwise, unwind the bufio overread and return the unbuffered input. + // Unwind the bufio overread and return the unbuffered input. buf, err := rr.Peek(rr.Buffered()) if err != nil { return nil, nil, errorf("internal error: %v", err) diff --git a/internal/format/format_test.go b/internal/format/format_test.go index dca10e64..cad26f74 100644 --- a/internal/format/format_test.go +++ b/internal/format/format_test.go @@ -11,6 +11,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" "filippo.io/age/internal/format" @@ -43,6 +44,42 @@ func TestStanzaMarshal(t *testing.T) { } } +// stanzaBodyReader emits an unbounded sequence of valid 64-column stanza +// body lines, so Parse sees a syntactically valid but never-ending stanza. +type stanzaBodyReader struct { + line []byte + off int +} + +func (r *stanzaBodyReader) Read(p []byte) (int, error) { + n := 0 + for n < len(p) { + c := copy(p[n:], r.line[r.off:]) + n += c + r.off = (r.off + c) % len(r.line) + } + return n, nil +} + +func TestParseHeaderSizeLimit(t *testing.T) { + // A body line of exactly ColumnsPerLine base64 chars keeps the stanza + // open, so the reader below never yields a short line and Parse would + // read forever without the header size cap. + line := strings.Repeat("A", format.ColumnsPerLine) + "\n" + r := io.MultiReader( + strings.NewReader("age-encryption.org/v1\n-> X\n"), + &stanzaBodyReader{line: []byte(line)}, + ) + + _, _, err := format.Parse(r) + if err == nil { + t.Fatal("Parse of >16 MiB header succeeded; want error") + } + if !strings.Contains(err.Error(), "header exceeds") { + t.Fatalf("Parse error = %q; want error mentioning %q", err, "header exceeds") + } +} + func FuzzMalleability(f *testing.F) { tests, err := filepath.Glob("../../testdata/testkit/*") if err != nil {