Skip to content

Fix infinite loop / unbounded allocation in readArray on truncated PDFs - #79

Open
spencerkimball wants to merge 1 commit into
ledongthuc:masterfrom
spencerkimball:fix-readarray-eof-loop
Open

Fix infinite loop / unbounded allocation in readArray on truncated PDFs#79
spencerkimball wants to merge 1 commit into
ledongthuc:masterfrom
spencerkimball:fix-readarray-eof-loop

Conversation

@spencerkimball

Copy link
Copy Markdown

The bug

buffer.readArray in lex.go loops reading tokens until it sees nil or keyword("]"):

for {
    tok := b.readToken()
    if tok == nil || tok == keyword("]") {
        break
    }
    b.unreadToken(tok)
    x = append(x, b.readObject())
}

When the input is exhausted, readToken returns io.EOF as a token value (if b.eof { return io.EOF }), which matches neither break condition. The loop then unreads the io.EOF token, readObject reads it back and returns it as an object, and it gets appended to the array — forever. Each iteration allocates, so memory grows without bound until the process is OOM-killed.

readDict in the same file already has an explicit io.EOF guard; readArray was missing the equivalent.

Impact

Any PDF whose content stream is truncated inside an unterminated array ([ with no closing ]) hangs every text-extraction entry point: Reader.GetPlainText, Page.GetPlainText, Page.Content, Page.GetTextByRow, Reader.GetStyledTexts — they all reach readArray via Interpret. This is not just a theoretical fuzz case: we hit it on a real-world malformed PDF while batch-extracting text from email attachments, and measured roughly 7 GB of heap growth in about 5 seconds before the process was killed.

The fix

Break out of the loop on io.EOF as well, mirroring readDict's handling — a one-line condition change. I audited the other readToken/readObject loops for the same shape: readDict (lex.go) and Interpret (ps.go) already break on io.EOF, and the loops in read.go either error out on a failed type assertion or are iteration-bounded, so no other changes were needed.

Regression test

lex_test.go synthesizes a minimal single-page PDF entirely in code (no external files) whose content stream ends inside an unterminated array, then runs GetPlainText in a goroutine with a 5-second watchdog. The test was verified to fail against unpatched master:

=== RUN   TestUnterminatedArrayTerminates
    lex_test.go:67: GetPlainText did not return within 5s: readArray is looping on io.EOF at end of a truncated content stream
--- FAIL: TestUnterminatedArrayTerminates (5.06s)

and passes with the fix (returns immediately). go test ./... passes.

readToken returns io.EOF as a token value once the input is exhausted,
but readArray only broke out of its loop on nil or keyword("]"). On a
PDF whose content stream is truncated inside an unterminated array, the
loop unread the io.EOF token, read it back as an object, and appended
it to the array forever, allocating memory without bound (~7GB of heap
in 5 seconds observed on a real-world malformed PDF) and hanging every
text-extraction entry point (GetPlainText, Page.Content,
Page.GetTextByRow).

readDict already guards against io.EOF; give readArray the same guard.

Add a regression test that synthesizes a minimal single-page PDF whose
content stream ends inside an unterminated array and verifies that
GetPlainText returns instead of spinning. The test times out against
the previous code and passes with the fix.
@muromeo1

muromeo1 commented Aug 7, 2026

Copy link
Copy Markdown

I faced the same issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants