Skip to content

fix: release the stream when a bitmap preview cannot be decoded - #41835

Merged
phil-davis merged 2 commits into
masterfrom
fix/oc10-164-bitmap-stream-leak
Sep 16, 2026
Merged

phil-davis merged 2 commits into
masterfrom
fix/oc10-164-bitmap-stream-leak

Conversation

@oc-tmueller

Copy link
Copy Markdown
Contributor

Summary

Bitmap::getThumbnail() opens the file and closes it only on the success path — the catch around getResizedPreview() returns before the fclose(). Every failed decode therefore leaks one file descriptor for the lifetime of the process.

Flagged as out of scope in #41827 ("that's a resource leak, not a security defect, and deserves its own focused PR"), so this is that PR. It targets master and is independent of the #41827/#41834 chain.

Why it matters more than it looks

A failed decode is not an edge case. Any content ImageMagick has no coder for lands in that catch, and #41834 makes throwing a designed outcome — if a build does not register a provider's coder, the pin throws rather than falling back to content sniffing. So occ preview pre-generation, or a cron preview job, over a directory of .heic/.psd files leaks a handle per file until EMFILE.

Second defect, same three lines

$file->fopen('r') was unchecked. A storage that cannot open the file returns false, and stream_get_contents(false) raises a TypeError — an \Error, so it escapes the catch (\Exception) directly underneath and surfaces as a 500 instead of the missing preview every other failure here degrades to. Confirmed, not theorised:

TypeError: stream_get_contents(): Argument #1 ($stream) must be of type resource, false given
  lib/private/Preview/Bitmap.php:89
  lib/private/Preview/Bitmap.php:51

What changed

  • fclose() moves into a finally, so it runs on both paths.
  • A false return from fopen() is handled explicitly and logged.

Tests

New tests/lib/Preview/BitmapStreamTest.php, 3 cases. It asserts the contract directly via is_resource($stream) on the caller's own handle rather than counting descriptors, so it is portable rather than Linux-only.

Confirmed RED before the fix on owncloudci/php:8.3 — 1 failure (the stream must be closed on the failure path) and 1 error (the TypeError above); the success-path case passed unfixed, as the control. GREEN after: 3 tests, 5 assertions.

Verification

  • tests/lib/Preview/ on owncloudci/php:8.3: 55 tests, 152 assertions, 0 failures.
  • make test-php-style: 0 of 2435 files need fixing.
  • php -l clean under PHP 7.4, so this backports to the 10.x line without syntax changes.

Note on sequencing

#41827 modifies these same lines. Whichever lands second needs a trivial rebase — the conflict is confined to the try/catch/fclose block.

🤖 Generated with Claude Code

Bitmap::getThumbnail() opened the file and closed it only on the success path.
The catch around getResizedPreview() returned without closing, so every failed
decode leaked one file descriptor for the lifetime of the process. A preview
pre-generation run or a cron preview job over a directory of files ImageMagick
has no coder for exhausts the descriptors one file at a time.

The open was also unchecked. A storage that cannot open the file returns false
rather than throwing, and stream_get_contents(false) raises a TypeError - an
\Error, so it escapes the \Exception handler directly underneath and surfaces as
a 500 instead of the missing preview every other failure here degrades to.

Closing moves into a finally block, and a false return from fopen() is handled
explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
@oc-tmueller
oc-tmueller requested a review from a team as a code owner September 16, 2026 10:33
@update-docs

