Skip to content

fix: allow sanitized SVG image uploads - #1973

Open
mconsoir wants to merge 1 commit into
hackmdio:developfrom
mconsoir:fix/svg-upload
Open

mconsoir wants to merge 1 commit into
hackmdio:developfrom
mconsoir:fix/svg-upload

Conversation

@mconsoir

Copy link
Copy Markdown

Summary

SVG uploads have been rejected with 403 Forbidden since 2.3.0, although image/svg+xml is listed in allowedUploadMimeTypes for all upload backends except imgur. This PR makes SVG uploads work again and sanitizes them so they cannot be used for stored XSS.

Problem

checkImageValid() in lib/imageRouter/index.js (added in 7a88f9d, "Check upload image mime type") detects the file type from its first bytes with image-type. SVG is plain XML and has no magic number, so imageType() always returns null and every SVG upload fails.

Simply skipping the check is not an option: an uploaded SVG is served from the CodiMD origin, and opening /uploads/<file>.svg directly would execute any embedded script in the context of the CodiMD session.

Changes

  • lib/imageRouter/sanitizeSvg.js (new): sanitizes SVG source with DOMPurify (SVG and SVG filter profiles) on a jsdom window and re-serializes the single <svg> root element as standalone XML.
    • Removes <script>, event handlers, javascript: URLs, <foreignObject>, animation-based attribute injection and everything else outside the SVG profile.
    • Parses as HTML, so DTDs and XML entities are never expanded (no XXE or entity expansion).
    • Serializing with XMLSerializer always yields well-formed XML with the SVG namespace, which <img> requires (e.g. for files without xmlns or with HTML entities like &nbsp;).
    • Returns null unless the input consists of exactly one <svg> root element.
  • lib/imageRouter/index.js: files with an .svg extension skip the magic number check. They are sanitized instead, and the temporary upload is replaced with the sanitized version before it reaches the upload backend, so this works for filesystem, S3, MinIO, Azure and Lutim. Uploads larger than 2 MiB are rejected. The handling of all other formats is unchanged.
  • app.js: /uploads responses get X-Content-Type-Options: nosniff. SVG files additionally get Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox as a second line of defense.
  • Dependencies: dompurify (already in the lockfile as a transitive dependency) and jsdom@20, which still supports Node 14–16. Both are server-side only and do not affect the frontend bundle.

Testing

  • New unit tests in test/sanitizeSvg.js. They parse the output as XML, the way browsers load an SVG image.
  • npm run lint and the full mocha suite pass on Node 16.20 (137 passing).
  • Manual end-to-end test against POST /uploadimage with the filesystem backend:
    • An SVG with onload, <script>, onclick, an xlink:href="javascript:…" link and <foreignObject><iframe> is stored without any of them, and the shapes and text remain.
    • An SVG without xmlns and with &nbsp; is stored as valid XML with the namespace added.
    • HTML renamed to .svg is rejected with 403, and so is a 3 MB SVG.
    • GET /uploads/<file>.svg returns Content-Type: image/svg+xml, X-Content-Type-Options: nosniff and the CSP header.

Notes

  • Headers on other backends: the CSP/nosniff headers only apply when files are served by CodiMD itself (imageUploadType: filesystem). With S3, MinIO or Azure, the sanitizing still applies, but the headers would have to be configured on the bucket or CDN.
  • imgur: behavior is unchanged. imgur does not accept SVG, and SVG is not in its allowedUploadMimeTypes.
  • <use>: DOMPurify removes <use> elements by default, even with local references. Sprite-based icons therefore lose those parts. I kept the stricter default; allowing local #id references only would be possible with a DOMPurify hook if you prefer that.
  • Size limit: the 2 MiB SVG limit is a constant.

SVG is listed in allowedUploadMimeTypes for every upload backend except
imgur, but since the magic number check from 7a88f9d ("Check upload
image mime type", released in 2.3.0) every SVG upload is rejected with
403: image-type cannot detect SVG because it is plain XML without a
magic number.

Handle SVG uploads separately: sanitize them with DOMPurify (SVG
profile) on a jsdom window and replace the temporary upload with the
sanitized, re-serialized XML before it is handed to the upload backend.
Files that do not consist of a single <svg> root element, or that are
larger than 2 MiB, are rejected.

As defense in depth, files served from /uploads get
X-Content-Type-Options: nosniff, and SVG files additionally get a
restrictive Content-Security-Policy with sandbox, so an SVG opened
directly can neither run scripts nor load external resources.

Signed-off-by: Markus Consoir <m.consoir@fz-juelich.de>

This branch has not been deployed

No deployments
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.

1 participant