Module: SvgSentinel::Rules

Included in:
Sanitizer, Scanner
Defined in:
lib/svg_sentinel/rules.rb

Overview

The shared security vocabulary: which elements, attributes, and URI schemes are dangerous, plus the pure string helpers that read them. Both the Scanner (which reports) and the Sanitizer (which rewrites) mix this in, so the two can never drift out of agreement about what "dangerous" means.

Constant Summary collapse

SCRIPT_ELEMENTS =
%w[script handler listener].freeze
FOREIGN_ELEMENTS =
%w[foreignobject iframe embed object audio video canvas].freeze
ANIMATION_ELEMENTS =
%w[animate animatecolor animatemotion animatetransform set mpath].freeze
STRICT_ALLOWED_ELEMENTS =

The strict profile is an allowlist, not a blocklist: any element whose local name is not here (and is not already covered by a more specific rule) is reported. This fails closed - a new dangerous element is flagged simply for not being on the list. It is a conservative set of static, non-scripting, non-foreign SVG elements. Raster , animation, and foreign/script elements are deliberately absent; they have their own, more descriptive findings.

%w[
  svg g defs symbol use switch view
  title desc metadata
  path rect circle ellipse line polyline polygon
  text tspan textpath tref altglyph glyphref
  lineargradient radialgradient stop
  solidcolor color-profile
  clippath mask pattern marker
  style font font-face missing-glyph glyph hkern vkern
  filter feblend fecolormatrix fecomponenttransfer fecomposite
  feconvolvematrix fediffuselighting fedisplacementmap fedropshadow
  feflood fegaussianblur femerge femergenode femorphology feoffset
  fespecularlighting fetile feturbulence fedistantlight fepointlight
  fespotlight fefunca fefuncr fefuncg fefuncb
].freeze
DANGEROUS_SCHEMES =
%w[javascript vbscript livescript mocha].freeze
URI_ATTRIBUTES =
%w[href src from to values by].freeze
EXTERNAL_SCHEMES =
%w[http https ftp].freeze
SENSITIVE_ANIMATED_ATTRIBUTES =

Attributes that a SMIL / must not target: writing script into an event handler, or a URI into href, is a script-injection vector regardless of the animation's to value.

%w[href xlink:href style].freeze
SCRIPTABLE_DATA_MEDIA =

data: media types that can carry executable markup. These are refused even when data URIs are otherwise allowed - "allow data URIs" means raster images, not an SVG or HTML document smuggled in as a data: URI.

%w[
  image/svg+xml text/html application/xhtml+xml text/xml application/xml
].freeze

Class Method Summary collapse

Class Method Details

.allowed_element?(name) ⇒ Boolean

Returns:

  • (Boolean)


66
67
68
# File 'lib/svg_sentinel/rules.rb', line 66

def allowed_element?(name)
  STRICT_ALLOWED_ELEMENTS.include?(name)
end

.animation_element?(name) ⇒ Boolean

Returns:

  • (Boolean)


62
63
64
# File 'lib/svg_sentinel/rules.rb', line 62

def animation_element?(name)
  ANIMATION_ELEMENTS.include?(name)
end

.classify_reference(target, allow_external: false, allow_data_uri: false) ⇒ Object

Classify a single reference target against the current allowances. Returns :dangerous (script/scriptable-data), :external, :data, or nil (relative/internal/allowed).



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/svg_sentinel/rules.rb', line 139

def classify_reference(target, allow_external: false, allow_data_uri: false)
  scheme = uri_scheme(target)
  return nil if scheme.nil?
  return :dangerous if dangerous_scheme?(scheme)

  if scheme == "data"
    return :dangerous if scriptable_data?(data_uri_media_type(target))

    return allow_data_uri ? nil : :data
  end

  return :external if external_scheme?(scheme) && !allow_external

  nil
end

.css_reference_targets(css) ⇒ Object

Every off-document reference target in a chunk of CSS: both url(...) and the string form of @import "..." (which url(...) scanning alone misses). Yields each raw target string.



129
130
131
132
133
134
# File 'lib/svg_sentinel/rules.rb', line 129

