Skip to content

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

Open
oc-tmueller wants to merge 4 commits into
masterfrom
fix/oc10-164-bitmap-preview-arbitrary-file-write
Open

oc-tmueller wants to merge 4 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

…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>
@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.

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>
@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.

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants