Skip to content

fix: prevent arbitrary file write via unsanitized SVG/MVG bitmap previews (OC10-164) - #41827

Merged
oc-tmueller merged 18 commits into
masterfrom
fix/oc10-164-bitmap-preview-arbitrary-file-write
Sep 24, 2026
Merged

oc-tmueller merged 18 commits into
masterfrom
fix/oc10-164-bitmap-preview-arbitrary-file-write

Conversation

@oc-tmueller

Copy link
Copy Markdown
Contributor

Summary

Bitmap::getResizedPreview() sanitized SVG content before handing it to Imagick::readImageBlob(), but fell back to the original, unsanitized bytes whenever the sanitizer returned an empty string. SVG::sanitizeSVGContent() returns '' for any content libxml cannot parse, not just genuinely malformed SVG - so a malformed SVG (or any non-XML payload such as a raw MVG script) reached ImageMagick unsanitized. There, an <image xlink:href="MSL:..."> or an MVG fill 'url(...)' primitive can execute an MSL script that reads and writes arbitrary files as the web user (CVSS 8.8).

  • Bitmap::getResizedPreview() no longer sanitizes-then-falls-back. It now rejects any content whose libmagic-detected media type is text/*, image/svg+xml, application/xml, or image/x-mvg before ever calling into Imagick (isDangerousToDecode()), and goes through ImagickFactory::create() so the svg:sanitize/svg:embed/svg:decode hardening options apply here too.
  • SVG::sanitizeSVGContent()'s return type changes from string to ?string, returning null when the underlying sanitizer does not return a string, so callers can distinguish "could not sanitize" from "sanitized to an empty document". The SVG provider's own call site now bails out on null instead of silently passing empty content to Imagick.
  • SanitizeTest is updated: SVG content fed to a Bitmap provider (PDF, Font) now must return false instead of a rendered PNG, since Bitmap providers no longer attempt to handle SVG-shaped content at all - that's the dedicated SVG provider's job. Added regression cases for a malformed SVG with an MSL xlink:href, a raw MVG script, and a well-formed SVG - all must return false from a Bitmap provider.

Note: image/x-mvg is included in the deny-list because libmagic (file-5.41, standard Ubuntu magic database) classifies a raw MVG script as image/x-mvg, not as any text/*/xml type - without it, the MVG regression case would not be blocked.

Not in scope for this PR (flagging separately): Bitmap::getThumbnail() leaks $stream when getResizedPreview() throws - it returns at line 54 before the fclose() at line 57. That's a resource leak, not a security defect, and deserves its own focused PR.

Test plan

  • make test-php-style
  • make test-php-unit TEST_PHP_SUITE=tests/lib/Preview/ - 58 tests, 8 skipped (unrelated missing Movie/Office providers), 0 failures
  • Confirmed RED before the fix: 6/8 SanitizeTest cases failed against unfixed code (the well-formed/sanitizable/malformed SVG cases all reached Imagick and rendered); confirmed GREEN after
  • tests/lib/Preview/PDFTest.php (testimage.pdf) and tests/lib/Preview/BitmapTest.php (testimage.eps) still produce previews

@oc-tmueller
oc-tmueller requested a review from a team as a code owner September 11, 2026 09:41
@update-docs

update-docs Bot commented Sep 11, 2026

Copy link
Copy Markdown

Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a changelog item based on your changes.

@kw-fscheuer

Copy link
Copy Markdown
Member

Follow-up suggestion rather than a change request on this PR — the fail-open fix here looks right to
me, and this is about removing the bug class rather than the instance.

What this approach leaves open by design. isDangerousToDecode() is a deny-list over the
sniffed type, and the decode that follows re-derives the format independently. Two consequences:

  1. The provider is selected by extension; the coder is selected by content. Detection.php maps
    an extension to a MIME type, which picks the provider — but readImageBlob() passes no format, so
    ImageMagick consults its own magic table (magick/magic.c, ~130 entries) and selects whatever the
    bytes look like. The set of coders reachable from any Bitmap provider is therefore the whole
    sniffable set, not the single MIME type the provider was registered for. Some of those coders
    shell out to external binaries through delegates.xml.
  2. Two different sniffing engines make the decision and take the action. The security check uses
    libmagic (getMimeTypeDetector()->detectString()); the decode uses ImageMagick's own table. Where
    they disagree, the check passes on libmagic's answer and the decode happens on ImageMagick's —
    the same shape as the libxml-vs-ImageMagick differential that made the original fail-open
    reachable in the first place.

There is a concrete residual path in this class. I've put the specifics on OC10-164 rather than here.

Suggested follow-up: pin the coder instead of letting ImageMagick guess. Each provider already
knows its format — SGI is registered for image/sgi and nothing else — so state it:

$bp->readImageBlob($content, 'SGI:blob');

That is a hard pin, not a hint. BlobToImage() sniffs only when the format is unset
(magick/blob.c:359-360), and an explicit FORMAT: prefix sets affirm in SetImageInfo()
(magick/image.c:2945), which returns before the "determine the image format from the first few
bytes" block at :2976. Verified against tag 6.9.12-98. No new Imagick API and no version floor.

Scope is small. There are three Imagick read paths in the whole repo —
Bitmap::getResizedPreview(), SVG::getThumbnail() and Office::getThumbnail() (which already
reads a file it produced itself) — plus one format declaration per Bitmap subclass. Roughly 15-20
lines across 11 files.

Two things worth watching:

  • Map MIME type → coder, not provider class → coder. Heic serves both image/heic and
    image/heif, and Font serves application/font-sfnt and application/x-font, so a single
    constant per class forces a wrong guess for one of them. Note that coders/heic.c registers
    HEIC, HEIF and AVIF as three separate coders, so an AVIF-branded file served by the Heic
    provider is the one case I'd want a real test sample for.
  • Subimage selection. Office::getThumbnail() passes [0], so a pinned filename becomes
    PDF:/tmp/x.pdf[0]. Frame parsing happens in the same SetImageInfo path, but it deserves a test.

Suggested tests in tests/lib/Preview/: a "pinned format still decodes" case per provider, plus a
negative case asserting that content of one format, under an extension mapping to a different
provider, no longer decodes.

The same change would need to follow on the 10.x line afterwards — no need to touch #41828 until the
v11 approach is agreed here.

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>
oc-tmueller added a commit that referenced this pull request Sep 22, 2026
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
@oc-tmueller
oc-tmueller force-pushed the fix/oc10-164-bitmap-preview-arbitrary-file-write branch from d828f95 to 78bb0f4 Compare September 22, 2026 20:57
jvillafanez
jvillafanez previously approved these changes Sep 23, 2026

@jvillafanez jvillafanez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code looks fine. I Assume the tests fail because of the PHP version (OC10 runs with PHP 7.4 not 8.3)

@phil-davis

Copy link
Copy Markdown
Contributor

https://github.com/owncloud/core/actions/runs/35783629819/job/107115884062?pr=41827

Err:8 mirror+file:/etc/apt/apt-mirrors.txt noble-updates/main amd64 libgs-common all 10.02.1~dfsg1-0ubuntu7.8
  404  Not Found [IP: 52.147.219.192 80]
Ign:9 https://security.ubuntu.com/ubuntu noble-updates/main amd64 libgs10-common all 10.02.1~dfsg1-0ubuntu7.8
Err:9 mirror+file:/etc/apt/apt-mirrors.txt noble-updates/main amd64 libgs10-common all 10.02.1~dfsg1-0ubuntu7.8
  404  Not Found [IP: 52.147.219.192 80]
