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 scriptable data: URIs (including whitespace-obfuscated ones), foreign objects, external references (in attributes and CSS url()), XXE via DOCTYPE/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.sanitize rewrites an SVG into a safe one using the same allowlist, or returns nil when the threat is structural and can't be rewritten.
  • Strict profile is an allowlist. :strict permits 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. :general keeps 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, listener elements
  • on* event-handler attributes
  • SMIL <animate>/<set> targeting a sensitive attribute (attributeName of an on* handler, href, or style)
  • javascript:, vbscript:, and similar URIs (whitespace/control-char obfuscation is stripped before the check), in attributes and in CSS
  • data: URIs (unless allow_data_uri: true); scriptable data: media types (image/svg+xml, text/html, */xml) are refused even with allow_data_uri: true
  • foreignObject, iframe, embed, object, audio, video, canvas
  • DOCTYPE or ENTITY declarations (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)/ftp references (unless allow_external: true), in URI attributes, presentation-attribute url() (fill, filter, mask, ...), and CSS url() / @import
  • raster <image> elements
  • animation elements (animate, set, ...)
  • any element outside the strict allowlist (:disallowed_element)
  • @import in 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.