SECURITY: Add content-disposition: attachment for SVG uploads

SECURITY: Add content-disposition: attachment for SVG uploads

  • strip out the href and xlink:href attributes from use element that are not anchors in svgs which can be used for XSS
  • adding the content-disposition: attachment ensures that uploaded SVGs cannot be opened and executed using the XSS exploit. svgs embedded using an img tag do not suffer from the same exploit
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index b1be8f1..366c065 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -252,7 +252,7 @@ class UploadsController < ApplicationController
       content_type: MiniMime.lookup_by_filename(upload.original_filename)&.content_type
     }
 
-    if !FileHelper.is_supported_image?(upload.original_filename)
+    if !FileHelper.is_inline_image?(upload.original_filename)
       opts[:disposition] = "attachment"
     elsif params[:inline]
       opts[:disposition] = "inline"
diff --git a/config/nginx.sample.conf b/config/nginx.sample.conf
index 310e8c8..18e16a2 100644
--- a/config/nginx.sample.conf
+++ b/config/nginx.sample.conf
@@ -185,9 +185,12 @@ server {
           try_files $uri =404;
       }
       # this allows us to bypass rails
-      location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|svg|ico|webp)$ {
+      location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ {
           try_files $uri =404;
       }
+      # SVG needs an extra header attached
+      location ~* \.(svg)$ {
+      }
       # thumbnails & optimized images
       location ~ /_?optimized/ {
           try_files $uri =404;
diff --git a/lib/file_helper.rb b/lib/file_helper.rb
index cbf11ee..162de9a 100644
--- a/lib/file_helper.rb
+++ b/lib/file_helper.rb
@@ -17,6 +17,10 @@ class FileHelper
     (filename =~ supported_images_regexp).present?
   end
 
+  def self.is_inline_image?(filename)
+    (filename =~ inline_images_regexp).present?
+  end
+
   def self.is_supported_media?(filename)
     (filename =~ supported_media_regexp).present?
   end
@@ -136,6 +140,11 @@ class FileHelper
     @@supported_images ||= Set.new %w{jpg jpeg png gif svg ico webp}
   end
 
+  def self.inline_images
+    # SVG cannot safely be shown as a document
+    @@inline_images ||= supported_images - %w{svg}
+  end
+
   def self.supported_audio
     @@supported_audio ||= Set.new %w{mp3 ogg wav m4a}
   end
@@ -148,6 +157,10 @@ class FileHelper
     @@supported_images_regexp ||= /\.(#{supported_images.to_a.join("|")})$/i
   end
 
+  def self.inline_images_regexp
+    @@inline_images_regexp ||= /\.(#{inline_images.to_a.join("|")})$/i
+  end
+
   def self.supported_media_regexp
     media = supported_images | supported_audio | supported_video
     @@supported_media_regexp ||= /\.(#{media.to_a.join("|")})$/i
diff --git a/lib/file_store/s3_store.rb b/lib/file_store/s3_store.rb
index bdf5662..d80e1ff 100644
--- a/lib/file_store/s3_store.rb
+++ b/lib/file_store/s3_store.rb
@@ -54,11 +54,12 @@ module FileStore
         content_type: opts[:content_type].presence || MiniMime.lookup_by_filename(filename)&.content_type
       }
 
-      # add a "content disposition: attachment" header with the original filename
-      # for everything but images. audio and video will still stream correctly in
-      # HTML players, and when a direct link is provided to any file but an image
-      # it will download correctly in the browser.
-      if !FileHelper.is_supported_image?(filename)
+      # add a "content disposition: attachment" header with the original
+      # filename for everything but safe images (not SVG). audio and video will
+      # still stream correctly in HTML players, and when a direct link is
+      # provided to any file but an image it will download correctly in the
+      # browser.
+      if !FileHelper.is_inline_image?(filename)
         options[:content_disposition] = ActionDispatch::Http::ContentDisposition.format(
           disposition: "attachment", filename: filename
         )
diff --git a/lib/file_store/to_s3_migration.rb b/lib/file_store/to_s3_migration.rb
index 0235375..85af5a5 100644
--- a/lib/file_store/to_s3_migration.rb
+++ b/lib/file_store/to_s3_migration.rb
@@ -232,6 +232,11 @@ module FileStore
           if upload&.secure
             options[:acl] = "private"
           end
+        elsif !FileHelper.is_inline_image?(name)
+          upload = Upload.find_by(url: "/#{file}")
+          options[:content_disposition] = ActionDispatch::Http::ContentDisposition.format(
+            disposition: "attachment", filename: upload&.original_filename || name
+          )
         end
 
         etag ||= Digest::MD5.file(path).hexdigest
diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb
index 797328f..7716af2 100644
--- a/lib/upload_creator.rb
+++ b/lib/upload_creator.rb
@@ -278,6 +278,14 @@ class UploadCreator
     doc = Nokogiri::XML(@file)
     doc.xpath(svg_whitelist_xpath).remove
     doc.xpath("//@*[starts-with(name(), 'on')]").remove
+    doc.css('use').each do |use_el|
+      if use_el.attr('href')
+        use_el.remove_attribute('href') unless use_el.attr('href').starts_with?('#')
+      end
+      if use_el.attr('xlink:href')
+        use_el.remove_attribute('xlink:href') unless use_el.attr('xlink:href').starts_with?('#')
+      end
+    end
     File.write(@file.path, doc.to_s)
     @file.rewind
   end

GitHub sha: 31e31ef4

1 Like

I think we should add tests to ensure that this doesn’t regress.

1 Like

I agree @martin-brennan and @riking could we follow up with a test please?

Tests got added here DEV: Add SVG tests for 31e31ef44 (#10205) · discourse/discourse@79b52b1 · GitHub @tgxworld @eviltrout

3 Likes