Ign:14 https://security.ubuntu.com/ubuntu noble-updates/main amd64 libgs10 amd64 10.02.1~dfsg1-0ubuntu7.8
Err:14 mirror+file:/etc/apt/apt-mirrors.txt noble-updates/main amd64 libgs10 amd64 10.02.1~dfsg1-0ubuntu7.8
  404  Not Found [IP: 52.147.219.192 80]
Ign:15 https://security.ubuntu.com/ubuntu noble-updates/main amd64 ghostscript amd64 10.02.1~dfsg1-0ubuntu7.8
Err:15 mirror+file:/etc/apt/apt-mirrors.txt noble-updates/main amd64 ghostscript amd64 10.02.1~dfsg1-0ubuntu7.8
  404  Not Found [IP: 52.147.219.192 80]
E: Failed to fetch mirror+file:/etc/apt/apt-mirrors.txt/pool/main/g/ghostscript/libgs-common_10.02.1%7edfsg1-0ubuntu7.8_all.deb  404  Not Found [IP: 52.147.219.192 80]
Fetched 20.6 MB in 2s (13.0 MB/s)
E: Failed to fetch mirror+file:/etc/apt/apt-mirrors.txt/pool/main/g/ghostscript/libgs10-common_10.02.1%7edfsg1-0ubuntu7.8_all.deb  404  Not Found [IP: 52.147.219.192 80]
E: Failed to fetch mirror+file:/etc/apt/apt-mirrors.txt/pool/main/g/ghostscript/libgs10_10.02.1%7edfsg1-0ubuntu7.8_amd64.deb  404  Not Found [IP: 52.147.219.192 80]
E: Failed to fetch mirror+file:/etc/apt/apt-mirrors.txt/pool/main/g/ghostscript/ghostscript_10.02.1%7edfsg1-0ubuntu7.8_amd64.deb  404  Not Found [IP: 52.147.219.192 80]
E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?

I restarted the workflow, and it had these same errors.
"something" (tm) is wrong with the infrastructure that provides apt ?

@phil-davis

Copy link
Copy Markdown
Contributor

actions/runner-images#7452

This is not a new thing. There are plenty of complaints about http://azure.archive.ubuntu.com and that IP address 52.147.219.192

@oc-tmueller

Copy link
Copy Markdown
Contributor Author

actions/runner-images#7452

This is not a new thing. There are plenty of complaints about http://azure.archive.ubuntu.com and that IP address 52.147.219.192

fix coming in ....

oc-tmueller added a commit that referenced this pull request Sep 23, 2026
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
@oc-tmueller
oc-tmueller force-pushed the fix/oc10-164-bitmap-preview-arbitrary-file-write branch from 78bb0f4 to 9ed5b56 Compare September 23, 2026 12:15

@kw-fscheuer kw-fscheuer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the six commits over 13c6163857, read-only against the branch.

The design is sound and I want to be clear about that up front: rejecting text-shaped content before ImageMagick and pinning the coder per provider gives two independent layers, and the comments explaining each decision are unusually good — particularly the one on getImagickFormat() setting out why $mimeType comes from $file->getMimeType() rather than the selection type, and why returning a constant is the right answer for trashbin previews. I traced the fail-open case below through both layers and the pin does hold the line: MVG content routed to the TIFF provider still dies in the TIFF coder. That is the defence-in-depth working as intended.

Three things I would want fixed before this merges.

1. The mime gate fails open — lib/private/Files/Type/Detection.php:264-277

isDangerousToDecode() (Bitmap.php:164) delegates to Detection::detectString(), which returns finfo_buffer() verbatim at line 267. That is typed string|false. When it returns false, explode(';', false, 2)[0] coerces to '', both strpos() checks miss, in_array('', [...], true) is false — and the content is admitted.

The else branch is the larger version of the same problem. With no ext-fileinfo it writes a temp file and calls $this->detect($tmpFile), which is extension-first; a temp file with no meaningful extension falls through to application/octet-stream unless the file binary happens to be installed. On such a build the OC10-164 gate admits everything. That branch also truncates to 8024 bytes (line 271), so even the file-binary path can be padded past.

Suggest an explicit fail-closed branch in isDangerousToDecode() — anything that is not a non-empty string is dangerous. That is cheaper than hardening detectString(), whose other callers expect the current behaviour.

2. finfo_open() is unchecked — same function, line 266

finfo_open(FILEINFO_MIME) returns false on a missing or corrupt magic database and goes straight into finfo_buffer(false, $data), which is a TypeError under PHP 8. TypeError is an \Error, so it walks past the catch (\Exception) in Bitmap::getThumbnail() and every bitmap preview becomes a 500 rather than degrading to a media-type icon.

Worth flagging because b5d51e6 in this same PR added the (string) cast on getMimeType() for precisely this hazard, with a comment explaining it — and the new code path then reintroduces it.

3. SVG::getThumbnail() does not check fopen() — lib/private/Preview/SVG.php:48

$stream = $file->fopen('r') at line 48, then stream_get_contents($stream) at 49, with no === false guard. Same TypeError → 500 path that Bitmap.php:49 now guards explicitly, with a comment saying exactly why. Same PR, one file brought up to standard and the other missed. The fclose() at line 53 is also outside any finally, so a throw between 48 and 53 leaks the handle — Bitmap.php gets that right too.

Non-blocking

  • ImagickFactory::create($path) sets svg:sanitize/svg:embed/svg:decode after new Imagick($files) has already decoded, so the path-taking overload gets none of the hardening. Office.php is the only caller that passes a path, and the new comment there presents it as the hardened route. The 'PDF:' prefix pin does work, since that is constructor-level, and it is LibreOffice's own output — so no impact here. But the API invites the mistake: construct empty then readImage(), or drop the overload.
  • SanitizeTest's new class-level @requires extension imagick contradicts the method comment directly below it, which argues against a coder guard so that a reduced build cannot silently skip the OC10-164 regression assertions. Every assertion in that class fires before Imagick is touched. The annotation belongs on CoderPinningTest only.
  • assertImage() (tests/lib/TestCase.php:559) and tests/lib/Preview/white-32x32.png are left with no callers or references.
  • detectString() runs over the whole file, up to preview_max_filesize_image (50 MB default), on every cache-miss preview. The non-finfo branch already truncates to 8024 bytes, which yields the same verdict for every type in the deny-list.
  • The RuntimeException thrown at Bitmap.php:108 is logged at line 66 as ImageMagick says: Refusing to decode text-based content.... An operator grepping for attempted OC10-164 exploitation cannot distinguish a blocked MVG upload from a corrupt TIFF. A separate catch, or a distinct marker, would make the gate attributable.
  • Scope: two changelogs, an unchangelogged 500 fix, and the PDFTest/SVGTest gate rewrite, against AGENTS.md:95,97. Probably not worth splitting now given the shared test suite, but noting it.

One residual worth a changelog sentence

41834 correctly scopes its Ghostscript claim to "any other bitmap provider". The direct route does stay open: provider selection is request-steerable — as the getImagickFormat() comment itself documents for apps/dav — and PDF::getImagickFormat() returns 'PDF' unconditionally, so ?mimeType=application/pdf on an arbitrary file pins its bytes to the PDF coder. Not a regression, since content sniffing did the same before, and the stock Debian/Ubuntu policy denies PDF/PS/EPS. A sentence setting that expectation would help.

