svg_sentinel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +57 -0
- data/LICENSE +21 -0
- data/README.md +245 -0
- data/exe/svg_sentinel +6 -0
- data/lib/svg_sentinel/cli.rb +214 -0
- data/lib/svg_sentinel/config.rb +71 -0
- data/lib/svg_sentinel/errors.rb +12 -0
- data/lib/svg_sentinel/finding.rb +40 -0
- data/lib/svg_sentinel/policy.rb +86 -0
- data/lib/svg_sentinel/result.rb +49 -0
- data/lib/svg_sentinel/rules.rb +192 -0
- data/lib/svg_sentinel/sanitizer.rb +158 -0
- data/lib/svg_sentinel/sarif.rb +80 -0
- data/lib/svg_sentinel/scanner.rb +400 -0
- data/lib/svg_sentinel/structure_listener.rb +59 -0
- data/lib/svg_sentinel/version.rb +5 -0
- data/lib/svg_sentinel.rb +50 -0
- metadata +90 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
require_relative "rules"
|
|
5
|
+
require_relative "policy"
|
|
6
|
+
require_relative "structure_listener"
|
|
7
|
+
require_relative "finding"
|
|
8
|
+
require_relative "result"
|
|
9
|
+
require_relative "errors"
|
|
10
|
+
|
|
11
|
+
module SvgSentinel
|
|
12
|
+
# The scanner. It reads an SVG string and reports what makes it unsafe.
|
|
13
|
+
#
|
|
14
|
+
# The rules come from two public sources: general SVG cross-site-scripting
|
|
15
|
+
# knowledge (no scripts, no event handlers, no javascript: URIs, no foreign
|
|
16
|
+
# objects, no XXE) and the BIMI "SVG Tiny 1.2 Portable/Secure" profile used
|
|
17
|
+
# for brand-mark logos (no scripting, no animation, no external references,
|
|
18
|
+
# no raster images). Nothing here is fetched over the network.
|
|
19
|
+
#
|
|
20
|
+
# It is built to survive hostile input: input is normalised to valid UTF-8
|
|
21
|
+
# first, a hard byte cap and streaming structural limits refuse oversized or
|
|
22
|
+
# pathologically shaped payloads before the tree parser runs, DOCTYPE /
|
|
23
|
+
# ENTITY declarations are refused so the parser is never exposed to
|
|
24
|
+
# entity-expansion or external-entity attacks, and every parse failure
|
|
25
|
+
# becomes a finding rather than an exception.
|
|
26
|
+
#
|
|
27
|
+
# Raw findings are re-rated by a Policy for the rendering context and any
|
|
28
|
+
# configured severity overrides before they reach the Result.
|
|
29
|
+
class Scanner
|
|
30
|
+
include Rules
|
|
31
|
+
|
|
32
|
+
DEFAULT_MAX_BYTES = 32_768 # BIMI recommends brand-mark SVGs stay small.
|
|
33
|
+
DEFAULT_HARD_MAX_BYTES = 5_242_880 # 5 MiB: refuse to parse anything larger.
|
|
34
|
+
|
|
35
|
+
DEFAULT_MAX_DEPTH = 100
|
|
36
|
+
DEFAULT_MAX_NODES = 200_000
|
|
37
|
+
DEFAULT_MAX_ATTRIBUTES = 512
|
|
38
|
+
|
|
39
|
+
# Nested <use> can expand exponentially ("SVG use bomb"). If the fully
|
|
40
|
+
# expanded node count would exceed this, the SVG is a render-DoS risk.
|
|
41
|
+
USE_EXPANSION_LIMIT = 1_000_000
|
|
42
|
+
|
|
43
|
+
def initialize(profile: :strict, allow_external: false, allow_data_uri: false,
|
|
44
|
+
max_bytes: DEFAULT_MAX_BYTES, hard_max_bytes: DEFAULT_HARD_MAX_BYTES,
|
|
45
|
+
max_depth: DEFAULT_MAX_DEPTH, max_nodes: DEFAULT_MAX_NODES,
|
|
46
|
+
max_attributes: DEFAULT_MAX_ATTRIBUTES,
|
|
47
|
+
context: :inline, severity_overrides: {}, disabled: [])
|
|
48
|
+
@profile = profile
|
|
49
|
+
@allow_external = allow_external
|
|
50
|
+
@allow_data_uri = allow_data_uri
|
|
51
|
+
# A limit of 0 (or negative, or nil) means "disabled" - the single place
|
|
52
|
+
# this is decided, so config, CLI, and the library agree.
|
|
53
|
+
@max_bytes = limit(max_bytes)
|
|
54
|
+
@hard_max_bytes = limit(hard_max_bytes)
|
|
55
|
+
@max_depth = limit(max_depth)
|
|
56
|
+
@max_nodes = limit(max_nodes)
|
|
57
|
+
@max_attributes = limit(max_attributes)
|
|
58
|
+
@policy = Policy.new(context: context, overrides: severity_overrides, disabled: disabled)
|
|
59
|
+
@findings = []
|
|
60
|
+
@current_path = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def scan(svg)
|
|
64
|
+
@findings = []
|
|
65
|
+
@current_path = nil
|
|
66
|
+
collect(svg)
|
|
67
|
+
Result.new(@policy.apply(@findings))
|
|
68
|
+
rescue SystemStackError
|
|
69
|
+
# Recursion (parser or tree walk) overflowed the stack on pathologically
|
|
70
|
+
# nested input. Only reachable when structural limits are disabled; the
|
|
71
|
+
# contract is still that scan returns a Result, never raises.
|
|
72
|
+
@current_path = nil
|
|
73
|
+
add_security(:too_deep, :critical, "SVG nesting overflowed the stack")
|
|
74
|
+
Result.new(@policy.apply(@findings))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def limit(value)
|
|
80
|
+
value if value&.positive?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Populates @findings with raw (un-re-rated) findings. Returns early at the
|
|
84
|
+
# first check that makes further inspection pointless or unsafe.
|
|
85
|
+
def collect(svg)
|
|
86
|
+
return add_security(:empty_input, :critical, "SVG is nil or empty") if svg.nil?
|
|
87
|
+
|
|
88
|
+
svg = normalize_encoding(svg)
|
|
89
|
+
return if svg.nil? # encoding already flagged
|
|
90
|
+
return add_security(:empty_input, :critical, "SVG is nil or empty") if svg.strip.empty?
|
|
91
|
+
|
|
92
|
+
return unless check_size(svg)
|
|
93
|
+
return unless check_no_doctype(svg)
|
|
94
|
+
return unless check_structure(svg)
|
|
95
|
+
|
|
96
|
+
doc = parse(svg)
|
|
97
|
+
return if doc.nil?
|
|
98
|
+
|
|
99
|
+
check_root(doc)
|
|
100
|
+
id_map = {}
|
|
101
|
+
saw_use = false
|
|
102
|
+
each_element(doc) do |element, path|
|
|
103
|
+
id = element.attributes["id"]
|
|
104
|
+
id_map[id] = element if id
|
|
105
|
+
saw_use = true if element.name.casecmp?("use")
|
|
106
|
+
inspect_element(element, path)
|
|
107
|
+
end
|
|
108
|
+
check_use_bomb(doc, id_map) if saw_use
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def normalize_encoding(svg)
|
|
112
|
+
status, utf8 = Rules.to_utf8(svg)
|
|
113
|
+
case status
|
|
114
|
+
when :ok then utf8
|
|
115
|
+
when :bad_utf16
|
|
116
|
+
add_security(:malformed, :critical, "SVG declares a UTF-16 BOM but is not valid UTF-16")
|
|
117
|
+
nil
|
|
118
|
+
else
|
|
119
|
+
add_security(:malformed, :critical, "SVG is not valid UTF-8")
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A hard cap refuses oversized input before REXML sees it. The soft cap is
|
|
125
|
+
# a brand-mark house rule and only warns. Returns false when scanning must
|
|
126
|
+
# stop.
|
|
127
|
+
def check_size(svg)
|
|
128
|
+
bytes = svg.bytesize
|
|
129
|
+
if @hard_max_bytes && bytes > @hard_max_bytes
|
|
130
|
+
add_security(:too_large, :critical,
|
|
131
|
+
"SVG exceeds hard limit of #{@hard_max_bytes} bytes; refusing to parse", "#{bytes} bytes")
|
|
132
|
+
return false
|
|
133
|
+
end
|
|
134
|
+
if @max_bytes && bytes > @max_bytes
|
|
135
|
+
add_policy(:oversize, :warning, "SVG exceeds #{@max_bytes} bytes", "#{bytes} bytes")
|
|
136
|
+
end
|
|
137
|
+
true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Refuse DTDs outright. A DOCTYPE can define entities (billion-laughs) or
|
|
141
|
+
# pull in external ones (XXE). Comments and CDATA are stripped first so a
|
|
142
|
+
# harmless "<!DOCTYPE" mentioned there is not a false positive; neither
|
|
143
|
+
# construct can nest or contain its own terminator, so the strip is safe.
|
|
144
|
+
def check_no_doctype(svg)
|
|
145
|
+
markup = svg.gsub(/<!--.*?-->/m, "").gsub(/<!\[CDATA\[.*?\]\]>/m, "")
|
|
146
|
+
if markup.match?(/<!DOCTYPE/i) || markup.match?(/<!ENTITY/i)
|
|
147
|
+
add_security(:doctype, :critical, "SVG contains a DOCTYPE or ENTITY declaration (XXE risk)")
|
|
148
|
+
return false
|
|
149
|
+
end
|
|
150
|
+
true
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Bound the document's shape on a streaming pass before building a DOM. A
|
|
154
|
+
# limit breach is fatal; a parse error is left for the tree parser to
|
|
155
|
+
# report as :malformed.
|
|
156
|
+
def check_structure(svg)
|
|
157
|
+
listener = StructureListener.new(
|
|
158
|
+
max_depth: @max_depth, max_nodes: @max_nodes, max_attributes: @max_attributes
|
|
159
|
+
)
|
|
160
|
+
REXML::Parsers::StreamParser.new(svg, listener).parse
|
|
161
|
+
true
|
|
162
|
+
rescue StructureListener::LimitExceeded
|
|
163
|
+
add_security(listener.breach.code, :critical, listener.breach.message)
|
|
164
|
+
false
|
|
165
|
+
rescue
|
|
166
|
+
true
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Any parse failure becomes a finding, never an exception - including a
|
|
170
|
+
# stack overflow from deeply nested input when the depth limit is off.
|
|
171
|
+
def parse(svg)
|
|
172
|
+
REXML::Document.new(svg)
|
|
173
|
+
rescue StandardError, SystemStackError => e
|
|
174
|
+
add_security(:malformed, :critical, "SVG could not be parsed safely", e.message.to_s[0, 200])
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def check_root(doc)
|
|
179
|
+
root = doc.root
|
|
180
|
+
if root.nil?
|
|
181
|
+
add_security(:no_root, :critical, "SVG has no root element")
|
|
182
|
+
elsif !root.name.casecmp?("svg")
|
|
183
|
+
add_policy(:not_svg, :warning, "Root element is <#{root.name}>, not <svg>")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def inspect_element(element, path)
|
|
188
|
+
@current_path = path.join("/")
|
|
189
|
+
name = element.name.downcase
|
|
190
|
+
|
|
191
|
+
inspect_element_name(element, name)
|
|
192
|
+
inspect_animation_target(element, name)
|
|
193
|
+
inspect_attributes(element)
|
|
194
|
+
inspect_inline_style(element)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# A SMIL <animate>/<set> that targets an event handler, href, or style can
|
|
198
|
+
# inject script or a URI whatever its `to` value is - the value-scheme
|
|
199
|
+
# check alone misses `to="alert(1)"` written into onclick.
|
|
200
|
+
def inspect_animation_target(element, name)
|
|
201
|
+
return unless animation_element?(name)
|
|
202
|
+
|
|
203
|
+
target = element.attributes["attributeName"]
|
|
204
|
+
return if target.nil? || !sensitive_animation_target?(target)
|
|
205
|
+
|
|
206
|
+
add_security(:animated_attribute, :critical,
|
|
207
|
+
"Animation targets sensitive attribute #{target}", target.to_s[0, 60])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def inspect_element_name(element, name)
|
|
211
|
+
if script_element?(name)
|
|
212
|
+
add_security(:script_element, :critical, "Scripting element <#{element.name}>")
|
|
213
|
+
elsif foreign_element?(name)
|
|
214
|
+
add_security(:foreign_element, :critical, "Foreign-content element <#{element.name}>")
|
|
215
|
+
elsif name == "image"
|
|
216
|
+
add_policy(:raster_image, :warning, "Raster <image> element (disallowed by strict profile)") if strict?
|
|
217
|
+
elsif animation_element?(name)
|
|
218
|
+
add_policy(:animation, :warning, "Animation element <#{element.name}> (disallowed by strict profile)") if strict?
|
|
219
|
+
elsif strict? && !allowed_element?(name)
|
|
220
|
+
add_policy(:disallowed_element, :warning, "Element <#{element.name}> is not in the strict allowlist")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def inspect_attributes(element)
|
|
225
|
+
element.attributes.each do |attr_name, value|
|
|
226
|
+
lname = attr_name.downcase
|
|
227
|
+
|
|
228
|
+
if event_handler_attr?(lname)
|
|
229
|
+
add_security(:event_handler, :critical, "Event-handler attribute #{attr_name}", value.to_s[0, 120])
|
|
230
|
+
next
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
inspect_uri_attribute(attr_name, lname, value)
|
|
234
|
+
inspect_attribute_urls(attr_name, lname, value)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Presentation attributes (fill, stroke, filter, mask, clip-path,
|
|
239
|
+
# marker-*, ...) carry url(...) references that reach the network or a
|
|
240
|
+
# scheme just like href does. The style attribute is handled separately by
|
|
241
|
+
# the CSS pass.
|
|
242
|
+
def inspect_attribute_urls(attr_name, lname, value)
|
|
243
|
+
return if lname == "style"
|
|
244
|
+
|
|
245
|
+
css_reference_targets(value.to_s) do |target|
|
|
246
|
+
inspect_reference(target, "#{attr_name} attribute")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def inspect_uri_attribute(attr_name, lname, value)
|
|
251
|
+
return unless uri_attribute?(lname)
|
|
252
|
+
|
|
253
|
+
scheme = uri_scheme(value)
|
|
254
|
+
return if scheme.nil? # relative refs and #fragments are fine
|
|
255
|
+
|
|
256
|
+
if dangerous_scheme?(scheme)
|
|
257
|
+
add_security(:script_uri, :critical, "#{scheme}: URI in #{attr_name}", value.to_s[0, 120])
|
|
258
|
+
elsif scheme == "data"
|
|
259
|
+
inspect_data_uri(value, attr_name)
|
|
260
|
+
elsif external_scheme?(scheme) && !@allow_external
|
|
261
|
+
add_security(:external_ref, :warning, "External reference in #{attr_name}", value.to_s[0, 120])
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def inspect_data_uri(value, where)
|
|
266
|
+
media = data_uri_media_type(value)
|
|
267
|
+
if scriptable_data?(media)
|
|
268
|
+
add_security(:data_uri, :critical, "Scriptable data: URI (#{media}) in #{where}", value.to_s[0, 60])
|
|
269
|
+
elsif !@allow_data_uri
|
|
270
|
+
add_security(:data_uri, :critical, "data: URI in #{where}", value.to_s[0, 60])
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def inspect_inline_style(element)
|
|
275
|
+
style = element.attributes["style"]
|
|
276
|
+
scan_css(style, "style attribute") if style
|
|
277
|
+
|
|
278
|
+
return unless element.name.casecmp?("style")
|
|
279
|
+
|
|
280
|
+
css = element.texts.join
|
|
281
|
+
scan_css(css, "<style> element")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def scan_css(css, where)
|
|
285
|
+
raw = strip_css_comments(css.to_s)
|
|
286
|
+
compact = decontrol(raw)
|
|
287
|
+
|
|
288
|
+
add_security(:css_script, :critical, "Scriptable CSS in #{where}", "expression(") if compact.match?(/expression\s*\(/i)
|
|
289
|
+
DANGEROUS_SCHEMES.each do |scheme|
|
|
290
|
+
add_security(:css_script, :critical, "#{scheme}: in #{where}") if compact.match?(/#{scheme}\s*:/i)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
css_reference_targets(raw) do |target|
|
|
294
|
+
inspect_reference(target, where)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
add_policy(:css_import, :warning, "@import in #{where}") if strict? && compact.match?(/@import/i)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# A url(...) or @import target reaches the network just like a href does,
|
|
301
|
+
# and can also carry javascript:/data: schemes. Attribute-level URI checks
|
|
302
|
+
# miss these, so CSS and presentation attributes both route through here.
|
|
303
|
+
def inspect_reference(target, where)
|
|
304
|
+
scheme = uri_scheme(target)
|
|
305
|
+
return if scheme.nil?
|
|
306
|
+
|
|
307
|
+
if dangerous_scheme?(scheme)
|
|
308
|
+
add_security(:css_script, :critical, "#{scheme}: URL in #{where}", target.to_s[0, 120])
|
|
309
|
+
elsif scheme == "data"
|
|
310
|
+
inspect_data_uri(target, where)
|
|
311
|
+
elsif external_scheme?(scheme) && !@allow_external
|
|
312
|
+
add_security(:external_ref, :warning, "External reference in #{where}", target.to_s[0, 120])
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Walk <use> references and compute the fully expanded node count with
|
|
317
|
+
# memoisation (so the linter itself stays linear even on a use bomb). A
|
|
318
|
+
# reference cycle is reported separately; expansion past the limit is a
|
|
319
|
+
# render-DoS.
|
|
320
|
+
def check_use_bomb(doc, id_map)
|
|
321
|
+
@current_path = nil
|
|
322
|
+
memo = {}
|
|
323
|
+
visiting = {}
|
|
324
|
+
cyclic = false
|
|
325
|
+
bombed = false
|
|
326
|
+
|
|
327
|
+
expand = lambda do |element|
|
|
328
|
+
return 0 if element.nil?
|
|
329
|
+
|
|
330
|
+
oid = element.object_id
|
|
331
|
+
return memo[oid] if memo.key?(oid)
|
|
332
|
+
if visiting[oid]
|
|
333
|
+
cyclic = true
|
|
334
|
+
return 0
|
|
335
|
+
end
|
|
336
|
+
visiting[oid] = true
|
|
337
|
+
|
|
338
|
+
total = 1
|
|
339
|
+
element.elements.each do |child|
|
|
340
|
+
total += expand.call(child)
|
|
341
|
+
break if total > USE_EXPANSION_LIMIT
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
if element.name.casecmp?("use") && total <= USE_EXPANSION_LIMIT
|
|
345
|
+
target = id_map[use_target_id(element)]
|
|
346
|
+
total += expand.call(target) if target
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
visiting.delete(oid)
|
|
350
|
+
bombed = true if total > USE_EXPANSION_LIMIT
|
|
351
|
+
memo[oid] = total
|
|
352
|
+
total
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
expand.call(doc.root)
|
|
356
|
+
|
|
357
|
+
add_security(:use_cycle, :warning, "Recursive <use> reference (rendering may loop)") if cyclic
|
|
358
|
+
if bombed
|
|
359
|
+
add_security(:use_bomb, :critical,
|
|
360
|
+
"Nested <use> expands to over #{USE_EXPANSION_LIMIT} nodes (render DoS)")
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def use_target_id(element)
|
|
365
|
+
href = element.attributes["href"] || element.attributes["xlink:href"]
|
|
366
|
+
return nil if href.nil?
|
|
367
|
+
|
|
368
|
+
match = href.to_s.strip.match(/\A#(.+)\z/m)
|
|
369
|
+
match && match[1]
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def each_element(node, path = [], &block)
|
|
373
|
+
node.elements.each do |element|
|
|
374
|
+
current = path + [element.name]
|
|
375
|
+
block.call(element, current)
|
|
376
|
+
each_element(element, current, &block)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def strict?
|
|
381
|
+
@profile == :strict
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def add(code, severity, category, message, detail = nil)
|
|
385
|
+
@findings << Finding.new(
|
|
386
|
+
code: code, severity: severity, category: category,
|
|
387
|
+
message: message, detail: detail, path: @current_path
|
|
388
|
+
)
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def add_security(code, severity, message, detail = nil)
|
|
393
|
+
add(code, severity, :security, message, detail)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def add_policy(code, severity, message, detail = nil)
|
|
397
|
+
add(code, severity, :policy, message, detail)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/streamlistener"
|
|
4
|
+
require "rexml/parsers/streamparser"
|
|
5
|
+
require "rexml/parsers/baseparser"
|
|
6
|
+
|
|
7
|
+
module SvgSentinel
|
|
8
|
+
# A streaming (SAX) listener that bounds the *shape* of the document -
|
|
9
|
+
# nesting depth, total element count, per-element attribute count - without
|
|
10
|
+
# ever building a DOM. It runs before the tree parser so a pathologically
|
|
11
|
+
# deep or wide payload is refused early, on a memory-bounded pass, instead of
|
|
12
|
+
# being fully materialised. The byte cap limits raw size; this limits the
|
|
13
|
+
# structural cost that a small byte count can still hide (deep nesting,
|
|
14
|
+
# attribute floods).
|
|
15
|
+
class StructureListener
|
|
16
|
+
include REXML::StreamListener
|
|
17
|
+
|
|
18
|
+
LimitExceeded = Class.new(StandardError)
|
|
19
|
+
|
|
20
|
+
Breach = Struct.new(:code, :message)
|
|
21
|
+
|
|
22
|
+
attr_reader :breach
|
|
23
|
+
|
|
24
|
+
def initialize(max_depth:, max_nodes:, max_attributes:)
|
|
25
|
+
@max_depth = max_depth
|
|
26
|
+
@max_nodes = max_nodes
|
|
27
|
+
@max_attributes = max_attributes
|
|
28
|
+
@depth = 0
|
|
29
|
+
@nodes = 0
|
|
30
|
+
@breach = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def tag_start(_name, attributes)
|
|
34
|
+
@nodes += 1
|
|
35
|
+
@depth += 1
|
|
36
|
+
|
|
37
|
+
if @max_nodes && @nodes > @max_nodes
|
|
38
|
+
breach!(:too_many_nodes, "document has more than #{@max_nodes} elements")
|
|
39
|
+
end
|
|
40
|
+
if @max_depth && @depth > @max_depth
|
|
41
|
+
breach!(:too_deep, "element nesting is deeper than #{@max_depth} levels")
|
|
42
|
+
end
|
|
43
|
+
if @max_attributes && attributes && attributes.size > @max_attributes
|
|
44
|
+
breach!(:too_many_attributes, "an element has more than #{@max_attributes} attributes")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tag_end(_name)
|
|
49
|
+
@depth -= 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def breach!(code, message)
|
|
55
|
+
@breach = Breach.new(code, message)
|
|
56
|
+
raise LimitExceeded
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/svg_sentinel.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "svg_sentinel/version"
|
|
4
|
+
require_relative "svg_sentinel/errors"
|
|
5
|
+
require_relative "svg_sentinel/rules"
|
|
6
|
+
require_relative "svg_sentinel/finding"
|
|
7
|
+
require_relative "svg_sentinel/result"
|
|
8
|
+
require_relative "svg_sentinel/policy"
|
|
9
|
+
require_relative "svg_sentinel/scanner"
|
|
10
|
+
require_relative "svg_sentinel/sanitizer"
|
|
11
|
+
|
|
12
|
+
# SvgSentinel lints untrusted SVG before you render or embed it. It flags the
|
|
13
|
+
# constructs that make SVG dangerous - scripts, event handlers, javascript:
|
|
14
|
+
# URIs, foreign objects, external references, and XXE via DOCTYPE or ENTITY -
|
|
15
|
+
# and, in its strict profile, the extras that a brand-mark logo (BIMI-style)
|
|
16
|
+
# must not contain, such as animation and raster images.
|
|
17
|
+
#
|
|
18
|
+
# result = SvgSentinel.scan(svg_string)
|
|
19
|
+
# result.safe? # => false
|
|
20
|
+
# result.findings # => [#<Finding code=:script_element severity=:critical ...>]
|
|
21
|
+
#
|
|
22
|
+
# Findings are re-rated for a rendering context (:inline by default, or :img /
|
|
23
|
+
# :css_background where a browser runs SVG without scripting). To clean rather
|
|
24
|
+
# than reject, use SvgSentinel.sanitize, which rewrites the SVG or returns nil
|
|
25
|
+
# when the threat is structural and cannot be safely rewritten.
|
|
26
|
+
module SvgSentinel
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Scan an SVG string and return a Result. Options are passed through to
|
|
30
|
+
# Scanner: profile (:strict or :general), allow_external, allow_data_uri,
|
|
31
|
+
# max_bytes (soft brand-mark warning), hard_max_bytes (hard refuse-to-parse
|
|
32
|
+
# cap), max_depth / max_nodes / max_attributes (structural limits), context
|
|
33
|
+
# (:inline, :standalone, :img, :css_background), severity_overrides, disabled.
|
|
34
|
+
def scan(svg, **options)
|
|
35
|
+
Scanner.new(**options).scan(svg)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Convenience predicate: true when the SVG has no critical findings.
|
|
39
|
+
def safe?(svg, **options)
|
|
40
|
+
scan(svg, **options).safe?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Rewrite an SVG into a safe one. Returns cleaned SVG as a String, or nil
|
|
44
|
+
# when the input carries a structural threat that cannot be rewritten
|
|
45
|
+
# (DTD/XXE, use bomb, oversize, malformed, non-<svg> root). Options: profile,
|
|
46
|
+
# allow_external, allow_data_uri, and the size/structural limits.
|
|
47
|
+
def sanitize(svg, **options)
|
|
48
|
+
Sanitizer.new(**options).sanitize(svg)
|
|
49
|
+
end
|
|
50
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: svg_sentinel
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Suleyman Musayev
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-13 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rexml
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.2'
|
|
27
|
+
description: 'A small, dependency-light Ruby tool for untrusted SVG. It flags the
|
|
28
|
+
things that make SVG dangerous to render or embed: scripts, event handlers, javascript:
|
|
29
|
+
URIs, external references (including inside CSS), scriptable data: URIs, foreign
|
|
30
|
+
objects, XXE via DOCTYPE or ENTITY declarations, and nested-<use> render bombs.
|
|
31
|
+
It can also sanitize - rewrite an SVG into a safe one - and re-rates findings for
|
|
32
|
+
the rendering context (inline vs <img>). Normalises encoding, caps size, and bounds
|
|
33
|
+
structural shape on a streaming pass so it is safe to point at hostile input. Ships
|
|
34
|
+
a strict allowlist profile for brand-mark logos (BIMI-style), a general profile,
|
|
35
|
+
YAML config, and a CLI with CI-friendly exit codes and SARIF output. Pure Ruby,
|
|
36
|
+
parses with REXML, no native extensions.'
|
|
37
|
+
email:
|
|
38
|
+
- slmusayev@gmail.com
|
|
39
|
+
executables:
|
|
40
|
+
- svg_sentinel
|
|
41
|
+
extensions: []
|
|
42
|
+
extra_rdoc_files: []
|
|
43
|
+
files:
|
|
44
|
+
- CHANGELOG.md
|
|
45
|
+
- LICENSE
|
|
46
|
+
- README.md
|
|
47
|
+
- exe/svg_sentinel
|
|
48
|
+
- lib/svg_sentinel.rb
|
|
49
|
+
- lib/svg_sentinel/cli.rb
|
|
50
|
+
- lib/svg_sentinel/config.rb
|
|
51
|
+
- lib/svg_sentinel/errors.rb
|
|
52
|
+
- lib/svg_sentinel/finding.rb
|
|
53
|
+
- lib/svg_sentinel/policy.rb
|
|
54
|
+
- lib/svg_sentinel/result.rb
|
|
55
|
+
- lib/svg_sentinel/rules.rb
|
|
56
|
+
- lib/svg_sentinel/sanitizer.rb
|
|
57
|
+
- lib/svg_sentinel/sarif.rb
|
|
58
|
+
- lib/svg_sentinel/scanner.rb
|
|
59
|
+
- lib/svg_sentinel/structure_listener.rb
|
|
60
|
+
- lib/svg_sentinel/version.rb
|
|
61
|
+
homepage: https://github.com/msuliq/svg_sentinel
|
|
62
|
+
licenses:
|
|
63
|
+
- MIT
|
|
64
|
+
metadata:
|
|
65
|
+
rubygems_mfa_required: 'true'
|
|
66
|
+
homepage_uri: https://github.com/msuliq/svg_sentinel
|
|
67
|
+
source_code_uri: https://github.com/msuliq/svg_sentinel
|
|
68
|
+
changelog_uri: https://github.com/msuliq/svg_sentinel/blob/main/CHANGELOG.md
|
|
69
|
+
bug_tracker_uri: https://github.com/msuliq/svg_sentinel/issues
|
|
70
|
+
post_install_message:
|
|
71
|
+
rdoc_options: []
|
|
72
|
+
require_paths:
|
|
73
|
+
- lib
|
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: 3.1.0
|
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
requirements: []
|
|
85
|
+
rubygems_version: 3.5.22
|
|
86
|
+
signing_key:
|
|
87
|
+
specification_version: 4
|
|
88
|
+
summary: Lint or sanitize untrusted SVG for scripts, external references, and other
|
|
89
|
+
unsafe content
|
|
90
|
+
test_files: []
|