Skip to content

A template is bounded before a part of it is read, and the message says how many parts it holds - #48

Closed
fabeat wants to merge 1 commit into
mainfrom
fix/template-bounds
Closed

fabeat wants to merge 1 commit into
mainfrom
fix/template-bounds

Conversation

@fabeat

@fabeat fabeat commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

What this is

Three defects a review found, and the limits that were missing from one path.

The write path read a template without any of the limits the read path has. A
template is somebody else's file, and a zip says how its contents are laid out and
nothing about how much room they will take up: forty kilobytes of a part is forty
megabytes of paragraph, and the entry count is whatever the writer felt like.

$zip = new ZipArchive();

if ($zip->open($template) !== true) {
    throw new FileNotWritable(sprintf(
        'The template "%s" could not be opened. …',
        $template,
    ));
}

No CHECKCONS, no bound on the number of parts, no bound on the size of a part. The
read path has all three, in Reverse\Options and Reverse\Package.

The message that says a document has too many parts reported zero parts. The
archive was closed before the number was read, and closing an archive resets the
count to zero:

if ($zip->numFiles > $options->maxEntries) {
    $package->close();

    throw new UnreadableDocument(sprintf(
        'The document has %d parts in it, which is more entries than the %d allowed.',
        $zip->numFiles,      // read after the close, so it is zero
        $options->maxEntries,
    ));
}

Two findings the analyser raises on examples/build.php — php:S2003 and
php:S1192. Neither changes what the build writes.

The issues are #45,
#46 and
#47.

The measure

A .dotx of sixty-five kilobytes whose [Content_Types].xml inflates to sixty-four
MiB, and an archive with five thousand parts:

before after
the write path, on the sixty-five kilobytes read it — the process peaked at 136 MB refused, with the part's size in the message
the write path, on the five thousand parts accepted it refused, with the number of parts in the message
the read path, on the same two refused both refused both

The peak is measured with memory_get_peak_usage() around a call to
TemplatePackage::of(), and the archive on disk is 65783 bytes.

What changed

The template is bounded before a part of it is read. The number of parts and the
size of any one of them are checked against the central directory, which costs one
lookup per entry and no decompression:

public static function of(string $template, TemplateOptions $options = new TemplateOptions()): self
{
    $zip = new ZipArchive();

    if ($zip->open($template, ZipArchive::CHECKCONS) !== true) {
        …
    }

    try {
        self::check($zip, $template, $options);
        …

Every entry is measured, and not only the ones this library reads. The template is
opened again by PHPWord's own processor, and a part that is read there is read whole.

The bounds are the read path's numbers — 4096 parts, 256 MiB each — so a document
is as welcome as a template. A caller who wants them tighter says so:

$template = new MarkdownTemplate(
    'report-template.dotx',
    limits: (new TemplateOptions())->withMaxPartBytes(8 * 1024 * 1024),
);

A template that is over a bound is refused with a
MarkdownWord\Exception\UnreadableTemplate, which is an InvalidInput: the file is
the caller's to fix. A template that cannot be opened at all keeps the
FileNotWritable it has always had, so nothing that catches it stops working.

The count in the message is the archive's own, read before the archive is closed.

The tests

Every test fails without the change it covers. Revert the change, run the one test,
watch it fail, put it back.

the change the test what it says without the change
the bound on the number of parts it('refuses a template that holds more parts than are allowed') the template is accepted
the bound on the size of a part it('refuses a template that holds a part larger than is allowed') the template is accepted
the count in the message it('says how many parts a template holds when it refuses it') the message says 0
the count in the read path's message it('refuses an archive with more entries in it than the budget allows') the message says 0
the limits reach the template it('refuses a template that is too much through the template itself') the template is accepted

it('reads a template that is within the limits') is the other side of the guard — a
bound that refuses everything refuses the templates that are fine. It passes either
way, and it is not coverage of the change.

The number the messages carry is read out of the archive in the test, so it cannot be
a number that goes stale.

The gates

  • The whole suite: 3952 passed, 6000 assertions.
  • PHPStan at level 1 over src, tools, tests and examples/build.php: no errors.
    No ignoreErrors, no baseline, no suppression.
  • composer check: green.

A note on the changelog

The entries are under ## [Unreleased], which is the same section
#43 adds. Merging this
after it is a one-line resolution — the two sections are the same heading in the same
place.

@sonarqubecloud

Copy link
Copy Markdown

@fabeat

fabeat commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

Combined into #49, which carries this and the rest of the campaign in one pull request. Nothing is lost: the commits are in it, and the release notes are folded into the 0.3.1 section.

@fabeat fabeat closed this Oct 31, 2026
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.

1 participant