Relatedly, this PR establishes that policy.xml is the process-wide control but neither ships one nor documents it as a deployment requirement. Any future code constructing an Imagick outside ImagickFactory reopens the class.

Two I could not confirm

Flagging rather than asserting, both cheap to make defensive regardless:

  1. Whether Imagick::setFormat() can return false without throwing on a build that does not register the coder — which would make the unchecked return value in getResizedPreview() fall back to the content sniffing the pin exists to prevent.
  2. Whether CoderPinningTest::requireDecodableFixture()'s catch (\ImagickException) misses the warning-severity condition that Provider::requireDecodableFixtureFile() deliberately catches as \Throwable — which would hard-fail the PDF/EPS/AI cases under failOnWarning="true" on policy-restricted distros, where they should skip.

@oc-tmueller

Copy link
Copy Markdown
Contributor Author
  1. SVG::getThumbnail() does not check fopen() — lib/private/Preview/SVG.php:48

already taken care of in https://github.com/owncloud/core/pull/41855/changes#diff-a55bd399feae16fe962bcdbcf764898adac00073a296a287d71b9c94b0815988

oc-tmueller and others added 4 commits September 24, 2026 10:57
…iews (OC10-164)

Bitmap::getResizedPreview() sanitized SVG content before handing it to
Imagick::readImageBlob(), but fell back to the ORIGINAL, unsanitized bytes
whenever the sanitizer returned an empty string - which it does for any
content libxml cannot parse, not just for genuinely malformed SVG. A
malformed SVG (or any non-XML payload such as a raw MVG script) therefore
reached ImageMagick unsanitized, where an <image xlink:href="MSL:..."> or
an MVG "fill 'url(...)'" primitive can execute an MSL script that reads and
writes arbitrary files as the web user (CVSS 8.8).

Bitmap providers (PDF, Font, Postscript, ...) only ever need to decode real
bitmap/vector image formats, never SVG or script-shaped text content - that
belongs exclusively to the dedicated SVG provider. getResizedPreview() now
rejects any content whose libmagic-detected media type is text/*,
image/svg+xml, application/xml, or image/x-mvg before ever calling into
Imagick, instead of trying to sanitize and falling back on failure. It also
now goes through ImagickFactory::create() so the svg:sanitize/svg:embed/
svg:decode hardening options apply here as they already did in the SVG
provider.

SVG::sanitizeSVGContent() return type changes from string to ?string so it
can report "could not sanitize" (null) separately from "sanitized to an
empty document" (''); its own provider now bails out on null instead of
silently passing empty content to Imagick.

The removal of the sanitize-with-fallback path in Bitmap changes the
behaviour asserted by SanitizeTest: SVG content fed to a Bitmap provider
(PDF, Font) now yields false instead of a rendered PNG, since Bitmap
providers no longer attempt to handle SVG-shaped content at all. Added
regression cases for a malformed SVG with an MSL xlink:href, a raw MVG
script, and a well-formed SVG - all must return false from a Bitmap
provider.

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>
Confirmed while testing the 10.16 backport: PHP 7.4's bundled fileinfo
extension reports the same SVG content as "image/svg", not
"image/svg+xml" - the exact-match check silently let it through on that
runtime while still catching it on PHP 8.3. Match by prefix instead so the
gate added in af3c147 ("fix: prevent arbitrary file write via
unsanitized SVG/MVG bitmap previews (OC10-164)") is not dependent on which
libmagic build a given PHP runtime happens to link.

Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
…0-164) (#41834)

* fix: pin the Imagick coder per bitmap preview provider (OC10-164)

isDangerousToDecode() (af3c147) is a deny-list over the libmagic-sniffed
type, but the decode that follows re-derives the format independently:
readImageBlob() with no format set consults Imagick's own ~130-entry magic
table, so the coder actually invoked can differ from what the mime check
reasoned about. application/postscript and application/pdf are deliberately
not denied - Postscript and PDF legitimately decode them - which means
PostScript-looking bytes still pass the gate through every other Bitmap
provider (SGI, Font, Illustrator, Photoshop, TIFF, Heic), and Imagick's own
sniffing then hands them to the Ghostscript delegate anyway.

Pin the coder each provider actually expects instead of leaving Imagick to
guess: getImagickFormat() maps a provider's own detected mime type(s) to an
explicit Imagick format name, and getResizedPreview() installs it with
setFormat() before readImageBlob(), so no temporary file is involved and the
content never leaves memory.

setFormat() pins the wand's output format as well as the input coder, so
both setImageFormat('png') and setFormat('png') are needed afterwards -
otherwise getThumbnail()'s (string) cast re-encodes back to the pinned input
format and hands back the original bytes. That one missing call is what
previously made setFormat() look as though it skipped rasterization
altogether. It does decode: verified against unpinned geometry for
tiff/psd/sgi/ai/heic/ttf on both ImageMagick 6.9.11-60 with imagick 3.8.1
and ImageMagick 7.1.1-36 with imagick 3.7.0.

The pin is deliberately not guarded by queryFormats(): if a build does not
register the coder a provider needs, throwing is correct, because the only
alternative is falling back to the content-sniffing this pin exists to
prevent. Heic pins HEIC for both image/heic and image/heif, as they are one
container handled by one coder module and not every build registers a
distinct HEIF coder.

Office.php pins through its constructor argument instead. A "FORMAT:path"
prefix there pins only the input coder and leaves the output format alone,
so its setImageFormat('jpg') needs no counterpart.

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: pin the Imagick coder for SVG previews too (OC10-164)

SVG::getThumbnail() is the one Imagick read path in core that is not a
Bitmap provider, and it had the same gap: ImagickFactory sets svg:sanitize,
svg:embed and svg:decode, but the read that follows let Imagick pick the
coder from the content, so those options could be reasoning about a
different coder than the one that ran.

Pin SVG explicitly, and reset both the image and wand output formats to
png32 afterwards for the same reason as Bitmap.php - setFormat() pins the
output format as well, so setImageFormat() alone would leave getImageBlob()
re-encoding back to SVG.

Unlike Bitmap.php the pin is guarded by queryFormats(). A build that
registers no SVG coder cannot be pinned to it and cannot decode SVG at all
either way, so throwing would trade a clear "no decode delegate" failure for
a confusing "Unable to set format" one; owncloudci/php:8.3 is such a build.
The value at risk is also lower here: what gets pinned is DOMSanitizer's
serialized output, not the raw file bytes.

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: cover Imagick coder pinning with real per-format fixtures (OC10-164)

Adds CoderPinningTest, which asserts both halves of the pin: every provider
still decodes its own format, and PostScript content is rejected by the
providers it is foreign to (SGI, Photoshop, TIFF, Heic) rather than being
handed to the Ghostscript delegate by ImageMagick's own content-sniffing.

Six fixtures had to be added - tests/data had no .ai/.heic/.psd/.sgi/.tiff/
.ttf sample at all, so there was nothing to decode per provider. The HEIC
fixture is AVIF-encoded on purpose: ImageMagick classifies the avif brand as
HEIC, and an HEVC-encoded sample needs a libde265 delegate that is not
present everywhere.

Skips are per-coder rather than blanket. The tests these are modelled on
gated on Imagick::queryFormats('SVG') as a stand-in for "this build has the
extended coder set", but owncloudci/php:8.3 registers no SVG coder at all,
so that guard skipped every case and the assertions never ran in CI. Each
case now requires only the one coder it exercises, which is also why the
image/heif case runs here: it pins HEIC, so it no longer depends on a
distinct HEIF coder being registered.

testPinnedDecodeReturnsPngAndNotThePinnedInputFormat covers the one
non-obvious part of the mechanism - setFormat() pins the output format as
well, and for TIFF the re-encode is byte-identical to the input, so dropping
the second setFormat() call would be easy to reintroduce and hard to notice.

SanitizeTest needs the mime type plumbed through, since providers now pin
based on it. Its skip guard moves to the PDF/TTF coders its two providers
actually use - it deliberately does not require an SVG coder, because the
whole point of those cases is that the content never reaches Imagick.

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: add changelog entry for the OC10-164 in-memory coder pin (#41834)

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: report build-dependent coder-pinning results correctly (OC10-164)

CoderPinningTest reported the wrong thing on any ImageMagick build differing
from the one it was written against, which matters for the pending PHP 7.4
backport: tests/phpunit-autotest.xml sets failOnRisky, and PHPUnit 9.6 defaults
beStrictAboutTestsThatDoNotTestAnything to true, so a test executing zero
assertions is a hard failure rather than a warning.

testFontNeverInvokesADangerousCoderForForeignContent kept its only assertion
inside `if ($result !== false)`. On any build where FreeType refuses the
PostScript payload outright - the safest outcome, and the one the test exists to
assert about - it executed no assertion at all and failed as risky. Both
outcomes now collapse into one branch-free assertion.

requireCoder() proves a coder is registered, not that the delegate behind it can
decode a given fixture. coders/heic.c registers HEIC, HEIF and AVIF whenever
libheif is present, but decoding the AVIF fixture additionally needs an AV1
decoder inside libheif, so a build without one failed instead of skipping.
requireDecodableFixture() reads the fixture unpinned first and skips when the
build cannot decode those bytes at all, since the pinned read failing then says
nothing about the pin. The fixture stays AVIF-branded deliberately: an
AVIF-branded file served by the Heic provider, which pins HEIC for it, is
exactly the case worth a real sample.

The negative tests could also pass for the wrong reason. isDangerousToDecode()
is a deny-list over the sniffed type and it denies text/*, so a libmagic build
reporting the payload as text/plain would reject it at that gate before the
coder pin ever ran. assertPayloadReachesTheCoderPin() asserts the sniffed type,
so such a build fails loudly with an actionable message instead of passing
vacuously. The payload itself was duplicated in both tests and is now a
constant.

Both fixtures the Font and Illustrator cases used are replaced by files already
in the tree. testimage.ttf was Microsoft Verdana, carrying an "All Rights
Reserved" notice and a trademark notice, so the Font case now reads the
Apache-2.0 core/fonts/OpenSans-Regular.ttf instead - same sfnt tag, same DSIG
table, same coder path. testimage.ai was byte-identical to testimage.pdf, and
ImageMagick's AI coder is a Ghostscript alias for the PDF one, so the
Illustrator case reads testimage.pdf directly. Fixture paths now resolve through
OC::$SERVERROOT, the existing idiom in tests/lib.

CoderPinningTest and SanitizeTest both call Imagick::queryFormats() unguarded,
which raises a class-not-found Error rather than skipping on a build without
ext-imagick. Both get the @requires annotation the neighbouring provider tests
already use.

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: gate preview tests on the coder each provider pins (OC10-164)

Three neighbouring preview tests guarded on the wrong thing, in ways the coder
pin makes load-bearing.

PDFTest gated on Imagick::queryFormats('SVG'), a coder the PDF provider never
touches. On any build registering no SVG coder - owncloudci/php:8.3 among them -
all four cases skipped while reporting "No PDF provider present", so the PDF
preview assertions never ran in CI even though the PDF coder was present. It now
requires PDF, the coder PDF::getImagickFormat() actually pins.

SVGTest names the right coder but compared the count to exactly 1, which skips
whenever a build registers SVG alongside SVGZ or MSVG. It now checks for zero,
matching the idiom the rest of the directory uses.

BitmapTest had no coder guard at all. It drives Postscript against testimage.eps,
which now hard-requires the EPS coder rather than reaching one through
ImageMagick's own sniffing, so on a reduced build it would fail instead of
skipping. It now requires EPS.

SanitizeTest's guard goes the other way and is removed entirely.
isDangerousToDecode() rejects that content before ImagickFactory::create() and
before setFormat(), so those eight cases never reach a coder - requiring PDF and
TTF could only ever let a reduced build skip the OC10-164 regression assertions
silently, which is the failure mode this whole series is trying to remove.

The changelog entry also now records that pinning costs previews for files whose
extension does not match their content, since media types come from the
extension. That is the intended trade-off, but it is user-visible.

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: cover decoding when the stored mime type is not the provider's (OC10-164)

The coder pin reads $file->getMimeType(), not the mime type that selected the
provider. Those differ whenever a caller overrides the selection type through
getThumbnail(['mimeType' => ...]): apps/files_trashbin/ajax/preview.php does,
because a trashed file's .d<timestamp> suffix defeats extension-based detection
and leaves the node reporting application/octet-stream, and apps/dav forwards the
request's query parameters straight through.

Using the file's own type is deliberate - a request cannot steer it, which is the
property the pin depends on. The cost is that an implementation is handed mime
types it does not serve, and must still decode; returning a constant coder does
that correctly.

That is easy to mistake for a bug and "tighten" by rejecting any mime type which
fails the provider's own getMimeType() regex. Doing so would reject every
trashbin bitmap preview - tif, psd, sgi, heic, ai, pdf and eps alike - to fix one
case. Three cases now assert the opposite, each first asserting that the mime
type really does fail the provider's regex so they cannot pass vacuously.

Font is the only provider whose coder depends on the argument, so it is the only
place the divergence is observable: a .pfb not stored as application/x-font gets
no preview. Deciding from content instead would mean re-deriving the format from
magic bytes, which is what the pin exists to avoid, so this is recorded rather
than fixed.

Comments only in lib/; no behaviour change.

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>
oc-tmueller and others added 14 commits September 24, 2026 10:57
Pinning the Imagick coder made $file->getMimeType() load-bearing: it now feeds
getResizedPreview()'s string-typed $mimeType parameter. That value comes from
FileInfo::getMimetype(), which returns whatever Cache::get() stored, and that in
turn is MimeTypeLoader::getMimetypeById() - documented to return null for an id
with no row in oc_mimetypes. Nothing constrains oc_filecache.mimetype with a
foreign key, so a dangling id is reachable.

Uncast, such a file raised a TypeError. A TypeError is an \Error, so it escaped
getThumbnail()'s catch (\Exception) and surfaced as a 500 instead of degrading
to a media-type icon - the same failure mode the unchecked fopen() had.

Casting keeps the degradation graceful without weakening the pin: seven of the
eight providers return a constant coder and ignore the argument entirely, and
Font, the only one that branches on it, falls through to TTF exactly as it
already does for a trashed .pfb reporting application/octet-stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
Two independent weaknesses, both found by re-reviewing this range rather than by
any failure.

SanitizeTest could only half detect a reverted mime gate. Removing the gate left
the four PDF cases passing: PDF pins a Ghostscript-backed coder, which rejects
SVG and MVG bytes on its own, so the assertion held on the pin alone. Only the
four Font cases failed, because the TTF coder renders arbitrary bytes as a
specimen sheet and so yields a preview. That left the two providers whose pin
offers no protection - the Ghostscript-backed ones, exactly where the gate is the
only defence - with no coverage at all.

Fixed by adding a payload that is denied by the gate yet decodable by the pin:
PostScript with its %!PS-Adobe header sniffs as application/postscript, which the
gate has to allow so real ones still preview, but drop the header and libmagic
reports text/plain while an affirmed PDF:/EPS: pin still hands it to Ghostscript.
Verified both directions - the two new cases pass with the gate and fail without
it, taking the revert detectors from four of eight to six of ten.

Also added a control assertion mirroring the deny-list, so a build whose libmagic
classifies one of these payloads differently fails with an actionable message
instead of going quiet, and renamed the parameter from $svgContent, which no
longer describes what it carries.

PDFTest and BitmapTest gated on Imagick::queryFormats(), which reports only that
a coder is registered. coders/pdf.c and coders/ps.c register PDF, AI and EPS
unconditionally and wire the Ghostscript delegate separately, and
MagickQueryFormats() never consults policy.xml - which on stock Debian and Ubuntu
denies the PDF/PS/EPS/XPS coders. On either build the guard answered "present"
and the cases failed where they meant to skip. Both now probe the fixture they
actually decode, via a shared helper on Provider.

That helper is requireDecodableFixtureFile(), named for the path it takes so it
cannot be confused with CoderPinningTest's blob-taking equivalent: handing one a
path would throw inside the probe and be converted into a skip, silently
retiring a test. It reads the fixture outside the probe, so an unreadable one
still fails, and catches \Throwable inside it, because imagick reports some
delegate and policy conditions at warning severity and failOnWarning="true"
turns those into PHPUnit warnings rather than ImagickException.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
testFontNeverInvokesADangerousCoderForForeignContent() asserted that the returned
preview stayed under 2048 bytes, meaning to catch the PostScript payload coming
back rendered by Ghostscript. It could not: OC_Image::data() re-encodes through
GD, and the image has already been downscaled to fit 32x32 by then, so both the
safe and the unsafe outcome land within a few hundred bytes of each other.
Removing the coder pin left the case green.

The thumbnail's shape does distinguish them. The payload now declares a portrait
bounding box, ImageMagick's TTF coder draws a fixed 800x480 specimen sheet, and on
owncloudci/php:8.3 that is 32x19 through the TTF pin against 25x32 with the pin
removed. Verified both directions: green as shipped, and failing with the pin
removed, which takes CoderPinningTest's detectors from four to five.

The page stays blank - the bounding box is the whole difference from
FOREIGN_POSTSCRIPT, since nothing here inspects a pixel. assertFalse() on the
result would not work either: the placeholder FreeType returns for non-font bytes
is a valid image on this build, so it would fail against a correctly pinned
decode. Kept as one branch-free expression, because failOnRisky turns a
zero-assertion test into a hard failure on any build that refuses the bytes.

The case now also requires that this build can rasterize PostScript at all.
Without Ghostscript, or under the stock Debian policy denying the PS coder, the
pin-removed mutant throws instead of rendering, getThumbnail() returns false and
the assertion would hold with no pin in place - green while protecting nothing.

Both capability probes here and on Provider catch \Exception rather than
\ImagickException. imagick reports some delegate and policy conditions at warning
severity, and PHPUnit 9 turns PHP warnings into PHPUnit\Framework\Error\Warning
through convertWarningsToExceptions, which defaults to true and is unset in
tests/phpunit-autotest.xml; failOnWarning only decides whether an emitted warning
fails the run. That class reaches \Exception via PHPUnit\Framework\Exception, so
one catch covers both - and unlike \Throwable it still lets an \Error fail rather
than become a green skip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
Three ways these cases could stay green with Bitmap::getResizedPreview()'s
setFormat() pin removed.

testFontNeverInvokesADangerousCoderForForeignContent() asserted the preview came
back under 2048 bytes. OC_Image::data() re-encodes through GD after the image has
been downscaled to fit 32x32, so the safe and unsafe outcomes both land within a
few hundred bytes and the ceiling could never be crossed. Shape does separate
them: ImageMagick's TTF coder draws a fixed 800x480 specimen sheet, landscape,
while an unpinned read goes to the PS coder and rasterizes a full page, portrait -
32x19 against 25x32 on owncloudci/php:8.3. assertFalse() would not work either,
since the placeholder FreeType returns for non-font bytes is a valid image here.

That shape is a property of the build, not of the payload: measured, this build's
PS coder ignores %%BoundingBox, EPSF-3.0 branding and setpagedevice alike and
always renders 612x792. So requirePortraitPostScriptRender() now asserts it, and
does so after reproducing the first-frame selection and bestfit downscale the
assertion actually observes - a raster only slightly taller than wide collapses to
exactly 32x32 there, which would have waved through a build that cannot
discriminate. The payload keeps a portrait bounding box even so, because upstream
coders/ps.c does derive Ghostscript's -g geometry from it on other builds, and a
portrait box is portrait under both behaviours where a square one would silently
stop exercising the pin.

testRejectsPostScriptContentFromAForeignProvider() had no PostScript precondition
at all. Under a policy.xml denying PS/EPS/PDF the unpinned read throws just as the
pinned one does, so all four rows held with no pin in place. They now require a
renderable payload first, via a plain capability helper the portrait one builds
on; with the pin removed and PS denied the class skips 8 instead of falsely
passing 4.

FOREIGN_POSTSCRIPT_PORTRAIT is gone - one constant serves both tests again.

Verified on owncloudci/php:8.3: 19 pass as shipped; 5 fail with the pin removed;
8 skip and none pass falsely with the pin removed and PS denied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
The entry named tif, psd, sgi, heic and ai - the most visible subset of the
trade-off rather than its extent. The list now comes from
resources/config/mimetypemapping.dist.json instead of memory, so image/sgi
contributes bw, int, inta, rgb and rgba alongside sgi, and image/heif contributes
heif. svgz is not mapped there at all, so it is deliberately absent.

It also says which of those providers a stock install actually has. Only SGI and
Heic are in PreviewManager's defaults; PDF, Postscript, Illustrator, Photoshop,
TIFF and Font need enabling, so without that note the list reads as a much wider
regression than most admins will see.

Fonts get their own paragraph rather than being listed or omitted. The font coder
accepts any bytes, so a mismatched .ttf still yields a thumbnail - just one the
font coder drew instead of the file's content - and a real .otf gains a preview it
never had, since an unpinned read has no decode delegate for CFF outlines. Neither
"affected" nor "unaffected" describes that honestly.

Office and SVG stay excluded: the Office pin covers the PDF LibreOffice just
produced rather than user bytes, and non-XML content never reached a coder in SVG
before this change either.

Also softened the "no filesystem access" claim to what this change is responsible
for. The pin adds no temporary file of its own, but it does not stop the
Ghostscript-backed coders staging their own input, and the original wording read
as a promise about the whole preview path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
Detection::detectString() is documented as returning a string, but returned
finfo_buffer()'s value unchecked, which is typed string|false, and passed
finfo_open()'s result to it without checking that either.

Both matter to the bitmap preview media type gate added earlier in this pull
request, which compares the detected type against a list it refuses to hand to
ImageMagick:

  - finfo_open() returns false when libmagic cannot load its magic database.
    finfo_buffer() then raises a TypeError, and because that is an \Error rather
    than an \Exception it escapes the handler in Bitmap::getThumbnail(), so a
    preview failed the whole request with a server error instead of falling back
    to a media type icon.
  - a false from finfo_buffer() reached the gate as the empty string, which
    matches no entry in the list, so the content was admitted.

Both now fall back to application/octet-stream. The finfo_open() warning is
suppressed the same way finfo_file()'s already is a few lines above, since the
fallback is what the caller acts on.

The function_exists() guard also tested finfo_file(), which this branch never
calls; it now tests finfo_buffer().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
ImagickFactory::create() passed its argument to the Imagick constructor, which
reads the image immediately, and only then set svg:sanitize, svg:embed and
svg:decode. Anything loaded that way was therefore decoded with none of the
hardening the options exist to provide - so the one form that takes a path was
the only unhardened way through the factory, while the comment added to
Office::getThumbnail() earlier in this pull request presents it as the hardened
route.

The instance is now always constructed empty, the options applied, and the file
read afterwards. Verified in a container that this is behaviour preserving for
the only caller that passes a path: Office's "PDF:<path>[0]" produces identical
geometry and identical output bytes either way, a foreign image pinned to the PDF
coder is still rejected, and readImage() does not pin the wand's output format
the way setFormat() does - so setImageFormat() alone remains sufficient there.

The parameter is narrowed from mixed to ?string at the same time. Imagick's
constructor also accepts an array of paths, but no caller has ever passed one,
and carrying that form would mean a second, untested branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
The gate can only be as good as the detection behind it, and that needs either
ext-fileinfo or the "file" binary. Neither is actually required to run ownCloud:
OC_Util::checkServer() does not list ext-fileinfo among its hard dependencies,
and OC_Util::fileInfoLoaded() only raises a recommendation in the admin panel.

On an install with neither, every payload is reported as
application/octet-stream and the gate admits all of them, leaving the
per-provider coder pin as the only remaining layer.

Recorded rather than closed: failing closed here would cost every bitmap preview
on a configuration ownCloud supports, and the condition pre-dates this check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
SanitizeTest carried a class level "@requires extension imagick" that contradicts
the comment directly below it, which argues against guarding these cases so that
a reduced build cannot silently skip the regression assertions. No case in the
class reaches Imagick at all: the media type gate rejects every payload before
ImagickFactory::create() is called. All ten cases still run and pass without it.

Also drops TestCase::assertImage() and tests/lib/Preview/white-32x32.png. Both
arrived together with an earlier change, and their only caller was the
SanitizeTest assertion this pull request replaced, so nothing references either
any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
Records the two preview hardening fixes from this review round, and states the
residual the coder pin deliberately leaves open: which provider serves a preview
is steerable by the request, so a file can still be routed to the PDF coder
whatever its content. That is not a change in behaviour - content sniffing
reached the same coder before the pin - and a distribution's ImageMagick policy
is the process-wide control for 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>
detectString()'s branch for installs without ext-fileinfo writes the content to a
temporary file so that detect() can inspect it. It did not check either step:
TempManager::getTemporaryFile() returns false when its directory is not writable,
and fopen(false, ...) raises a ValueError on PHP 8. Being an \Error rather than an
\Exception, that escapes the handler in Bitmap::getThumbnail(), so the request
failed with a server error instead of falling back to a media type icon.

Nothing in core called detectString() before the media type gate added in this
pull request, so this branch was not previously reachable from a request. It now
runs on every bitmap preview on such an install, which is what makes it worth
closing alongside the finfo_open() guard above it - the same failure mode, on the
sibling path.

Not covered by a test: reaching this branch needs a build without ext-fileinfo,
which cannot be simulated from within the test suite.

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

The comment argued that pinning EPS covers .ps because EPS and PS share
ReadPSImage(). They do share it, but not unconditionally - it branches on the
requested coder - so the comment asserted an equivalence it did not establish.

Replaced with what was actually measured on the shipped image: plain PostScript
and EPSF-tagged content, both declaring a bounding box smaller than the page,
render to identical geometry read unpinned, pinned EPS and pinned PS. The pin
therefore does not change what a .ps file previews as, which is the property the
comment needed to support.

No functional change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
detect() falls back to the "file" binary when nothing else identified the content,
and OC_Helper::canExecute() only establishes that the binary exists - not that we
are allowed to start a process. popen() is a common disable_functions entry, and
on PHP 8 a disabled function is undefined, so calling it raises an \Error. popen()
is also documented to return false when the process cannot be forked, which makes
the fgets() and pclose() that follow raise a TypeError.

All of those are \Error rather than \Exception, so they escape the handler in
Bitmap::getThumbnail() and fail the request instead of degrading to a media type
icon - the same hole the previous commit closed two frames up, on the path that
commit routes into. An install without ext-fileinfo reaches this code on every
bitmap preview, so the guard there was incomplete without this one.

Verified that the guard is transparent when popen is available: the branch still
returns the type the "file" binary reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
The rewritten comment rests the pin's safety on a measurement, so it should say
where that measurement came from: ImageMagick 6.9.11-60 with Ghostscript 9.55.0,
as shipped in owncloudci/php:8.3. Versions read from the build rather than
recalled.

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 force-pushed the fix/oc10-164-bitmap-preview-arbitrary-file-write branch from 92698ee to cb4196a Compare September 24, 2026 09:31
@oc-tmueller

Copy link
Copy Markdown
Contributor Author

Addressed below. I rebased onto master first, which resolves finding 3 on its own.

Blocking findings

1 + 2 — the gate failing open, and finfo_open() unchecked. Both fixed in
Detection::detectString() rather than in isDangerousToDecode(). detectString() is
documented as returning a string and isDangerousToDecode() is its only production
caller, so fixing the contract at source covers both conditions and leaves nothing for the
gate to re-check: a false from finfo_open() and a non-string or empty finfo_buffer()
both fall back to application/octet-stream. The function_exists() guard also tested
finfo_file(), which that branch never calls — it now tests finfo_buffer().

I confirmed the mechanism before fixing rather than from the signature:
finfo_buffer(false, 'x') is TypeError: Argument #1 ($finfo) must be of type finfo, false given on 8.3, and a failing finfo_open() raises three warnings first. The regression test
drives it through MAGIC, which libmagic reads at finfo_open() time, and asserts that
precondition so it fails loudly rather than going quiet on a build that ignores MAGIC.

Auditing that function turned up two more instances of the same hazard on the path your
finding routes into, both fixed alongside:

  • the no-fileinfo branch wrote a temp file without checking getTemporaryFile() — which
    returns false on an unwritable temp dir — or fopen(). fopen(false, …) is a
    ValueError on PHP 8.
  • detect() then falls back to the file binary, and OC_Helper::canExecute() only
    establishes that the binary exists, not that we may start a process. popen is a common
    disable_functions entry, and on PHP 8 a disabled function is undefined, so the call
    raises Error: Call to undefined function popen(); popen() returning false on a failed
    fork likewise makes fgets()/pclose() raise TypeError.

Both are \Error rather than \Exception, so both were 500s rather than a media-type icon —
the same shape as your finding 2, two and three frames down. Worth noting because these only
matter now: nothing in core called detectString() before this PR, so that whole branch was
unreachable from a request until the gate started using it.

The part I have deliberately not closed is the no-ext-fileinfo fail-open you fold into
finding 1. It is documented at isDangerousToDecode() instead. ext-fileinfo is required
by composer.json, but that binds only at composer-install time and release tarballs ship
a pre-built lib/composer: OC_Util::checkServer()'s hard dependency list does not include
it, and OC_Util::fileInfoLoaded() only raises an admin-panel recommendation ("we strongly
recommend"). Such an install boots and is supported, so failing closed would cost it every
bitmap preview — tif, psd, sgi, heic, ai, pdf, eps. Since the coder pin still holds there,
as your own TIFF/MVG trace shows, I recorded it as a bounded limitation rather than trading
those previews away. Glad to raise it as its own issue if you would rather see it tracked.

Note that findings 1 and 2 cannot arise on that configuration anyway — both live in the
finfo branch, which is not taken when fileinfo is absent.

3 — SVG::getThumbnail()'s unguarded fopen(). Fixed independently in #41855, which
landed on master while this was in review, in exactly the shape you describe:
!is_resource() plus fclose() in a finally. The rebase picked it up, so nothing is left
to do here, and I checked that both sides survived it intact.

Non-blocking, taken

  • ImagickFactory::create($path). Fixed — constructed empty, options applied, then
    readImage(). Verified in a container rather than reasoned about, since the constructor
    pin is load-bearing for Office: PDF:<path>[0] yields identical geometry and identical
    output bytes either way, a PNG pinned to the PDF coder is still rejected either way, and
    readImage() does not pin the wand's output format the way setFormat() does — so
    Office.php still needs only setImageFormat('jpg') and its comment stays accurate. The
    parameter is narrowed from mixed to ?string; the array form Imagick accepts has never
    had a caller.
  • SanitizeTest's @requires extension imagick. Removed — it does contradict the
    comment below it, and all ten cases run without it, since the gate rejects every payload
    before ImagickFactory::create().
  • Orphaned assertImage() and white-32x32.png. Both deleted.
  • The residual changelog sentence. Added to changelog/unreleased/41834.

Non-blocking, not taken

  • The log marker separating a blocked payload from a corrupt TIFF, and truncating the
    sniff to 8 KB
    . Both are reasonable; both are deferred rather than folded into a security
    fix, as a maintainer call.
  • policy.xml as a documented deployment requirement. The image side is fix: harden ImageMagick policy and install rsvg-convert (OC10-164) owncloud-docker/php#309;
    the documentation belongs in docs rather than in this PR.
  • Scope, AGENTS.md:95,97. Agreed, and taking your own read that splitting is not worth
    it given the shared test suite.

The two you could not confirm

1 — can setFormat() return false without throwing? No. I probed every coder absent
from owncloudci/php:8.3 — JXL, DJVU, FPX, EXR, FLIF, SVG, HEIF, plus a nonsense name — and
each raises ImagickException: Unable to set format; registered coders return true. So the
unchecked return cannot silently degrade to the content sniffing the pin prevents: the
fail-closed behaviour you wanted already happens via the exception, which
Bitmap::getResizedPreview() deliberately does not catch. The one edge this surfaced is that
setFormat('') returns true — unreachable here, since all eight getImagickFormat()
implementations return non-empty constants and the method is typed string.

2 — requireDecodableFixture() vs requireDecodableFixtureFile(). The premise does not
hold at the current head: both catch \Exception — not \ImagickException, not \Throwable
(CoderPinningTest.php:97, Provider.php:109). Each carries a comment on exactly your
point: \Exception because PHPUnit converts warning-severity delegate and policy conditions
into PHPUnit\Framework\Error\Warning, which reaches \Exception via
PHPUnit\Framework\Exception; and not \Throwable, so an \Error still fails rather than
becoming a green skip. The PDF/EPS/AI cases therefore skip rather than hard-fail on a
policy-restricted distro, which is the outcome you were after.

One further thing your review prompted me to check, and then correct: the Postscript
provider justified pinning EPS on it sharing ReadPSImage() with PS. That sharing is
conditional, so rather than leave an unverified internals claim I replaced it with a measured
one — plain PostScript and EPSF-tagged content, both declaring a bounding box smaller than
the page, render to identical geometry read unpinned, pinned EPS and pinned PS.

Locally re-run on owncloudci/php:8.3: tests/lib/Preview/ 86 tests with only the
pre-existing Movie/Office/SVG skips, tests/lib/Files/Type/ 12 tests, php-cs-fixer clean,
and php -l under 7.4 so #41828 can cherry-pick without syntax changes.

@kw-fscheuer kw-fscheuer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at cb4196ac, read-only against the branch and against ImageMagick6 upstream.
Approving. OC10-164 is closed, and all three of my blocking findings are fixed in code rather
than only answered.

What I checked rather than took on trust

The reported vector is dead twice over. Bitmap::getResizedPreview() no longer
sanitizes-then-falls-back — the raw-bytes path is gone — and text/SVG/XML/MVG-sniffed content
is rejected before ImagickFactory::create() is ever reached:

if ($this->isDangerousToDecode($content)) {
throw new \RuntimeException('Refusing to decode text-based content for a bitmap preview');
}

Independently of that gate, the payload is pinned to the provider's own coder:

# the content-sniffing this pin exists to prevent.
$bp->setFormat($this->getImagickFormat($mimeType));
$bp->readImageBlob($content);

SanitizeTest now asserts false for a malformed SVG carrying an MSL xlink:href, a raw MVG
script and a well-formed SVG, and assertPayloadIsDeniedByTheMimeGate() asserts the
precondition, so a build whose libmagic classifies the payload differently fails loudly instead
of going quiet. The headerless-PostScript case is the right addition — it is the one payload the
gate has to catch unaided, because for PDF and Postscript the pin is not a second layer.

Findings 1 and 2 are fixed here:

// missing bitmap preview into a 500 in OC\Preview\Bitmap::getThumbnail().
$finfo = @\finfo_open(FILEINFO_MIME);
if ($finfo === false) {
return 'application/octet-stream';
}
// finfo_buffer() is typed string|false. An unusable return has to become the
// fallback rather than reach a caller, because this method is documented as
// returning a string and OC\Preview\Bitmap compares the result against a
// deny-list of media types it refuses to decode - '' matches no entry there
// and would admit the very content the list exists to reject.
$mimeType = \finfo_buffer($finfo, $data);
return \is_string($mimeType) && $mimeType !== '' ? $mimeType : 'application/octet-stream';

Your placement is better than mine. I said detectString() had other callers expecting the
current behaviour; it does not — the interface, the new gate and two test files are the whole
set — so fixing the contract at source was the right call, not the more expensive one.

Finding 3 arrived with the rebase from #41855, and both the is_resource() guard and the
fclose() in finally are present:

$stream = $file->fopen('r');
if (!\is_resource($stream)) {
// stream_get_contents() below would raise a TypeError, which is an \Error and
// so would escape the handler underneath rather than degrade to no preview.
// Not a === false check: View::fopen() returns null for a path
// isForbiddenFileOrDir() rejects and for one Filesystem::resolvePath() finds
// no storage for, and that reaches stream_get_contents() just as badly.
\OCP\Util::writeLog('core', 'Could not open ' . $file->getPath() . ' for a preview', \OCP\Util::ERROR);
return false;
}
try {
$content = \stream_get_contents($stream);
} finally {
// the read itself can throw from the wrapper stack - the encryption module
// does, on a corrupt or missing key - and that is caught below, so without
// this the descriptor and the view's shared lock would both be held on
\fclose($stream);
}

Three things I checked because they would have been quiet failures: all eight Bitmap
subclasses implement getImagickFormat(), so there is no abstract-method gap; both
ImagickFactory::create() call sites pass a string or nothing, so narrowing to ?string breaks
nothing; and setFormat('png') next to setImageFormat('png') is load-bearing for the
(string)$bp cast at Bitmap.php:79, which testPinnedDecodeReturnsPngAndNotThePinnedInputFormat()
now guards. After this PR no new Imagick survives outside ImagickFactory, which is what makes
this a fix for the class rather than for the instance.

One new finding — not a blocker, and I am not holding the merge for it

The changelog tells operators that "the PDF, PostScript and EPS coders are the ones a
distribution's ImageMagick policy denies by default":

they are. This is not a change - content sniffing reached the same coder before -
and the PDF, PostScript and EPS coders are the ones a distribution's ImageMagick
policy denies by default. Deployments that enable those coders should keep that
policy as the control, because it applies process-wide rather than per provider.

That list is one short, and this PR's own tests say so: CoderPinningTest excludes PDF,
Postscript and Illustrator from providesForeignProviders() as the Ghostscript-backed
providers, and notes that the AI coder "is a Ghostscript alias for the PDF one".

For policy purposes it is not an alias. coders/pdf.c registers AI as its own entry, with
ReadPDFImage as the decoder and magick_module set to "PDF":

https://github.com/ImageMagick/ImageMagick6/blob/45439b3fd7ea3e12bfbb94269efe8af51cf78367/coders/pdf.c#L796-L807

and ReadImage() authorizes the coder domain against magick_info->name, passing the module
only to the module domain:

https://github.com/ImageMagick/ImageMagick6/blob/45439b3fd7ea3e12bfbb94269efe8af51cf78367/magick/constitute.c#L565-L568

https://github.com/ImageMagick/ImageMagick6/blob/45439b3fd7ea3e12bfbb94269efe8af51cf78367/magick/constitute.c#L423-L432

So <policy domain="coder" rights="none" pattern="PDF"/> — the form the stock Debian/Ubuntu
policy uses — does not cover a read pinned to AI. A domain="module" deny would.

The consequence is narrow but it points the wrong way: read unpinned, .ai content carrying
%!PS-Adobe sniffed as PS and was denied by that policy; pinned, it decodes as AI and is not.
It needs OC\Preview\Illustrator to be explicitly enabled, and that provider is in neither the
hardcoded default (PreviewManager.php:266-281) nor config.sample.php, so this is a hardening
regression for an opt-in provider rather than anything reachable on a stock install. That is why
this is a note and not a change request.

Adding AI to that changelog sentence covers the operator side. Whether the pin should also be
reflected in a recommended policy.xml is a docs question rather than one for this PR. Tracked
on our side.

Minor, take or leave

  • Bitmap.php:59 still reads "Creates \Imagick object from bitmap or vector file". Vector
    content is precisely what the code below it now refuses.
  • Bitmap.php:172-174 calls the coder pin "the only remaining layer" where detection is
    unavailable. True for six providers; for PDF and Postscript it is not a layer at all, as
    SanitizeTest's own comment on the headerless-PostScript payload sets out.
  • Detection.php:277 says "suppressed like finfo_file() below". That call is above, at
    :224-227, and what guards it against a false finfo_open() is the and-chain
    short-circuit, not the @.
  • SVGTest's === 0 is the right change for the wrong reason: queryFormats() globs, and
    'SVG' carries no wildcard, so it never matched SVGZ or MSVG.

None of that changes the verdict. The two "could not confirm" items are both answered by
measurement, which is the right way to have closed them. Ship it.

@oc-tmueller
oc-tmueller merged commit d7305d9 into master Sep 24, 2026
31 checks passed
@oc-tmueller
oc-tmueller deleted the fix/oc10-164-bitmap-preview-arbitrary-file-write branch September 24, 2026 11:09
oc-tmueller added a commit that referenced this pull request Sep 24, 2026
…iews [10.16]

Backport of #41827. That PR carries the per-provider Imagick coder pin from
#41834 as well, because #41834 was merged into its branch, so the squash commit
on master contains both changes and so does this backport.

Bitmap::getResizedPreview() sanitized SVG content before handing it to
Imagick::readImageBlob(), but fell back to the ORIGINAL, unsanitized bytes
whenever the sanitizer returned an empty string - which it does for any content
libxml cannot parse, not only for genuinely malformed SVG. A malformed SVG, or
any non-XML payload such as a raw MVG script, therefore reached ImageMagick
unsanitized, where an <image xlink:href="MSL:..."> or an MVG "fill 'url(...)'"
primitive can execute an MSL script that reads and writes arbitrary files as the
web user. getResizedPreview() now rejects content whose libmagic-detected media
type is text/*, image/svg*, application/xml or image/x-mvg before calling into
Imagick at all, and each provider pins the exact coder it serves instead of
letting ImageMagick re-derive the format from the content.

Adapted for PHP 7.4, the only version 10.16 supports. Master justifies several of
these guards by PHP 8 raising an \Error that escapes catch (\Exception); on 7.4
the same calls only warn, so every such claim was re-derived on the target
runtime rather than carried over:

 - finfo_buffer(false, ...) warns and returns false on 7.4, and detectString()
   returned that false to the new deny-list, where it collapses to '' and matches
   no entry. Here the missing guard admitted the content the list exists to
   reject; it is on PHP 8 that it turns a missing preview into a 500.
 - the same holds for popen()/fgets()/pclose() in detect() and for
   fopen(false, ...) in the branch taken without ext-fileinfo.
 - the (string) cast on $file->getMimeType() is required on 7.4 too: passing null
   to a userland string-typed parameter is a TypeError on 7.4 as well. Measured,
   because the surrounding guards are not.

10.16 keeps its own "$stream === false" check and $image->loadFromData($bp); the
is_resource() form and the (string) cast on that call are master-only, from
#41855 and #41449, and the three-way merge preserved both correctly.

The measurements the coder-pin comments rest on were re-taken on
owncloudci/php:7.4, this branch's own CI image. It ships the same ImageMagick
6.9.11-60 and Ghostscript 9.55.0 as the 8.3 image and every figure reproduced:
plain PostScript and EPSF-branded content both render 612x792 unpinned, pinned
EPS and pinned PS alike; a %!PS-Adobe payload read unpinned reaches the PS coder
at 612x792 while the TTF pin gives 800x480. That image also registers no SVG
coder and no HEIF coder distinct from HEIC - which is precisely what PDFTest's
old SVG-based guard got wrong and what Heic's single HEIC pin is there for.

Verified in owncloudci/php:7.4: tests/lib/Preview/ plus
tests/lib/Files/Type/DetectionTest.php at 68 tests / 207 assertions, against 41
tests / 123 assertions before, with 12 environment skips (Movie, Office, SVG).
The PDF cases run here for the first time. php -l clean under 7.4 on all 21
changed files.

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

4 participants