def css_reference_targets(css)
  return enum_for(:css_reference_targets, css) unless block_given?

  css.scan(/url\(\s*(['"]?)(.*?)\1\s*\)/im) { |_quote, target| yield target }
  css.scan(/@import\s+(['"])(.*?)\1/im) { |_quote, target| yield target }
end

.dangerous_css?(css, allow_external: false, allow_data_uri: false) ⇒ Boolean

True when this CSS text carries something executable or off-origin. Used by the Scanner to report and by the Sanitizer to decide whether to drop a style declaration wholesale.

Returns:

  • (Boolean)


181
182
183
184
185
186
187
188
189
190
# File 'lib/svg_sentinel/rules.rb', line 181

def dangerous_css?(css, allow_external: false, allow_data_uri: false)
  raw = strip_css_comments(css.to_s)
  compact = decontrol(raw)
  return true if compact.match?(/expression\s*\(/i)
  return true if DANGEROUS_SCHEMES.any? { |s| compact.match?(/#{s}\s*:/i) }

  css_reference_targets(raw).any? do |target|
    !classify_reference(target, allow_external: allow_external, allow_data_uri: allow_data_uri).nil?
  end
end

.dangerous_scheme?(scheme) ⇒ Boolean

Returns:

  • (Boolean)


87
88
89
# File 'lib/svg_sentinel/rules.rb', line 87

def dangerous_scheme?(scheme)
  DANGEROUS_SCHEMES.include?(scheme)
end

.data_uri_media_type(value) ⇒ Object



107
108
109
110
111
112
113
114
# File 'lib/svg_sentinel/rules.rb', line 107

def data_uri_media_type(value)
  cleaned = decontrol(value.to_s)
  match = cleaned.match(/\Adata:([^;,]*)/i)
  return nil unless match

  type = match[1].downcase
  type.empty? ? "text/plain" : type
end

.decontrol(str) ⇒ Object

Remove whitespace and C0 control characters (NUL through space), a common trick for hiding "javascript:" inside an attribute value.



118
119
120
# File 'lib/svg_sentinel/rules.rb', line 118

def decontrol(str)
  str.gsub(/[[:cntrl:][:space:]]/, "")
end

.event_handler_attr?(lname) ⇒ Boolean

Returns:

  • (Boolean)


70
71
72
# File 'lib/svg_sentinel/rules.rb', line 70

def event_handler_attr?(lname)
  lname.start_with?("on")
end

.external_scheme?(scheme) ⇒ Boolean

Returns:

  • (Boolean)


91
92
93
# File 'lib/svg_sentinel/rules.rb', line 91

def external_scheme?(scheme)
  EXTERNAL_SCHEMES.include?(scheme)
end

.foreign_element?(name) ⇒ Boolean

Returns:

  • (Boolean)


58
59
60
# File 'lib/svg_sentinel/rules.rb', line 58

def foreign_element?(name)
  FOREIGN_ELEMENTS.include?(name)
end

.script_element?(name) ⇒ Boolean

Returns:

  • (Boolean)


54
55
56
# File 'lib/svg_sentinel/rules.rb', line 54

def script_element?(name)
  SCRIPT_ELEMENTS.include?(name)
end

.scriptable_data?(media) ⇒ Boolean

Returns:

  • (Boolean)


95
96
97
# File 'lib/svg_sentinel/rules.rb', line 95

def scriptable_data?(media)
  !media.nil? && SCRIPTABLE_DATA_MEDIA.include?(media)
end

.sensitive_animation_target?(attribute_name) ⇒ Boolean

True when a SMIL animation's attributeName points at an attribute that can execute or fetch: an on* handler, href, or style.

Returns:

  • (Boolean)


76
77
78
79
# File 'lib/svg_sentinel/rules.rb', line 76

def sensitive_animation_target?(attribute_name)
  target = decontrol(attribute_name.to_s).downcase
  event_handler_attr?(target) || SENSITIVE_ANIMATED_ATTRIBUTES.include?(target)
end

.strip_css_comments(css) ⇒ Object



122
123
124
# File 'lib/svg_sentinel/rules.rb', line 122

def strip_css_comments(css)
  css.gsub(%r{/\*.*?\*/}m, "")
end

.to_utf8(raw) ⇒ Object

Normalise arbitrary bytes to valid UTF-8 so downstream byte-level checks are meaningful and no encoding error can escape. Returns [:ok, string], [:bad_utf16, nil], or [:bad_utf8, nil].



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/svg_sentinel/rules.rb', line 158

def to_utf8(raw)
  str = raw.to_s
  bytes = str.b
  head = bytes[0, 2]

  if head == "\xFF\xFE".b || head == "\xFE\xFF".b
    src = (head == "\xFF\xFE".b) ? Encoding::UTF_16LE : Encoding::UTF_16BE
    begin
      return [:ok, bytes[2..].force_encoding(src).encode(Encoding::UTF_8)]
    rescue EncodingError
      return [:bad_utf16, nil]
    end
  end

  utf8 = str.dup.force_encoding(Encoding::UTF_8)
  return [:ok, utf8] if utf8.valid_encoding?

  [:bad_utf8, nil]
end

.uri_attribute?(lname) ⇒ Boolean

Returns:

  • (Boolean)


81
82
83
84
85
# File 'lib/svg_sentinel/rules.rb', line 81

def uri_attribute?(lname)
  return true if URI_ATTRIBUTES.include?(lname)

  lname.end_with?(":href")
end

.uri_scheme(value) ⇒ Object

Strip whitespace and control characters before reading a scheme, so "java\tscript:" and " javascript:" cannot slip past.



101
102
103
104
105
# File 'lib/svg_sentinel/rules.rb', line 101

def uri_scheme(value)
  cleaned = decontrol(value.to_s)
  match = cleaned.match(/\A([a-z][a-z0-9+.-]*):/i)
  match && match[1].downcase
end