Skip to content

Sanitize uploaded SVGs - #456

Merged
absumo merged 2 commits into
masterfrom
571-stored-xss-via-svg
Oct 15, 2026
Merged

absumo merged 2 commits into
masterfrom
571-stored-xss-via-svg

Conversation

@absumo

@absumo absumo commented Oct 12, 2026 •

Copy link
Copy Markdown

Summary by Sourcery

Protect uploaded SVGs from stored XSS by sanitizing active content before files are stored or served.

New Features:

  • Sanitize SVG content during media and page uploads to remove scripts, event handlers, and other active or unsafe constructs while preserving valid artwork.

Bug Fixes:

  • Prevent stored cross-site scripting through SVG uploads, including SVG payloads disguised with other image extensions.
  • Reject and clean up uploads that cannot be safely sanitized.

Enhancements:

  • Add a reusable SVG sanitizer service with extension, MIME-type, and content detection support.

Build:

  • Add the DOM, Fileinfo, and Libxml PHP extensions and the SVG DOM sanitizer dependency.

Tests:

  • Add unit and integration coverage for dangerous SVG content, disguised uploads, case-insensitive extensions, rejected files, and preservation of legitimate SVG data.

@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR mitigates stored XSS from SVG uploads by introducing a DOM-based sanitizer, invoking it across the Pages and Media Library upload flows before persistent storage, cleaning up failed temporary uploads, and adding unit and integration regression tests.

Sequence diagram for SVG upload sanitization

sequenceDiagram
    participant Upload as Upload flow
    participant Sanitizer as SvgSanitizer
    participant DOM as DOMSanitizer
    participant Storage as File storage

    Upload->>Sanitizer: isSvgExtension(extension)
    alt SVG upload
        Upload->>Sanitizer: sanitize(content)
        Sanitizer->>DOM: sanitize(content)
        DOM-->>Sanitizer: sanitized SVG
        Sanitizer-->>Upload: sanitized content
        Upload->>Storage: write sanitized SVG
    else Non-SVG upload
        Upload->>Storage: write original file
    end
Loading

Sequence diagram for failed Media Library SVG sanitization

sequenceDiagram
    participant Upload as MediaItemUpload
    participant Sanitizer as SvgSanitizer
    participant Storage as Temporary storage
    participant Client

    Upload->>Storage: upload to temporary path
    Upload->>Sanitizer: sanitizeFile(path)
    alt Sanitization fails
        Sanitizer-->>Upload: RuntimeException
        Upload->>Storage: deleteFolder(uuid)
        Upload-->>Client: sendResponseForResult(success=false)
    else Sanitization succeeds
        Sanitizer-->>Upload: sanitized file
        Upload->>Storage: rename to final path
    end
Loading

File-Level Changes

Change Details Files
Centralize SVG active-content removal in a reusable sanitizer backed by rhukster/dom-sanitizer.
  • Detect SVGs case-insensitively by extension.
  • Sanitize SVG strings or rewrite uploaded files in place.
  • Reject empty, unreadable, unparsable, or unwritable SVGs with runtime errors.
src/ForkCMS/Utility/Svg/SvgSanitizer.php
src/ForkCMS/Utility/Tests/Svg/SvgSanitizerTest.php
composer.json
app/config/config.yml
Apply SVG sanitization to both backend upload paths before files become persistently accessible.
  • Sanitize Pages uploads before collision handling and storage.
  • Sanitize Media Library temporary uploads before renaming them to their final path.
  • Delete failed Media Library uploads and return an unsuccessful response.
  • Remove leftover upload debug dumps.
src/Backend/Modules/Pages/Ajax/UploadFile.php
src/Backend/Modules/MediaLibrary/Ajax/MediaItemUpload.php
Add regression coverage for stored-XSS payloads and upload behavior.
  • Verify scripts, event handlers, JavaScript URLs, foreignObject content, and external references are removed while valid drawing data remains.
  • Exercise case-insensitive SVG extensions, empty and missing files, and in-place rewriting.
  • Test end-to-end Pages uploads and preserve rejection of non-image extensions.
src/ForkCMS/Utility/Tests/Svg/SvgSanitizerTest.php
src/Backend/Modules/Pages/Tests/Ajax/UploadFileTest.php

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 4 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/Backend/Modules/Pages/Ajax/UploadFile.php" line_range="131-137" />
<code_context>
         $fileName = $baseName . '.' . $extension;

+        // an svg is an active document, so strip the scripts and event handlers before it is stored
+        if (SvgSanitizer::isSvgExtension($extension)) {
+            try {
+                $content = (new SvgSanitizer())->sanitize($content);
+            } catch (RuntimeException $runtimeException) {
+                throw new Exception($runtimeException->getMessage());
+            }
+        }
+
</code_context>
<issue_to_address>
**CKFinder uploads retain active SVGs**

