This is a consolidated reference for AI coding assistants: the complete API and every rejection rule in one file, covering SvgSecurityCheck 1.0. For human-friendly docs, see the README.
Contents:
- What SvgSecurityCheck Is
- API -
checkFile(),checkString(),rules(),Result,Violation - Usage Pattern
- Error Codes
- Rules: Structure
- Rules: Elements and Namespaces
- Rules: Attributes
- Rules: URLs -
href,url(), embedded images - Rules: CSS
- Rules: Animation
- Rules: Reference Loops and Expansion
- Allowlists
- Limits
- How the Rules Compare to Chrome
- Constraints and Gotchas
SvgSecurityCheck checks an uploaded SVG file and rejects it if it could run script, load anything
from outside itself, or hang a renderer, with a list of reasons. Chrome's <img> mode blocks
script and outside loads, but Chrome does not block them when the file is opened on its own;
SvgSecurityCheck checks the file, so it is safe either way. The file is never modified:
there is no cleaned output, and the caller stores the original bytes or refuses the upload.
Every rule is an allowlist. Elements, attributes, XML namespaces, URL forms, animation
targets, CSS functions and CSS at-rules not on a list are rejected. On top of the lists,
on* attributes, a <script> element in any namespace, and three CSS tokens the lists cannot
catch (a backslash, -moz-binding and behavior:) are rejected by name.
The rules have no options: every install checks the same way. The size limits are public
static properties, see Limits.
use Itools\SvgSecurityCheck\SvgSecurityCheck;
$result = SvgSecurityCheck::checkFile($_FILES['logo']['tmp_name']);
$result->ok; // true when the file passed
$result->errors; // Violation[] - empty when ok, otherwise one per distinct problemRequirements: PHP 8.1+, ext-xmlreader, ext-libxml. No other dependencies.
Three final classes in the Itools\SvgSecurityCheck namespace. Nothing throws for a bad file:
a hostile or broken upload is expected input and comes back as a rejected Result.
SvgSecurityCheck::checkFile(string $path): Result // streams the file; memory grows only with embedded data: images
SvgSecurityCheck::checkString(string $svg): Result // same check on SVG source in a string
SvgSecurityCheck::rules(): array // the allowlists, for documentation and debuggingcheckFile() and checkString() give the same result for the same bytes. Given a path that
is not a readable file, checkFile() returns a result with one file-unreadable error; it
never throws. Neither method looks at the file name, extension, or MIME type.
rules() returns an array with the keys elements, attributes, namespacedAttributes
(keyed by namespace URI), inertNamespaces, inertProcessingInstructions, imageElements,
dataImageTypes, cssFunctions and cssAtRules. See Allowlists. The lists
are constants; changing the returned array changes nothing.
final class Result
{
public readonly bool $ok; // true when $errors is empty
public readonly array $errors; // Violation[], in the order found, at most 50
}errors holds one Violation per distinct problem (deduplicated by code and detail), in the
order found, capped at 50. Prolog problems (not-svg, not-utf8, doctype-not-allowed,
file-unreadable, and malformed-xml when no root tag starts within the first 64 KB) stop
the check, so they arrive alone. When malformed-xml is present, it is always the last entry:
nothing after a parse error is checked.
$violation->code; // 'element-not-allowed' - stable across releases
$violation->detail; // 'script' - plain text taken from the file, values cut at 60 characters plus '...'
$violation->template; // '<%s> is not allowed in uploaded SVGs'
$violation->message; // '<script> is not allowed in uploaded SVGs'
Violation::TEMPLATES; // every message template keyed by code, one %s eachAll four properties are readonly strings, and detail and message hold raw text from the
file; see Constraints and Gotchas for encoding them. The
constructors are public, so a test can build a result:
new Result([new Violation('element-not-allowed', 'script')]). The code must be a key of
Violation::TEMPLATES.
For translation, template goes through the translation function and detail goes back in
with sprintf(). The template holds literal < and >, so the whole result is encoded:
echo htmlspecialchars(sprintf(t($violation->template), $violation->detail));Violation::TEMPLATES lists every template by code so a translation system can register
them all up front. Codes never change once released; template wording can, so a translation
table keyed by code keeps working when a template is reworded.
Some details are English text written by the library, not text from the file: the phrases
listed for not-svg, not-utf8, doctype-not-allowed, reference-expansion-too-large,
css-not-allowed and embedded-svg-not-allowed in Error Codes, plus the
PCRE-limit phrases below. A translation system can map those by exact value. libxml2's message
in malformed-xml and an embedded SVG's inner message stay English.
use Itools\SvgSecurityCheck\SvgSecurityCheck;
$upload = $_FILES['logo'];
if ($upload['error'] !== UPLOAD_ERR_OK) {
exit('The upload failed, please try again');
}
$result = SvgSecurityCheck::checkFile($upload['tmp_name']);
if (!$result->ok) {
foreach ($result->errors as $violation) {
echo '<p>', htmlspecialchars($violation->message), '</p>';
}
exit;
}
move_uploaded_file($upload['tmp_name'], "/var/www/uploads/logo.svg"); // the original bytes, unchangedSVG sanitizers return a cleaned string to store. SvgSecurityCheck does not; store the original.
// WRONG - checkString() returns a Result, not a cleaned SVG; this writes an empty file, with no warning
$clean = SvgSecurityCheck::checkString($svg);
file_put_contents($path, $clean);
// RIGHT - check, then store the original bytes only when ok
if (SvgSecurityCheck::checkString($svg)->ok) {
file_put_contents($path, $svg);
}Every code, its template, and what detail holds. Templates are Violation::TEMPLATES. The
fix to show the uploader for each code is in errors.md.
| Code | Template | detail |
|---|---|---|
file-unreadable |
Cannot read file %s |
the file's basename |
not-svg |
This is not an SVG file: it starts with %s |
the first 20 bytes, control and non-ASCII bytes escaped, or nothing (the file is empty) |
not-utf8 |
SVG files must be UTF-8, this one is %s |
UTF-16 or UTF-32 or declared as ISO-8859-1 (the declared encoding) |
malformed-xml |
The SVG is not well-formed XML: %s |
libxml2's message plus (line N), or no root element within the first 64 KB |
doctype-not-allowed |
The DOCTYPE declaration is not allowed because %s |
it contains an internal DTD subset (entity declarations) or a quoted id contains >, where HTML parsers end it |
processing-instruction |
Processing instructions like <?%s?> are not allowed |
the instruction's target, such as xml-stylesheet; for an xpacket marker holding >, the target and its text |
comment-not-allowed |
A comment starting with <!--%s is not allowed, HTML parsers close it there |
> or -> |
cdata-not-allowed |
A CDATA section containing > is not allowed inside <%s>, HTML parsers end it there |
title or desc |
element-in-title-or-desc |
<title> and <desc> may hold only text, not <%s> |
the element name as written, such as style or a |
root-not-svg |
The root element must be <svg>, not <%s> |
the root element's name as written |
root-namespace-wrong |
The root <svg> element must declare xmlns="http://www.w3.org/2000/svg", but it has %s |
none or xmlns="..." with the namespace found |
element-not-allowed |
<%s> is not allowed in uploaded SVGs |
the element's local name, or its name as written when it has no namespace or is a <script> |
namespace-not-allowed |
Elements from the XML namespace %s are not allowed |
the namespace URI |
element-prefix-wrong |
<%s> has the wrong prefix: SVG elements take none, metadata elements need one |
the element name as written, such as svg:rect or p |
event-handler |
%s= event handler attributes are not allowed |
the attribute name as written, such as onload or xlink:onload |
attribute-not-allowed |
The %s attribute is not allowed |
the attribute name as written, such as tabindex or xml:base |
xlink-prefix-wrong |
The xlink: prefix must be bound to http://www.w3.org/1999/xlink, not %s |
the namespace the prefix is bound to |
href-not-allowed |
href must reference an element in the same file (#id), not %s |
the value, or (empty) |
image-href-not-allowed |
Image href must be #id or an embedded PNG, JPEG, GIF, WebP or SVG data: URL, not %s |
the value, or (empty) |
embedded-svg-not-allowed |
An embedded SVG image was rejected: %s |
the inner file's message, the data: URL is not valid base64, or SVG images nested more than 3 levels deep |
url-not-fragment |
url() in the %s attribute must reference an element in the same file (#id) |
the attribute name |
reference-expansion-too-large |
The references in this file %s |
form a loop (#a -> #b -> #a), expand to more than 100,000 elements, or need more than 100,000 records to track (ids and references) |
css-not-allowed |
CSS containing %s is not allowed |
the token, function or at-rule as matched (\, image-set(, @import, url(https://example.com/a.css) or more than 1,000,000 bytes |
animation-target-not-allowed |
Animating the %s attribute is not allowed |
the attributeName value |
Values taken from the file are cut at 60 characters and end with .... Control characters
and invalid bytes are escaped after the cut, so they add length, and three details are not
cut: the file-unreadable basename, an embedded SVG's inner message, and the (line N)
after a malformed-xml message.
A regex that reaches PHP's PCRE limits on hostile input rejects instead of passing. The
detail is then declared in a form that could not be read (not-utf8),
could not be counted (reference-expansion-too-large) or text that could not be scanned
(css-not-allowed); url-not-fragment keeps the attribute name as its detail.
Checked on the first 64 KB before parsing, then by the parser. Each of the first four ends
the check. The first three arrive as the only error; malformed-xml ends the list after
anything found earlier in the file.
not-utf8: a UTF-16 or UTF-32 byte order mark, a null byte just before or after the first<, two null bytes at the start, or an XML declaration whoseencodingis anything bututf-8(case-insensitive). A UTF-8 byte order mark is accepted and skipped.not-svg: after the byte order mark and leading whitespace, the first byte is not<. An empty or whitespace-only file reportsnothing (the file is empty).doctype-not-allowed: a<!DOCTYPEwith an internal subset ([), or a quoted public or system id that holds a>. An HTML parser ends the DOCTYPE at the first>, inside quotes or not, so markup after it would be live if the file were served astext/html. A DOCTYPE with only a public or system id is otherwise accepted; nothing is fetched (LIBXML_NONET,LOADDTDoff,SUBST_ENTITIESoff), so an entity that such a DTD would define reportsmalformed-xml(Entity 'foo' not defined). The five built-in entities and numeric character references are accepted.malformed-xml: the root tag does not start within 64 KB, or libxml2 reports any error or warning. Includes an undefined namespace prefix and invalid UTF-8 bytes. libxml2's own limits apply: element depth 256, a single text node 10 MB.processing-instruction: any<?target ...?>anywhere, including<?xml-stylesheet?>, except the targets ininertProcessingInstructions(Adobe'sxpacketXMP markers). Anxpacketmarker whose text holds>rejects too: HTML parsers end the instruction at the first>. The<?xml ...?>declaration itself is not a processing instruction and is accepted.comment-not-allowed: a comment whose content starts with>or->(written<!-->or<!--->). All other comments are accepted.cdata-not-allowed: a<![CDATA[section inside<title>or<desc>whose text contains>. HTML parsers use HTML rules inside those two elements and end the CDATA at the first>. CDATA anywhere else is accepted, and so is>. The detail is the element name.element-in-title-or-desc: any element inside<title>or<desc>, at any depth. HTML parsers read elements there as HTML, where<style/>is not empty and the text after it becomes CSS this check never saw. The detail is the element name as written.root-not-svg: the root element's local name is notsvg. Stops the check.root-namespace-wrong: the root<svg>is not inhttp://www.w3.org/2000/svg. Stops the check. A prefixed root (<svg:svg xmlns:svg="...">) getselement-prefix-wrong, see Rules: Elements and Namespaces.
An element whose local name is script (case-insensitive) rejects with element-not-allowed
in any namespace and with any prefix, so script is rejected everywhere. Every other element,
by its namespace:
- SVG namespace (
http://www.w3.org/2000/svg): the local name must be in the elements allowlist, elseelement-not-allowed. Names are case-sensitive. The rejected element's attributes are not checked; its children are. - No namespace (an unbound prefix, or inside
xmlns=""):element-not-allowedwith the name as written. - Inert namespace (see the list): the element passes, and its attribute names are not
checked. Their values are, as described under Rules: Attributes, and
on*still rejects. Served astext/html, a metadata element is an unknown SVG element and Chrome applies itsstyle. - Any other namespace:
namespace-not-allowedwith the URI. This covers XHTML, MathML, XInclude, XML Events, and anything unknown.
Then the prefix must match: an SVG element is written without one (<rect>) and an
inert-namespace element with one (<rdf:RDF>), else element-prefix-wrong with the name as
written. HTML parsers ignore xmlns and read an element by the name as written, so
<svg:svg> is not SVG to them and <p xmlns="http://ns.adobe.com/x"> is an HTML <p>.
Design tools always write it this way.
Not allowed on purpose: <script>, <foreignObject>, <handler>, <listener>, <iframe>,
<embed>, <font> (and the other SVG font elements), <tref>, <cursor>, <animateColor>,
<discard>. A nested <svg> is accepted.
For every attribute on an element in the SVG namespace or an inert namespace, in this order:
xmlnsandxmlns:*declarations pass, exceptxmlns:xlink(in any case, such asxmlns:XLINK) bound to anything other thanhttp://www.w3.org/1999/xlink, which rejects withxlink-prefix-wrong. HTML parsers lowercase attribute names and readxlink:hrefby its spelling, so a rebound prefix would turn an unchecked attribute into a working link if the file were served astext/html.- A name whose local part starts with
on(case-insensitive, any namespace, any element including inert ones) rejects withevent-handler. No allowed attribute starts withon. - An attribute in an inert namespace passes. It always has a prefix, and step 1 keeps that
prefix from being
xlinkin any case, so no parser reads it. - On an SVG element, an unprefixed name must be in the attributes allowlist
or start with
data-oraria-. A prefixed name must belangorspacein the XML namespace orhrefortitlein the XLink namespace, matched by namespace, not by the prefix as written (xml:lang,xlink:href). Anything else rejects withattribute-not-allowed. Names are case-sensitive. On an inert element this step is skipped; step 5 still runs. - The value is then checked:
hrefandxlink:hrefper URLs,styleper CSS, every other value forurl(per URLs, and on animation elements per Animation.
Not allowed on purpose: tabindex, target, crossorigin, cursor, xml:base, xml:id
(Batik reads it as an id, which the expansion check would not count), xlink:show,
xlink:actuate, every on*.
href and xlink:href. The value is trimmed. It must start with # (a same-file
reference). On <image> and <feImage> only, it may instead be an embedded image:
data:image/png;base64,, data:image/jpeg;base64,, data:image/jpg;base64,,
data:image/gif;base64,, data:image/webp;base64, or data:image/svg+xml;base64, followed
by the data (the whole prefix matched case-insensitively; ;base64, is required). Anything
else rejects: image-href-not-allowed on those two elements, href-not-allowed everywhere
else, including <a href="https://..."> and href="". Nothing checks that the #id exists.
Embedded SVG. A data:image/svg+xml;base64, value is decoded (strict base64) and checked
as its own file with every rule here. Each problem found inside reports as
embedded-svg-not-allowed with the inner message as the detail. SVG inside SVG is followed
three levels deep; a fourth level rejects.
url() in attribute values. Every allowed attribute except href and style is scanned
for url( followed by optional whitespace and an optional quote and then anything but #
(case-insensitive). A match rejects with url-not-fragment and the attribute name, so
fill="url(#gradient)" and fill="url( '#gradient' )" are accepted and
fill="url(image.png)" and fill="url(https://...)" are rejected. CSS rules 1 and 2 (banned
tokens and the function allowlist, under Rules: CSS below) apply to these values too and
reject with css-not-allowed, because presentation attributes are CSS values:
fill="u\72l(...)" is url(...) to a browser and mask="image-set('https://...' 1x)" loads
that image. Values of data-* and aria-* attributes are not CSS and skip those two rules;
the url( scan above still runs on them.
Applies to the text of every <style> element (including CDATA sections and text inside
child elements) and to every style attribute; rules 1 and 2 also run on every other
attribute value except href, data-* and aria-*. All matching is case-insensitive:
- Banned tokens, which the allowlists cannot catch: a backslash
\(an escape could spell any name), and-moz-bindingandbehavior:(whitespace before the colon allowed), the properties old Firefox and IE used to attach code, whoseurl(#id)value would otherwise pass;behaviorcounts only as a whole property name, with or without-ms-, soscroll-behavior:passes and_behavior:rejects. Each rejects withcss-not-allowedand the token as the detail. - Function names. Every name directly followed by
(must be incssFunctions, elsecss-not-allowedwith the name and(as the detail (image-set(,paint(). A name right after@is an at-rule, so@media(is checked by rule 3. Outside resources load through functions, so one a browser adds later is rejected until it is listed. - At-rules. Every
@namemust be incssAtRules, elsecss-not-allowedwith the at-rule as the detail (@import). url(whose target, after optional whitespace and quote, does not start with#,data:font/, ordata:;base64,rejects with theurl(and up to 40 characters after it as the detail. The whitespace is CSS's own (space, tab, newline, form feed). A browser keeps a no-break space or other Unicode space as part of the URL, so a target that starts with one rejects.- More than 1,000,000 bytes of text in one
<style>element rejects withmore than 1,000,000 bytesas the detail. The text is held whole until the closing tag, so the limit keeps memory at 1 MB.
So fill: url(#p) passes, @font-face { src: url(data:font/woff2;base64,...) } passes
anywhere in the CSS, and background: url(https://...), @import, image-set(...) and any
escape sequence reject. Comments and strings are not skipped: content: "alert(1)" rejects
for alert(. Property names, :hover, :visited and !important are not checked. A
function in a selector (:not(), :nth-child()) goes through the function allowlist like
any other, so :host() rejects.
On <animate>, <set>, <animateTransform> and <animateMotion>:
attributeName(trimmed, and with anyprefix:removed, soxlink:hrefandq:hrefare bothhref) must be in the attributes allowlist and nothref,style,classorid, elseanimation-target-not-allowed. Those four change URLs, CSS and references after the check;data-*,aria-*andon*are not in the allowlist, so they cannot be animated either. Names are case-sensitive. The detail is the name as written.from,to,by,valuesget the same checks as any attribute value:url()must be#idand CSS functions must be listed. A bare URL needs no rule of its own:hrefcannot be animated, and on the attributes that can,to="javascript:x"orto="https://x"loads and runs nothing.
<mpath> is allowed; its href follows the href rule.
After the whole file is read (and only when it parsed cleanly), every same-file reference
that renders its target is followed: url(#id) in any attribute including style, and
href on <use>, <pattern>, <linearGradient>, <radialGradient>, <filter> and
<feImage>; href on <a> and on the animation elements is not followed, but url(#id) in
their other attributes is (<set to="url(#a)">). Each target is read the way browsers read
it before the id lookup: a quoted id runs to its closing quote (url('#a)b') is id a)b),
tabs, newlines and trailing spaces are dropped, and a percent-encoded #id is decoded
(#%61 is #a).
- A loop (
#areferences#bwhich references#a, or#areferences itself) rejects withreference-expansion-too-largeandform a loop (#a -> #b -> #a). - The elements the references would render are counted. More than 100,000 rejects with
expand to more than 100,000 elements. Five nested patterns of ten rects each is enough. - Every
idis a record, so is every distinct target of an element that renders on its own, and each reference is recorded once more per element with anidaround it; the same target inside the sameidis one record. More than 100,000 records rejects withneed more than 100,000 records to track (ids and references), so memory stays at a few MB whatever the file does. - Content inside
<defs>,<symbol>,<pattern>,<mask>,<clipPath>and<filter>counts only when something references it. A loop inside a<symbol>that nothing uses is accepted. The exception is an animation:<set to="url(#p)">inside<defs>still changes the visible element it targets, so itsurl()counts wherever it is. - References made through CSS selectors in
<style>(.a { fill: url(#p) }) are not followed. This is the known gap in the rule. <marker>is not in the elements allowlist, so it rejects withelement-not-allowedbefore any counting. Browsers draw a marker's content again at every point of a line, so a short file with a long path can keep a browser busy for minutes. Themarker-*properties stay allowed and reference nothing.
The lists as rules() returns them. Anything not here is rejected.
elements (SVG namespace):
svg g defs symbol use title desc metadata switch a view style path rect circle ellipse line polyline
polygon text tspan textPath image linearGradient radialGradient stop pattern clipPath mask filter
feBlend feColorMatrix feComponentTransfer feComposite feConvolveMatrix feDiffuseLighting
feDisplacementMap feDistantLight feDropShadow feFlood feFuncA feFuncB feFuncG feFuncR feGaussianBlur
feImage feMerge feMergeNode feMorphology feOffset fePointLight feSpecularLighting feSpotLight feTile
feTurbulence animate set animateTransform animateMotion mpath
attributes (unprefixed; plus any data-* and aria-*):
id class style lang role requiredExtensions requiredFeatures systemLanguage
externalResourcesRequired version baseProfile viewBox preserveAspectRatio zoomAndPan
contentStyleType x y width height cx cy r rx ry x1 y1 x2 y2 fx fy fr d points pathLength href
transform transform-origin transform-box dx dy rotate textLength lengthAdjust startOffset method
spacing side gradientUnits gradientTransform spreadMethod offset patternUnits patternContentUnits
patternTransform clipPathUnits maskUnits maskContentUnits refX refY filterUnits primitiveUnits
filterRes in in2 result mode type values tableValues slope intercept amplitude exponent k1 k2 k3 k4
operator radius stdDeviation edgeMode kernelMatrix order divisor bias targetX targetY
kernelUnitLength preserveAlpha surfaceScale diffuseConstant specularConstant specularExponent z
azimuth elevation pointsAtX pointsAtY pointsAtZ limitingConeAngle xChannelSelector yChannelSelector
scale baseFrequency numOctaves seed stitchTiles attributeName attributeType begin dur end min max
restart repeatCount repeatDur calcMode keyTimes keySplines from to by additive accumulate path
keyPoints origin alignment-baseline baseline-shift clip clip-path clip-rule color
color-interpolation color-interpolation-filters color-rendering direction display dominant-baseline
enable-background fill fill-opacity fill-rule filter flood-color flood-opacity font font-family
font-kerning font-size font-size-adjust font-stretch font-style font-variant font-weight
glyph-orientation-horizontal glyph-orientation-vertical image-rendering isolation kerning
letter-spacing lighting-color marker marker-end marker-mid marker-start mask mask-type
mix-blend-mode opacity overflow paint-order pointer-events shape-rendering stop-color stop-opacity
stroke stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit
stroke-opacity stroke-width text-anchor text-decoration text-rendering unicode-bidi vector-effect
visibility white-space word-spacing writing-mode
namespacedAttributes, keyed by namespace URI.
| Namespace | Attributes |
|---|---|
http://www.w3.org/XML/1998/namespace |
lang, space |
http://www.w3.org/1999/xlink |
href, title |
inertNamespaces: elements and attributes in these pass without a name check (except
on*, a <script> element, and an element written without a prefix); the attribute values
of an inert element are still checked. Matched by the start of the URI, so every namespace
under http://ns.adobe.com/ or http://purl.org/dc/ counts.
http://www.inkscape.org/namespaces/inkscape
http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd
http://www.w3.org/1999/02/22-rdf-syntax-ns#
http://www.w3.org/2000/01/rdf-schema#
http://purl.org/dc/
http://creativecommons.org/ns#
http://web.resource.org/cc/
http://ns.adobe.com/
adobe:ns:meta/
http://www.serif.com/
http://www.bohemiancoding.com/sketch/ns
http://schemas.microsoft.com/visio/2003/SVGExtensions/
http://www.w3.org/2001/XMLSchema-instance
inertProcessingInstructions: processing-instruction targets that are accepted. Adobe
tools put a pair of xpacket markers around their XMP metadata block, and nothing reads them.
Every other target rejects with processing-instruction.
xpacket
imageElements:
image, feImage
dataImageTypes:
png, jpeg, jpg, gif, webp, svg+xml
cssFunctions: function names allowed in CSS and in every attribute value except
href, data-* and aria-*, matched case-insensitively. See Rules: CSS.
url format matrix translate translatex translatey scale scalex scaley rotate skew skewx skewy rgb
rgba hsl hsla hwb lab lch oklab oklch color color-mix light-dark icc-color blur brightness contrast
drop-shadow grayscale hue-rotate invert opacity saturate sepia circle ellipse inset polygon path
rect calc var min max clamp cubic-bezier steps not is where has nth-child nth-last-child nth-of-type
nth-last-of-type lang dir
cssAtRules: at-rules allowed in CSS, without the @, matched case-insensitively.
font-face keyframes -webkit-keyframes media supports charset
The SvgSecurityCheck limits are public static properties, set before the check
(SvgSecurityCheck::$maxExpandedElements = 250000;). The memory a hostile file can cost rises
with them. The rules themselves have no options.
| Limit | Value | Source |
|---|---|---|
| Bytes searched for the root tag | 64 KB | $prologLimit |
| Errors reported per file | 50 | $maxErrors |
| Embedded SVG nesting | 3 levels | $maxEmbedDepth |
| Elements rendered through references | 100,000 | $maxExpandedElements |
| Ids and references recorded | 100,000 | $maxReferenceEntries |
Text in one <style> element |
1 MB | $maxStyleLength |
Value length in detail |
60 characters | $maxDetailLength |
| Element nesting depth | 256 | libxml2 default |
| Single text node | 10 MB | libxml2 default |
| File size | none | streamed, except embedded data: images |
The yardstick is Chrome's <img> mode: the browser parses everything, then disables script,
interaction and every fetch that is not a data: URL at render time. The rules are
stricter wherever that protection is render-time only and disappears when the file is
opened directly or rasterized on a server.
Chrome's <img> mode |
Rule here |
|---|---|
<script> parsed, never runs |
element-not-allowed |
on* attributes ignored |
event-handler |
javascript: and https: hrefs inert |
href-not-allowed |
external url() silently not loaded |
url-not-fragment, css-not-allowed |
<?xml-stylesheet href="#id"?> processed |
processing-instruction |
<foreignObject> rendered without script |
element-not-allowed |
| DTD entities expanded | doctype-not-allowed |
| reference loops broken by the renderer | reference-expansion-too-large |
SVG inside <image> rendered in secure mode |
checked with every rule |
Looser than Chrome, or not mirrored: :hover, :visited, :focus in CSS and
begin="click" in SMIL are accepted (Chrome's <img> mode makes them do nothing; opened
directly they work; nothing loads or runs either way). Two elements are rejected as unknown
although they carry no attack: <animateColor> and <discard>, which no design tool exports.
-
No cleaned output. The result is accept or reject. Store the original bytes or refuse.
-
No line numbers. A
Violationhas no line or column property; the one exception is the libxml2 message inside amalformed-xmldetail, which names a line. -
Same problem once. Five
<script>elements report one error; five differenton*attributes report five. -
A small malformed file reports only
malformed-xml. libxml2 parses in chunks and discards the chunk containing the error, so errors in the same chunk are not seen. A large file reports the errors found before the broken chunk, thenmalformed-xmllast. An undefined namespace prefix does not stop libxml2, so the element is reported as well. -
50 errors and the check stops. Nothing is read past the fiftieth, including a parse error later in the file.
-
detailandmessageare unencoded text from the file. Always one line of valid UTF-8 (control characters and bytes that are not UTF-8 are escaped, as\nor\351), but they can hold</script>, quotes and backticks. HTML-encode for a page. A JSON response withContent-Type: application/jsonneeds nothing extra on the server, and the page script inserts the text withtextContent, notinnerHTML. Inside a<script>block:$json = json_encode($violation->message, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
-
Element and attribute names are case-sensitive;
on*detection,<script>in other namespaces, data image types, and CSS tokens, functions and at-rules are case-insensitive. -
hrefis trimmed before checking, sohref=" #a"passes;href=""rejects with(empty). -
Not checked: whether a
#idtarget exists, the file extension, the MIME type, file size, CSS property names and selectors without functions, and references made through CSS selectors in<style>. -
Serving an accepted file: it is safe to open directly with no special headers, and no script runs and nothing loads from outside even if it is served as
text/html, because the element prefix,xmlns:xlink, comment, CDATA, processing-instruction and text-only<title>/<desc>rules close the places where an HTML parser reads the file differently from XML. Save it under a.svgname the code picks, never the uploaded name, since a server sets the content type from the extension. Refuse.svgzuploads; this library reads only the bytes. -
Extra headers are a second layer.
X-Content-Type-Options: nosniffandContent-Security-Policy: sandboxblock script in case a rule is ever bypassed. The rules do not depend on them. -
Not for inline SVG. An accepted file is safe to serve as its own document or through
<img>. SVG source written straight into an HTML page becomes part of that page: its<style>rules apply to the whole page and its ids mix with the page's own, which these rules do not check. Show an accepted file with<img src>, and sanitize inline SVG on output with DOMPurify. -
Not configurable. There is no way to allow an extra element or attribute. Open an issue with the file that was rejected. The size limits are the one exception, see Limits.