SvgSentinel
Lint or sanitize untrusted SVG before you render or embed it.
SVG is XML that a browser will execute. It can carry <script>, onload=
handlers, javascript: URIs, <foreignObject> full of HTML, references to
remote servers, <!ENTITY> payloads that expand until your process falls over,
and nested <use> elements that expand into millions of nodes. If you accept
SVG from users - avatars, brand logos, uploaded icons - you need to know what is
inside before you serve it.
SvgSentinel reads an SVG and tells you what makes it dangerous, and can rewrite
it into a safe one. It reports; you decide - or you hand it sanitize and let
it clean.
Why it exists
- Catches the real risks. Scripts, event handlers,
javascript:and scriptabledata:URIs (including whitespace-obfuscated ones), foreign objects, external references (in attributes and CSSurl()), XXE viaDOCTYPE/ENTITY, and<use>expansion bombs. - Safe to run on hostile input. Input is normalised to valid UTF-8 first (a stray byte can never crash the scanner), a hard byte cap and a streaming structural pass (depth / node / attribute limits) refuse pathological payloads before the tree parser runs, DTDs are refused so the parser can't be attacked with entity expansion, and every parse failure becomes a finding rather than an exception. Nothing is ever fetched over the network.
- Context-aware. The same SVG is dangerous differently depending on how a
browser runs it.
scan(svg, context: :img)knows that an SVG loaded through<img>or CSS runs without scripting, and re-rates findings accordingly. - Cleans, not just complains.
SvgSentinel.sanitizerewrites an SVG into a safe one using the same allowlist, or returnsnilwhen the threat is structural and can't be rewritten. - Strict profile is an allowlist.
:strictpermits a conservative set of static SVG elements and flags everything else - so a new dangerous element is caught for simply not being on the list. It matches what a brand-mark logo (BIMI "SVG Tiny 1.2 Portable/Secure") is allowed to contain.:generalkeeps every security check but drops the cosmetic ones. - Dependency-light. Pure Ruby, parses with REXML. No native extensions.
Installation
gem "svg_sentinel"
Then bundle install, or gem install svg_sentinel.
Usage
require "svg_sentinel"
result = SvgSentinel.scan(svg_string)
result.safe? # => false (no critical findings means safe)
result.critical? # => true
result.criticals # => [#<Finding code=:script_element severity=:critical ...>]
result.warnings # => [...]
result.security_findings # => real attack surface (any severity)
result.policy_findings # => strict-profile house rules (any severity)
result.to_h # => { safe: false, findings: [...] }
# Quick predicate
SvgSentinel.safe?(svg_string) # => true / false
Each finding carries a code, a severity (:critical or :warning), a
category (:security or :policy), a human message, an optional detail,
and the element path where it occurred:
SvgSentinel.scan('<svg onload="x()"><script>y()</script></svg>').findings.map(&:to_h)
# => [
# { code: :event_handler, severity: :critical, category: :security,
# message: "Event-handler attribute onload", detail: "x()", path: "svg" },
# { code: :script_element, severity: :critical, category: :security,
# message: "Scripting element <script>", detail: nil, path: "svg/script" }
# ]
severity and category are orthogonal: use severity (or safe?) to decide
whether to render, and category to separate genuine security signals from
strict-profile compliance noise.
Sanitizing
sanitize returns cleaned SVG, or nil when the input carries a structural
threat that can't be rewritten (DTD/XXE, use bomb, oversize, malformed, a
non-<svg> root). Fixable problems - scripts, handlers, dangerous URIs,
dangerous CSS - are removed.
clean = SvgSentinel.sanitize(untrusted_svg)
if clean
store(clean) # safe to serve
else
reject! # nothing safe could be produced
end
Sanitizing removes; it does not repair. A <style> block or style attribute
that contains anything dangerous is dropped whole rather than partially
rewritten - partial CSS rewriting is where sanitizers leak.
Rendering context
An SVG in <img src> or a CSS background runs in the browser's secure-static
mode: no scripting, no external subresources. Findings that only matter when
the SVG can script or fetch are downgraded to warnings there, so they don't
make the SVG "unsafe" on their own. Parser-level and denial-of-service findings
(XXE, use bombs, oversize) are never downgraded.
svg = '<svg onload="track()">...</svg>'
SvgSentinel.safe?(svg) # => false (context: :inline, default)
SvgSentinel.safe?(svg, context: :img) # => true (onload never fires in <img>)
Contexts: :inline (default), :standalone, :img, :css_background.
Command line
svg_sentinel logo.svg # exit 0 if safe, 1 if unsafe
cat logo.svg | svg_sentinel # read from STDIN
svg_sentinel --context img icons/*.svg
svg_sentinel --format sarif *.svg # SARIF 2.1.0 for code scanning
svg_sentinel --sanitize logo.svg # write cleaned SVG to stdout
Exit codes: 0 every input is safe, 1 at least one input is unsafe
(so CI fails), 2 a usage/config error or an unreadable file. Run
svg_sentinel --help for all options.
Configuration
Settings can live in .svg_sentinel.yml (auto-discovered in the working
directory, or --config PATH). Command-line flags override the file.
profile: strict
context: img
allow_external: false
severity:
external_ref: error # treat as critical in this repo
raster_image: ignore # we accept raster logos
disabled:
- css_import
The same overrides work in code:
SvgSentinel.scan(svg, severity_overrides: {external_ref: :error}, disabled: [:css_import])
Security note on config discovery. The CLI auto-discovers .svg_sentinel.yml
from the working directory, and that file can disable checks or downgrade
their severity. Like other linters this is convenient, but it means a config
dropped into a directory can weaken the gate. When you run SvgSentinel as a
security gate over untrusted content (e.g. scanning an upload directory or
an untrusted checkout), pass an explicit --config you control and do not run
it from a directory whose contents you don't trust.
What counts as critical vs warning
Critical (unsafe to render or embed as-is):
script,handler,listenerelementson*event-handler attributes- SMIL
<animate>/<set>targeting a sensitive attribute (attributeNameof anon*handler,href, orstyle) javascript:,vbscript:, and similar URIs (whitespace/control-char obfuscation is stripped before the check), in attributes and in CSSdata:URIs (unlessallow_data_uri: true); scriptabledata:media types (image/svg+xml,text/html,*/xml) are refused even withallow_data_uri: trueforeignObject,iframe,embed,object,audio,video,canvasDOCTYPEorENTITYdeclarations (XXE, entity expansion)expression(...)or script URIs inside CSS- nested
<use>that expands past ~1,000,000 nodes (render DoS) - input past the hard byte cap, too deeply nested, too many elements, too many attributes on one element, malformed, empty, or not valid UTF-8
Warning (safe to render, but disallowed by the strict brand-mark profile, or otherwise worth a look):
- external
http(s)/ftpreferences (unlessallow_external: true), in URI attributes, presentation-attributeurl()(fill, filter, mask, ...), and CSSurl()/@import - raster
<image>elements - animation elements (
animate,set, ...) - any element outside the strict allowlist (
:disallowed_element) @importin CSS- recursive
<use>references - oversize input (soft threshold, default 32 KB)
Options
SvgSentinel.scan(svg,
profile: :strict, # :strict (default) or :general
context: :inline, # :inline (default), :standalone, :img, :css_background
allow_external: false, # permit http(s)/ftp references
allow_data_uri: false, # permit non-scriptable data: URIs
max_bytes: 32_768, # soft size-warning threshold; nil to disable
hard_max_bytes: 5_242_880, # hard refuse-to-parse cap; nil to disable
max_depth: 100, # max element nesting; nil to disable
max_nodes: 200_000, # max element count; nil to disable
max_attributes: 512, # max attributes on one element; nil to disable
severity_overrides: {}, # code => :error / :warning / :ignore
disabled: []) # codes to drop entirely
sanitize accepts the same profile, allow_external, allow_data_uri, and
size/structural options.
Scope and limits
SvgSentinel is a static analyzer. No static analyzer can perfectly mirror a browser's HTML/SVG parser, so for genuinely hostile input the defence-in-depth answers still apply: serve untrusted SVG from a sandboxed origin under CSP, or rasterize it server-side. REXML, like any XML parser, has its own parsing-DoS history; the hard byte cap, the streaming structural limits, and DTD refusal are the mitigations - size them to your workload.
Development
bundle install
bundle exec rspec
The vector corpus lives in spec/fixtures/payloads.yml. To cross-check against
a real renderer, point SVG_SENTINEL_ORACLE at a command that reads an SVG on
stdin and prints executes or inert:
SVG_SENTINEL_ORACLE='node spec/support/dompurify_probe.js' bundle exec rspec
License
MIT. See LICENSE.