Module: CamaleonCms::SvgContentChecker

Defined in:
lib/camaleon_cms/svg_content_checker.rb

Overview

Parse-based rejection for uploads a browser parses as markup.

Named for SVG because that is where it started; it now serves every markup extension the scanner routes here (see UploaderContentSecurity::MARKUP_EXTENSIONS). The reason is the reason it replaced the regex denylist for SVG: event handlers are rejected by shape — any attribute whose name begins with on — so no handler name is ever enumerated, and a handler the web platform ships next year is refused without a code change. A name list cannot make that promise; the one in ContentSecurity is missing onpointer*, ontouch* and onauxclick today.

Constant Summary collapse

SVG_BANNED_TAGS =

animate and set are intentionally absent: SMIL animation elements carry no script by themselves, and their scripting vector is the onbegin/onend/onrepeat attribute, which the element-agnostic on* check below rejects wherever it appears. foreignObject and handler stay banned — they embed foreign markup or handlers.

form/meta/base/style/link are valid in SVG and none executes script on its own, but an uploaded SVG is served inline from the site origin and these five are what turn a passive image into markup that can navigate (meta http-equiv=refresh, base href), collect input (form), or pull in remote styling (link, style). This list only ever runs for uploads that are being scanned at all, i.e. from uploaders without media_unfiltered_upload, so it needs no trust argument.

%w[
  script foreignObject iframe object embed handler form meta base style link
].freeze
BANNED_TAGS =

What the checker actually refuses: the SVG list above unioned with the generic ruleset's element denylist. The union is load-bearing, not tidiness. ContentSecurity::BLOCKED_ELEMENTS refuses applet frameset frame template portal marquee math, which SVG_BANNED_TAGS does not; routing .html here while checking only the SVG list would have narrowed what an HTML upload is refused for. Unioning means every extension routed here is refused for at least everything the ruleset it came from refused.

(SVG_BANNED_TAGS | ContentSecurity::BLOCKED_ELEMENTS).freeze
HTML_MODE =

Extensions parsed as HTML rather than XML. Kept here rather than inferred, because the two modes differ in what a parse failure means (see parse_document).

:html
XML_MODE =
:xml
ASCII_UPPER =

Lowercasing tables for XPath translate(). The tag check folds case so a name an HTML parser lowercases is matched the same as its source form: Nokogiri::HTML reports <foreignObject> as foreignobject, and an SVG inlined into an HTML document fires <SCRIPT> exactly as <script>. Without folding, foreignObject -- the only mixed-case entry in BANNED_TAGS -- would be refused as .svg (XML, case-sensitive) yet accepted as .html.

'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
ASCII_LOWER =
'abcdefghijklmnopqrstuvwxyz'
BANNED_TAGS_XPATH =

The banned-element XPath, built once from the frozen BANNED_TAGS rather than reassembled on every scan: //*[translate(local-name(),UPPER,LOWER)='tag' or ...], each tag folded to lowercase so an HTML-lowercased element name still matches.

"//*[#{BANNED_TAGS.map do |tag|
  "translate(local-name(), '#{ASCII_UPPER}', '#{ASCII_LOWER}') = '#{tag.downcase}'"
end.join(' or ')}]".freeze

Class Method Summary collapse

Class Method Details

.unsafe?(content, mode: XML_MODE) ⇒ Boolean

mode: defaults to :xml so the pre-existing single-argument call (unsafe?(content)) keeps its exact behaviour for any downstream caller.

Returns:

  • (Boolean)


60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/camaleon_cms/svg_content_checker.rb', line 60

def unsafe?(content, mode: XML_MODE)
  return true if content.nil? || content.empty? # rubocop:disable Rails/Blank

  doc = parse_document(content, mode)
  return true if doc.nil? # XML syntax error -- fail closed

  # XML mode only. A document that parses to nothing is not a valid document of its format, so
  # refusing it fails closed. HTML has no such signal — Nokogiri::HTML never raises and reports
  # a nil root for input as ordinary as a whitespace-only or comment-only file, which must be
  # accepted. See design.md D3: the guarantee does not exist in HTML mode and is not faked.
  return true if mode != HTML_MODE && doc.root.nil?

  dangerous_document?(doc, mode)
end