When an SVG is uploaded through CKFinder, `FileUpload::execute()` writes the uploaded stream without calling `SvgSanitizer`, leaving active SVG content available from the public files directory and allowing stored XSS.

Route CKFinder uploads through SVG sanitization before storing or publishing them.

Also at `src/Backend/Modules/MediaLibrary/Ajax/MediaItemUpload.php:103`, `src/ForkCMS/Utility/Svg/SvgSanitizer.php:15`.
</issue_to_address>

### Comment 2
<location path="src/Backend/Modules/MediaLibrary/Ajax/MediaItemUpload.php" line_range="103" />
<code_context>
+            // an svg is an active document, so strip the scripts and event handlers while the file still lives
+            // under its unguessable temporary name, it may never be reachable under its final name with the
+            // payload intact
+            $this->sanitizeSvg($tempUploadPath, $result);
+
             // Move file to correct folder
</code_context>
<issue_to_address>
**Unsanitized SVGs are briefly reachable**

When a request fetches the temporary URL before sanitization completes, `UploadHandler` writes the original SVG beneath the public media root before `sanitizeSvg()` runs, and the request-supplied UUID makes the temporary path reachable; the server serves the active SVG and triggers stored XSS.

Keep temporary uploads outside the public root and publish them only after sanitization completes.

Also at `src/Backend/Modules/MediaLibrary/Ajax/MediaItemUpload.php:104`.
</issue_to_address>

### Comment 3
<location path="src/Backend/Modules/MediaLibrary/Ajax/MediaItemUpload.php" line_range="104" />
<code_context>
         arguments:
             - "%site.path_www%"

+    ForkCMS\Utility\Svg\SvgSanitizer:
+        public: true
</code_context>
<issue_to_address>
**Concurrent upload bypasses sanitization**

When two requests use the same upload UUID and filename, and one replaces the temporary file after the other sanitizes it but before it renames it, `sanitizeSvg()` sanitizes the shared temporary path, but another request can replace that file before `rename()` publishes it. The unsanitized replacement then becomes publicly accessible as an SVG.

Serialize finalization per upload UUID, or sanitize and atomically publish the exact file instance that was validated.
</issue_to_address>

### Comment 4
<location path="src/ForkCMS/Utility/Svg/SvgSanitizer.php" line_range="55" />
<code_context>
+            throw new RuntimeException('Could not read the SVG file "' . $path . '".');
+        }
+
+        if (@file_put_contents($path, $this->sanitize($content)) === false) {
+            throw new RuntimeException('Could not write the sanitized SVG file "' . $path . '".');
+        }
</code_context>
<issue_to_address>
**Truncated SVGs are accepted**

When the filesystem accepts only part of the sanitized content, `file_put_contents()` can return a positive short count, but `sanitizeFile()` rejects only `false`; it therefore accepts the truncated SVG and the upload proceeds with a corrupted asset.

Check that the number of bytes written equals the sanitized content length, and throw an exception otherwise.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 4 findings to address first, and if the sanitizer fails to remove active SVG content, an uploaded file can remain available from the site origin and execute script when opened, exposing visitors to stored XSS. Reverting would stop future sanitization but would not remove or repair any malicious files already stored.

Blocking findings: src/Backend/Modules/Pages/Ajax/UploadFile.php:137, src/Backend/Modules/MediaLibrary/Ajax/MediaItemUpload.php:103, src/Backend/Modules/MediaLibrary/Ajax/MediaItemUpload.php:104, src/ForkCMS/Utility/Svg/SvgSanitizer.php:55


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread src/Backend/Modules/Pages/Ajax/UploadFile.php Outdated
Comment thread src/Backend/Modules/MediaLibrary/Ajax/MediaItemUpload.php
Comment thread src/Backend/Modules/MediaLibrary/Ajax/MediaItemUpload.php
Comment thread src/ForkCMS/Utility/Svg/SvgSanitizer.php
Comment thread src/Backend/Modules/MediaLibrary/Ajax/MediaItemUpload.php
Comment thread src/Backend/Modules/Pages/Ajax/UploadFile.php Outdated
Comment thread src/ForkCMS/Utility/Svg/SvgSanitizer.php
Comment thread composer.json
"twig/inky-extra": "^3.23",
"symfony/uid": "^5.4",
"symfony/cache": "^5.4"
"symfony/cache": "^5.4",

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.

Deze zal dan ook geupdate moeten worden in symfony upgrade

jonasdekeukelaere added a commit that referenced this pull request Oct 14, 2026
Security: fix open advisories not covered by #455 and #456
@absumo
absumo merged commit a4c4b92 into master Oct 15, 2026
8 of 12 checks passed
@jonasdekeukelaere
jonasdekeukelaere deleted the 571-stored-xss-via-svg branch October 16, 2026 07:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants