Skip to content

fix: release the stream when a bitmap preview cannot be decoded [10.16] - #41837

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

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

Conversation

@oc-tmueller

Copy link
Copy Markdown
Contributor

10.16 backport of #41835.

Description

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.

A failed decode is not an edge case: any content ImageMagick has no coder for lands in that catch. So occ preview pre-generation, or a cron preview job, over a directory of .heic/.psd files leaks a handle per file until EMFILE.

fclose() moves into a finally, so it runs on both paths.

Second defect, same three lines — and it behaves differently on 7.4

$file->fopen('r') was unchecked. This is where the backport genuinely diverges from #41835, so the wording of the commit message, the changelog and one test was adapted rather than cherry-picked verbatim.

On PHP 8, which master runs, stream_get_contents(false) raises a TypeError — an \Error, so it escapes the catch (\Exception) directly underneath and surfaces as a 500. On PHP 7.4, the only version this branch supports, the same call merely warns and hands on false. Probed directly on both images:

PHP 7.4.33  WARNING: stream_get_contents() expects parameter 1 to be resource, bool given
            returned: false
PHP 8.3.31  threw TypeError: stream_get_contents(): Argument #1 ($stream) must be of type resource, false given

Tracing the unfixed 10.16 path for a storage that returns false, the false is coerced by the sanitizer and Imagick rejects the empty string:

PHP WARNING: stream_get_contents() expects parameter 1 to be resource, bool given
caught \Exception -> logs 'ImageMagick says: Zero size image string passed' and returns false

