0.0
The project is in a healthy, maintained state
A small, dependency-light Ruby tool for untrusted SVG. It flags the things that make SVG dangerous to render or embed: scripts, event handlers, javascript: URIs, external references (including inside CSS), scriptable data: URIs, foreign objects, XXE via DOCTYPE or ENTITY declarations, and nested-<use> render bombs. It can also sanitize - rewrite an SVG into a safe one - and re-rates findings for the rendering context (inline vs <img>). Normalises encoding, caps size, and bounds structural shape on a streaming pass so it is safe to point at hostile input. Ships a strict allowlist profile for brand-mark logos (BIMI-style), a general profile, YAML config, and a CLI with CI-friendly exit codes and SARIF output. Pure Ruby, parses with REXML, no native extensions.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 3.2
 Project Readme

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.