fix: prevent arbitrary file write via unsanitized SVG/MVG bitmap previews (OC10-164) - #41827
oc-tmueller wants to merge 4 commits into
Conversation
…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>
|
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>
|
Follow-up suggestion rather than a change request on this PR — the fail-open fix here looks right to What this approach leaves open by design.
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 $bp->readImageBlob($content, 'SGI:blob');That is a hard pin, not a hint. Scope is small. There are three Imagick read paths in the whole repo — Two things worth watching:
Suggested tests in The same change would need to follow on the 10.x line afterwards — no need to touch #41828 until the |
…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>
Summary
Bitmap::getResizedPreview()sanitized SVG content before handing it toImagick::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 MVGfill '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 istext/*,image/svg+xml,application/xml, orimage/x-mvgbefore ever calling into Imagick (isDangerousToDecode()), and goes throughImagickFactory::create()so thesvg:sanitize/svg:embed/svg:decodehardening options apply here too.SVG::sanitizeSVGContent()'s return type changes fromstringto?string, returningnullwhen 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 onnullinstead of silently passing empty content to Imagick.SanitizeTestis updated: SVG content fed to a Bitmap provider (PDF, Font) now must returnfalseinstead of a rendered PNG, since Bitmap providers no longer attempt to handle SVG-shaped content at all - that's the dedicatedSVGprovider's job. Added regression cases for a malformed SVG with an MSLxlink:href, a raw MVG script, and a well-formed SVG - all must returnfalsefrom a Bitmap provider.Note:
image/x-mvgis included in the deny-list because libmagic (file-5.41, standard Ubuntu magic database) classifies a raw MVG script asimage/x-mvg, not as anytext/*/xml type - without it, the MVG regression case would not be blocked.Not in scope for this PR (flagging separately):
Bitmap::getThumbnail()leaks$streamwhengetResizedPreview()throws - it returns at line 54 before thefclose()at line 57. That's a resource leak, not a security defect, and deserves its own focused PR.Test plan
make test-php-stylemake test-php-unit TEST_PHP_SUITE=tests/lib/Preview/- 58 tests, 8 skipped (unrelated missing Movie/Office providers), 0 failuresSanitizeTestcases failed against unfixed code (the well-formed/sanitizable/malformed SVG cases all reached Imagick and rendered); confirmed GREEN aftertests/lib/Preview/PDFTest.php(testimage.pdf) andtests/lib/Preview/BitmapTest.php(testimage.eps) still produce previews