diff --git a/app/models/svg_scrubber.rb b/app/models/svg_scrubber.rb new file mode 100644 index 0000000..e109154 --- /dev/null +++ b/app/models/svg_scrubber.rb @@ -0,0 +1,73 @@ +# Loofah scrubber that strips dangerous content from SVG files +# while preserving safe SVG elements and attributes for icon display. +class SvgScrubber < Loofah::Scrubber + ALLOWED_ELEMENTS = %w[ + svg g defs use symbol + circle ellipse line path polygon polyline rect + text tspan textPath + clipPath mask pattern + linearGradient radialGradient stop + filter feBlend feColorMatrix feComponentTransfer feComposite + feConvolveMatrix feDiffuseLighting feDisplacementMap feFlood + feGaussianBlur feImage feMerge feMergeNode feMorphology + feOffset feSpecularLighting feTile feTurbulence + title desc metadata + ].freeze + + ALLOWED_ATTRIBUTES = %w[ + id class style + x y x1 y1 x2 y2 cx cy r rx ry + width height viewBox preserveAspectRatio + d points + fill stroke stroke-width stroke-linecap stroke-linejoin stroke-dasharray + opacity fill-opacity stroke-opacity + transform translate rotate scale + font-family font-size font-weight text-anchor + clip-path mask filter + gradientUnits gradientTransform spreadMethod + offset stop-color stop-opacity + dx dy textLength lengthAdjust + xmlns xmlns:xlink + color display visibility overflow + fill-rule clip-rule + marker-start marker-mid marker-end + ].freeze + + # Loofah hands attribute names back in their source case (e.g. "viewBox"). + # Compare against a downcased copy so SVG-spec camelCase attributes aren't + # stripped from legitimate icons. + ALLOWED_ATTRIBUTES_LOOKUP = ALLOWED_ATTRIBUTES.map(&:downcase).to_set.freeze + + # Event handler attributes that must always be removed + EVENT_HANDLER_PATTERN = /\Aon/i + + def initialize + @direction = :top_down + end + + def scrub(node) + return CONTINUE if node.text? || node.cdata? + + if node.element? + if ALLOWED_ELEMENTS.include?(node.name) + # Remove disallowed and event handler attributes + node.attribute_nodes.each do |attr| + attr.remove unless safe_attribute?(attr) + end + return CONTINUE + end + end + + node.remove + STOP + end + + private + + def safe_attribute?(attr) + name = attr.name.downcase + return false if name.match?(EVENT_HANDLER_PATTERN) + return false if attr.value&.match?(/javascript:|data:/i) + ALLOWED_ATTRIBUTES_LOOKUP.include?(name) + end +end diff --git a/test/models/svg_scrubber_test.rb b/test/models/svg_scrubber_test.rb new file mode 100644 index 0000000..1bbf7c0 --- /dev/null +++ b/test/models/svg_scrubber_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class SvgScrubberTest < ActiveSupport::TestCase + def scrub(svg) + Loofah.xml_document(svg).scrub!(SvgScrubber.new).to_xml + end + + test "strips embedded script elements" do + svg = %() + + cleaned = scrub(svg) + + refute_match(/