This comment was marked as resolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
@phil-davis
phil-davis merged commit 0d0a306 into master Sep 16, 2026
31 checks passed
@phil-davis
phil-davis deleted the fix/oc10-164-bitmap-stream-leak branch September 16, 2026 11:41
phil-davis pushed a commit that referenced this pull request Sep 16, 2026
…6] (#41837)

* fix: release the stream when a bitmap preview cannot be decoded [10.16]

Bitmap::getThumbnail() opened the file and closed it only on the success path.
The catch around getResizedPreview() returned without closing, so every failed
decode leaked one file descriptor for the lifetime of the process. A preview
pre-generation run or a cron preview job over a directory of files ImageMagick
has no coder for exhausts the descriptors one file at a time.

The open was also unchecked. A storage that cannot open the file returns false
rather than throwing, and stream_get_contents(false) cannot report that: on the
PHP 7.4 this branch runs on it warns and hands on false, so the real cause is
only ever logged as "ImageMagick says: Zero size image string passed", behind an
unrelated PHP warning. (On PHP 8, which master runs, the same call raises a
TypeError - an \Error, so it escapes the \Exception handler directly underneath
and surfaces as a 500. That difference is why the wording here and in the
changelog deviates from #41835.)

Closing moves into a finally block, and a false return from fopen() is handled
explicitly.

10.16 backport of #41835.
(cherry picked from commit 0d0a306)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* test: make the backported stream tests detect their defects on PHP 7.4

Both cherry-picked test cases were written against PHP 8 and did not hold on the
only version this branch supports.

testReturnsFalseWhenTheFileCannotBeOpened asserted the return value, which cannot
distinguish anything on 7.4: stream_get_contents(false) merely warns and hands on
false, the sanitizer coerces it and Imagick rejects the empty string, so the
unpatched code already returns false. The case passed with the fix reverted. What
the guard actually removes on 7.4 is the noise - a warning from
stream_get_contents(), and an "ImageMagick says:" line blaming ImageMagick for a
file it never saw - so it now asserts that no warning is emitted, and is renamed
accordingly. The handler honours error_reporting(), so diagnostics the code under
test silenced with @ (the sanitizer's own loadXML warning, among any future ones)
cannot fail the case; the un-suppressed warning alone detects the regression.

testClosesTheStreamWhenDecodingThrows fed an XML payload, which ImageMagick sniffs
as SVG. It throws here only because neither owncloudci/php:7.4 nor :8.3 registers
an SVG delegate; on a build with librsvg or the internal MSVG renderer the lenient
parser returns a blank canvas instead, the decode succeeds and the case fails.
Replaced with content no coder claims at all, verified to be reported as format ''
rather than format 'SVG' on both images.

Confirmed both now fail without the fix - 2 failures, the second listing the
warning verbatim - with the success-path case still passing as the control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

---------

Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
oc-tmueller added a commit that referenced this pull request Sep 22, 2026
* test: stub the mime type in BitmapStreamTest so it survives the coder pin

BitmapStreamTest mocks OCP\Files\File without stubbing getMimeType(), so the mock
returns null. That is harmless today, but #41827 has Bitmap providers read the
mime type to decide which Imagick coder to pin, and getResizedPreview() declares
it as string - null there is a TypeError, which being an \Error escapes
getThumbnail()'s \Exception handler rather than degrading to no preview. Merging
#41827 would therefore turn these cases red on master.

The success case also decoded a PNG through the Photoshop provider, which only
works while ImageMagick is free to sniff the format. Once Photoshop pins the PSD
coder, a PNG stops decoding and the case fails for a reason that has nothing to
do with the stream. It now uses the PDF provider against testimage.pdf, so the
provider, the file's mime type and the content all agree and the success path
stays a success either way - guarded on the PDF coder, since pinning makes that
a hard requirement.

Verified against both trees: on master 3 tests / 5 assertions, and on master
merged with #41827 the full tests/lib/Preview/ suite is 79 tests / 215
assertions / 0 failures, where before this change it reported 2 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* test: decode a self-written TIFF rather than gating on the PDF coder

Imagick::queryFormats('PDF') reports that the coder was compiled in. It says
nothing about whether a PDF can actually be decoded: it consults neither the
coder rights in policy.xml nor the presence of the Ghostscript delegate. On an
image that revokes the PDF coder - the ImageMagick hardening OC10-164 is itself
driving - or one without the gs binary, the guard passes, readImageBlob() throws,
and the case fails red over an environment difference rather than over the stream
handling it exists to check. That is the same mistake as gating a test on a coder
the provider never uses, which this series has been removing elsewhere.

The success case now writes its own TIFF through Imagick and decodes it through
the TIFF provider. TIFF needs no external delegate, and a build cannot disagree
with itself about a blob it just produced, so the remaining skip fires only where
TIFF is unavailable altogether - in which case no assertion here could run
anyway. It also drops a fixture dependency.

The comments claiming that the mime type is read and that XML is rejected before
any coder is consulted described the coder-pin change on #41827, which is not in
this tree. They now say what happens here and what they anticipate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* test: probe the TIFF read path, not just the write path, before asserting

The guard added in the previous commit wrote a TIFF and treated that as proof the
build could handle TIFF. ImageMagick grants coder rights per direction, so a
policy of rights="write" for TIFF lets the blob be produced, declines to skip, and
then fails red on the decode - reintroducing exactly the failure the guard exists
to remove. It now reads the blob back inside the guard, so what is probed is what
the assertion needs. Verified by revoking TIFF read in a throwaway container: the
case skips with a clear message instead of failing.

The guard also caught only \ImagickException, while ImagickPixelException extends
\Exception directly and is a sibling rather than a subclass, so a pixel-wand
failure would have escaped as an error rather than the intended skip. It now
catches \Exception, and the Imagick handles are released in finally blocks rather
than only on the success path - which matters in a test about releasing handles.

Finally, the claim that no coder is consulted for the XML payload was wrong:
ImageMagick's SVG coder claims any blob opening with "<?xml" and then fails on a
document with no <svg> root. The comment now says that, and warns that another XML
payload is not automatically substitutable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* test: skip only when TIFF is absent, and assert the stream before the decode

The round-trip probe added in the previous commit closed one hole by opening
another: it turned any TIFF failure into a skip, and a skip here costs the
success-path fclose() assertion - which is the OC10-164 stream-leak guard itself.
A guard quietly withholding these assertions is exactly how they came to never run
in CI, so a misconfiguration should be loud, not green.

The guard is now the single condition that is genuinely an absent feature rather
than a broken setup: no TIFF coder registered at all. Revoked coder rights, an
unparsable policy.xml or a wand that cannot be constructed all fail. TIFF can be
held to that standard because no stock policy revokes it, unlike PDF, which
Debian and Ubuntu deny out of the box - the reason this uses a TIFF in the first
place.

The stream assertion also moves ahead of the decode assertion, so an environment
that cannot decode the blob still exercises the handle release under test and
still reports the decode as the failure. Verified by revoking TIFF read in a
throwaway container: all five assertions run, and the failure names the decode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* test: separate an absent TIFF delegate from a denied one by ImageMagick's message

The previous commit gated on Imagick::queryFormats('TIFF'), on the assumption that
registration implies support. It does not: coders/tiff.c registers TIFF, TIF and
TIFF64 unconditionally and only assigns the decoder and encoder pointers when
built against libtiff, while GetMagickList() behind queryFormats() matches on the
coder name alone. A build without libtiff therefore reports TIFF as registered,
declines to skip, and - with the catch removed by that same commit - errors
instead. That is the fourth variant of one mistake in this file: checking
something adjacent to what the assertion needs.

There is no registration check that can tell an absent feature from a broken
setup, so this stops using a proxy and reads what ImageMagick reports. A missing
delegate yields "no encode delegate for this image format" (or the decode
equivalent) and skips; a policy denial yields "not allowed by the security policy"
and is re-thrown, along with anything else. Both directions are probed, since
coder rights are granted per direction.

The success-path assertion message is also outcome-neutral now. It runs before the
decode assertion, so it fires when the decode failed too, and must not claim the
leak was on the success path when the decode is the actual defect.

Verified in throwaway containers: a normal build passes; a policy revoking TIFF is
loud rather than skipped; and MagickCore's message catalogue carries both delegate
strings this matches on. The missing-delegate branch is matched against that
catalogue rather than executed, since this build has libtiff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* test: keep both TIFF guards, since neither covers the other's case

The previous commit swapped the queryFormats() check for a message check when the
two are complementary. Without libtiff, a modular ImageMagick - Debian and Ubuntu
configure --with-modules - never builds coders/tiff.so, so TIFF is not registered
and setImageFormat() fails with php-imagick's own "Unable to set the image format"
before any delegate is consulted. That matches neither delegate substring, so it
was rethrown and turned a build with no TIFF feature red. queryFormats() is what
catches that case; the message check catches the non-modular build, which
registers TIFF regardless and fails later at the delegate. Both are back.

Also records two limits instead of implying they do not exist. A module- or
coder-domain policy denial can surface as MissingDelegateError, textually identical
to an absent delegate, so such a build skips - the classifier only rejects messages
that name a policy outright rather than guessing. And an allowlist-style policy.xml
denying all but a few coders fails here, which is the accepted cost of being loud
about misconfiguration; the note explaining why TIFF rather than PDF is restored
alongside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* test: fail the undecodable case on bytes no coder claims

The payload was '<?xml version="1.0"?><notanimage>x</notanimage>', which is not
environment-independent. ImageMagick's IsSVG() claims any blob opening with "<?xml",
so readImageBlob() reported "no decode delegate for this image format `SVG'" - it
threw only because these images register no SVG renderer. Where librsvg or the
internal MSVG renderer is present, the lenient parser returns a blank canvas rather
than throwing, and the case would fail for reasons unrelated to the stream. That is
the same environment coupling this file has been shedding elsewhere; the failure
path had it too.

It now uses bytes no coder claims. ImageMagick sniffs the format as "" and fails
with "no decode delegate for this image format `'" on every build regardless of
which delegates are compiled in. libmagic reads them as application/octet-stream
rather than text, so they also survive #41827's mime gate and still reach the
decode on that branch instead of being turned away earlier.

Verified in a container: the old payload sniffs as SVG, the new one as "", and both
the master tree and the tree merged with #41827 stay green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* docs: correct the recorded reasons in BitmapStreamTest's comments

Three claims in these docblocks were wrong, and the payload rationale was the one
that mattered: it said an XML payload "would throw only where no SVG renderer is
registered" and would otherwise return a blank canvas. Measured in three builds -
stock, with libmagickcore-6.q16-6-extra installed, and with the policy opened up -
it throws in all of them, as "no decode delegate `SVG'", then "not allowed by the
security policy `MVG'", then MVG's own "must specify image size". The coder is MVG
rather than SVG too. So the reason to prefer bytes no coder claims is not that the
XML payload is unusable, it is that its failure reason varies by build and that
libmagic reads it as text/xml, which #41827's mime gate rejects before the decode.
The comment now says that, so nobody rules out a working option on a wrong premise.

The read-back rationale claimed both directions get denied; what actually happens
with TIFF rights revoked is that getImageBlob() still returns a blob and only the
read raises - which is the argument for probing the read, now stated as measured.

The mime-type stub was described as anticipating #41827 and reading as speculative,
when omitting it is precisely what turned that PR red. It is stated as a
requirement instead, so it does not invite deletion once the pin lands.

Also trims the libtiff explanation. It asserted ImageMagick internals no assertion
here pins and which differ across major versions, and it is where the errors above
were concentrated; the two-check rationale and the PDF-vs-TIFF choice stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* test: pin why the undecodable case throws, and assert the handle first

Both assertions in testClosesTheStreamWhenDecodingThrows are satisfied by any early
return from getThumbnail(), and nothing tied the failure to the decode. That matters
on #41827, which adds a pre-decode mime gate denying text/*: a build whose libmagic
read these bytes as text would refuse them before any coder, leave this test green,
and quietly stop covering the path the test is named for. The detected media type is
now asserted, so that drift fails instead of hiding.

The two tests also disagreed on assertion order. PHPUnit stops at the first failure,
so asserting the result first meant an unexpectedly decodable payload would mask a
co-occurring leak - the handle being the regression guard this file exists for.
testClosesTheStreamOnSuccess already ordered it the other way and said why; the
failure case now matches.

The payload rationale claimed the sniffed format is "", which holds here but not
under the pin, where nothing is sniffed and the pinned coder rejects the header
instead. Both throw without depending on the build's delegates, which is the actual
property being relied on, so the comment says that rather than one tree's mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* test: decode a PSD, dropping the TIFF availability guard entirely

Every guard in this file existed because the success case used TIFF, and TIFF can be
absent: coders/tiff.so links libtiff. PSD cannot be absent for that reason -
coders/psd.so links no image library at all, ImageMagick implements the format
natively - and the Photoshop provider was already here for the failure case.

So the success case now writes and decodes a PSD, and the whole apparatus goes:
no queryFormats() check, no write-then-read-back probe, no message classifier
separating an absent delegate from a denied one, and no docblock asserting
ImageMagick internals that nothing pins. The test is unconditional, which is what it
should have been throughout - a skip would retire the success-path fclose()
assertion, and a guard quietly withholding assertions is how the OC10-164 preview
tests came to never run in CI to begin with. The file loses 44 lines.

This also removes a contradiction with the branch it is written to be compatible
with: CoderPinningTest::requireDecodableFixture() skips on a policy denial where the
classifier here rethrew, so the same suite gave two answers for the same coder.

Verified: unconditional pass on master and on the tree merged with the pin, and
still red when the finally that releases the handle is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* test: assert the gate's own condition, and stub the third mock's mime type

Two narrow corrections.

The pin on the payload's detected type asserted one exact classification,
application/octet-stream, while the gate it protects only refuses text/*,
image/svg*, application/xml and image/x-mvg. A libmagic that matched these bytes to
some other binary magic entry would still reach the decode exactly as intended and
fail the assertion, which is the build-dependence this file has been shedding. It
now mirrors isDangerousToDecode()'s own condition.

The mime type is also stubbed on the cannot-be-opened mock, so that case does not
depend on where in getThumbnail() the mime type is first read.

That stub does not make the file runnable on a tree without #41835's fopen guard,
and the comment no longer claims it does - measured on #41827's branch, which
carries neither that guard nor the finally, the suite reports 1 error and 1 failure
because every case here asserts what those two added. Failing there is correct, and
it is why this lands on master rather than folded into #41834: CI builds the
head-into-base merge commit, which always contains both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* docs: scope the master-only claim, and name the deny-list this mirrors

Two comment corrections, no code change.

"Every case here asserts behaviour the guard and the finally introduced" is wrong
for testClosesTheStreamOnSuccess: fclose() on the success path predates #41835,
which only moved it into the finally, so that case passes on a tree without either.
The measurement already said so - one error and one failure on #41834's branch, two
cases and not three - and the claim should have been scoped to those two.

The pre-decode check is also now attributed to its source, OC\Preview\Bitmap::
isDangerousToDecode(), which #41834 adds and which is private and so cannot be
called from a test. Mirroring it is still preferable to pinning one exact libmagic
classification, but the duplication has a cost worth stating: if that deny-list
gains an entry, this copy must gain it too, or the payload starts being refused at
the gate while the assertion stays green and the decode goes uncovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* docs: name the change that actually drifts, and drop merge-strategy prose

Two more comment corrections.

The maintenance note pointed the wrong maintainer at the mirror. A new text/ entry
in isDangerousToDecode()'s deny-list is already matched by the text/ prefix here, so
mirroring it would be busywork; the change that actually drifts is an entry of the
application/xml or image/x-mvg shape, which the prefix does not catch. It now says
that. isDangerousToDecode()'s own comment also enumerates what it already covers
rather than anticipating additions, so that clause is gone.

The claim that CI building the head-into-base merge commit is why this belongs on
master rather than folded into #41834 was a non-sequitur - that same fact means
folding it in would have been green too, since the failures only appear on the bare
branch. The real reason is that the branch tree lacks #41835 and so cannot run the
file locally, which the surrounding lines already say. Merge-strategy reasoning does
not belong in a test docblock in any case; it goes in the pull request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

* docs: state the drift rule against all three of the mirrored rules

The previous wording named only the text/ prefix and treated a drifting deny-list
entry as necessarily an exact match. The mirror has three rules, and an entry written
as a prefix - application/postscript alongside the existing image/svg, say - drifts
just as badly while a reader following that wording concludes no mirroring is needed.
It also over-warned in the other direction: an added image/svg+xml is not matched by
text/ but is already matched by the image/svg prefix, so it does not drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>

---------

Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
Co-authored-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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