Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions internal/format/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions internal/format/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"

"filippo.io/age/internal/format"
Expand Down Expand Up @@ -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 {
Expand Down
Loading