So on 10.16 this is not a 500 — it is a spurious warning plus a log line blaming ImageMagick for a file that was never opened. Handling the false explicitly replaces both with one accurate message, and keeps the line correct if it is ever run on PHP 8. (DOMDocument::loadXML() warns as well, but behind @, and OC\Log\ErrorHandler::onError() drops @-suppressed diagnostics, so only one warning reaches owncloud.log outside debug mode — which is why the changelog says "an unrelated warning" where #41835's says nothing about warnings at all.)

Tests

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

The second commit reworks two of the three cases, because as cherry-picked they did not hold on 7.4:

  • The unopenable-file case could not fail. It asserted the return value, but unpatched 10.16 already returns false there (per the trace above) — verified by reverting the fix and watching it pass. What the guard actually removes on 7.4 is the warning, so it now asserts that none is emitted, and is renamed to match. The handler honours error_reporting(), so @-suppressed diagnostics from anywhere in the path cannot fail it; the un-suppressed warning alone detects the regression.
  • The undecodable-content payload was delegate-dependent. <?xml …?><notanimage> is sniffed as SVG and throws only because neither owncloudci/php:7.4 nor :8.3 registers an SVG delegate (no decode delegate … 'SVG'). On a build with librsvg or the internal MSVG renderer, that lenient parser returns a blank canvas, the decode succeeds and the case fails. Replaced with content no coder claims at all, confirmed to be reported as format '' rather than 'SVG' on both images.

Confirmed RED before the fix on owncloudci/php:7.4: 2 failures — the unclosed stream, and the warning recorded verbatim — with testClosesTheStreamOnSuccess passing unfixed as the control. GREEN after: 3 tests, 6 assertions.

Verification

All on owncloudci/php:7.4, PHP 7.4.33, imagick 3.8.1 / ImageMagick 6.9.11-60:

  • tests/lib/Preview/BitmapStreamTest.php: 3 tests, 6 assertions, 0 failures.
  • tests/lib/Preview/: 41 tests, 0 failures, 18 skipped — against a pristine-branch baseline of 38 tests / 18 skipped. Exactly the 3 new tests, no new skips. (All 18 skips are the pre-existing No SVG provider present; this image registers no SVG coder. Assertion totals drift by a couple between runs because tests/lib/Preview/Provider.php feeds random_int() dimensions.)
  • make test-php-style: php-cs-fixer 0 of 2410 files, phpcs clean, OCPSinceChecker OK.
  • make test-php-phan: exit 0 over 1440 files, nothing reported. Worth running separately from master, since this branch pins phan ^5.4 (5.5.2) where master pins ^6.0.7.
  • phpstan (1.12.34, the version this branch locks): 311 findings both with and without the change — identical counts, none in lib/private/Preview/Bitmap.php. That count is an artefact of my local lib/composer state; CI reports "No errors" over the same 1497 files.
  • php -l clean on both changed files.

Diff vs master

Only the (string)$bp cast is absent — master gained it in #41449 (PHP 8.3 support), 10.16 keeps loadFromData($bp). It is context, not touched by this change; the 3-way cherry-pick preserved the 10.16 form.

Related

The same class of leak still exists next door — Preview\Image::getThumbnail() returns early on rejected dimensions without reaching its fclose(), and SVG/TXT also feed an unchecked fopen() into stream_get_contents(). #41835 left those alone too; keeping this backport minimal, they deserve their own issue.

🤖 Generated with Claude Code

oc-tmueller and others added 2 commits September 16, 2026 15:07
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>
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>
@phil-davis
phil-davis merged commit 6443822 into 10.16 Sep 16, 2026
11 checks passed
@phil-davis
phil-davis deleted the fix/oc10-164-bitmap-stream-leak-10.16 branch September 16, 2026 23:46
oc-tmueller added a commit that referenced this pull request Sep 25, 2026
Found reviewing the release commit, all three in text that ships inside the
tarball, so this is the last point at which they are free to fix.

**Six updated dependencies were unnamed.** Diffing composer.lock at v10.16.4
against this branch gives 17 changed production packages; the entry listed 11.
Missing: sabre/dav (4.7.0 to 4.7.1), sabre/event (5.1.7 to 5.1.9),
sabre/vobject (4.5.8 to 4.6.1), pimple/pimple (v3.6.1 to v3.6.2),
nikic/php-parser (v5.7.0 to v5.9.0) and dg/composer-cleaner (v2.2.1 to
v2.2.2). All but sabre/event are direct entries in composer.json's require,
and sabre/vobject is a minor bump of the vCard/iCalendar parser behind CalDAV
and CardDAV — an administrator auditing what moved in that stack for 10.16.5
would have seen nothing. Five of the six came from #41787, whose URL was
missing from the entry as well; nikic/php-parser then went on to v5.9.0 in
#41830, which was already listed. Earlier releases on this branch do list
sabre bumps here (10.10.0, 10.11.0, 10.12.0), so the omission also broke the
house convention.

**Two entries linked a pull request that never reached this branch.** #41819
was merged into ci/oracle-db-in-github-actions-10.16, an intermediate branch
that no longer exists on the remote; the change reached 10.16 through #41815
(9408736). Likewise the 41835 entry cited only master's #41835, while the
10.16 delivery was #41837 (6443822). Both now cite the 10.16 pull request
first, matching what 41827, 41834 and 41855 already do with #41863 — so
calens also makes the branch's own pull request the primary link.

CHANGELOG.md is regenerated with calens and, as on the rest of this branch,
written without a trailing newline: `ocrelease changelog` captures calens'
stdout through execa, which strips it, and every released section on 10.16 was
produced that way. Adding one here would only create churn at the next
release.

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 added a commit that referenced this pull request Sep 25, 2026
* feat: release 10.16.5

Bump version.php to 10.16.5 and materialize the changelog fragments for
this release.

$OC_Version becomes [10, 16, 5, 0]: the 4th digit is the internal
DB-upgrade patch level, not the public patch number, and nothing in this
release needs it moved.

The 11 fragments in changelog/unreleased/ move into
changelog/10.16.5_2026-09-25/ (git mv, so the renames stay tracked) and
CHANGELOG.md is regenerated with calens. unreleased/ keeps its .gitkeep
and is now empty, ready for the next cycle.

Security:

- #41784 update PHP dependencies to close published advisories
- #41803 prevent path traversal via appconfig public_/remote_ keys
- #41827 reject SVG/script content before it reaches ImageMagick bitmap
  previews
- #41834 pin the Imagick coder for each preview provider

Bugfixes:

- #41782 restore index usage for filecache writes on Oracle
- #41808 avoid a deprecation notice when hashing the file cache path on
  Oracle
- #41814 show federated users in the share dialog when local users also
  match
- #41819 speed up Oracle schema introspection
- #41835 release the file handle when a bitmap preview cannot be decoded
- #41855 report a preview file that cannot be opened without logging
  noise

Changes:

- #41788 update PHP dependencies

Same shape as 852062d ("feat: release 10.16.4"), which did the changelog
and the version bump in one commit. Once merged, v10.16.5 gets tagged on
the merged commit and the release bundles are built and published from
owncloud/server-release.

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

* fix: correct three defects in the 10.16.5 release notes

Found reviewing the release commit, all three in text that ships inside the
tarball, so this is the last point at which they are free to fix.

**Six updated dependencies were unnamed.** Diffing composer.lock at v10.16.4
against this branch gives 17 changed production packages; the entry listed 11.
Missing: sabre/dav (4.7.0 to 4.7.1), sabre/event (5.1.7 to 5.1.9),
sabre/vobject (4.5.8 to 4.6.1), pimple/pimple (v3.6.1 to v3.6.2),
nikic/php-parser (v5.7.0 to v5.9.0) and dg/composer-cleaner (v2.2.1 to
v2.2.2). All but sabre/event are direct entries in composer.json's require,
and sabre/vobject is a minor bump of the vCard/iCalendar parser behind CalDAV
and CardDAV — an administrator auditing what moved in that stack for 10.16.5
would have seen nothing. Five of the six came from #41787, whose URL was
missing from the entry as well; nikic/php-parser then went on to v5.9.0 in
#41830, which was already listed. Earlier releases on this branch do list
sabre bumps here (10.10.0, 10.11.0, 10.12.0), so the omission also broke the
house convention.

**Two entries linked a pull request that never reached this branch.** #41819
was merged into ci/oracle-db-in-github-actions-10.16, an intermediate branch
that no longer exists on the remote; the change reached 10.16 through #41815
(9408736). Likewise the 41835 entry cited only master's #41835, while the
10.16 delivery was #41837 (6443822). Both now cite the 10.16 pull request
first, matching what 41827, 41834 and 41855 already do with #41863 — so
calens also makes the branch's own pull request the primary link.

CHANGELOG.md is regenerated with calens and, as on the rest of this branch,
written without a trailing newline: `ocrelease changelog` captures calens'
stdout through execa, which strips it, and every released section on 10.16 was
produced that way. Adding one here would only create churn at the next
release.

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

* chore: fold the federated address book sync fix into the 10.16.5 release notes

Its fix merged into 10.16 after the release notes were first prepared, so
its changelog fragment was still sitting in changelog/unreleased and would
have been deferred to the next release while the fix itself shipped in this
one.

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.

3 participants