SVG 검사기
SVG를 XML로 검사하고 실행 가능한 요소를 표시. 렌더링은 하지 않습니다.
모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.
위에 SVG를 붙여넣거나 파일을 편집기에 끌어다 놓으면 XML로 검사합니다. 문법 오류를 한 번에 모두, 각각 줄과 열과 고치는 법을 붙여서 보여 줍니다. SVG는 XML 어휘이므로, 빌드 도구나 아이콘 파이프라인, 브라우저가 거부하는 SVG는 대개 SVG 규칙이 아니라 XML 규칙을 어기고 있습니다.
Figma나 Illustrator, Inkscape에서 내보낸 파일이 번들러를 망가뜨릴 때, 아이콘이 하얗게 나와서 파일이 파싱은 되는지부터 알아야 할 때, 또는 사용자가 올린 SVG를 브라우저 근처에 보내기 전에 읽어 보고 싶을 때 쓰세요.
여기서의 차이는 이 페이지가 하지 않기로 한 일에 있습니다. 대부분의 SVG 검사기는 소스 옆에 여러분의 파일을 그려 보여 주며, 그 안의 스크립트를 실행할 바로 그 렌더링 엔진에 파일을 넘깁니다. 이 페이지는 결코 렌더링하지 않습니다. 여러분의 마크업은 편집기 안의 텍스트로 남습니다. 페이지가 보여 주는 값은 모두 textContent나 createElement로 쓰므로 파일의 어느 부분도 innerHTML에 닿지 않고, 아무것도 업로드되지 않습니다.
SVG는 브라우저가 실행하는 XML
SVG는 <script> 요소를 담을 수 있고, 어떤 도형에든 onload나 onmouseover 속성을 붙일 수 있으며, javascript:로 시작하는 href도, 임의의 HTML을 품은 <foreignObject>도 가질 수 있습니다. 전부 적법한 SVG이고 전부 정형식 XML입니다. 그래서 문법 검사만으로는 어떤 파일이 안전하다고 결코 말할 수 없습니다.
그 내용이 언제 실행되는지는 파일을 어떻게 쓰느냐에 달렸습니다. <img> 태그나 CSS 배경으로 불러온 SVG는 스크립트를 실행하지 않고 주변 페이지에 손을 뻗지도 못합니다. 여러분의 DOM에 인라인으로 넣거나 자기 URL로 열면, 그것을 내어 준 출처의 권한으로 실행됩니다. 이 두 번째 경우가 업로드 기능을 저장형 크로스사이트 스크립팅으로 바꿔 놓습니다.
그래서 이 페이지는 XML을 검사하되, 그 파일을 실행하는 당사자가 되기를 거부합니다. <script> 요소는 문법 오류로 보고하지 않습니다. 실제로 오류가 아니기 때문입니다. 여러분이 받는 것은 정형식 문제를 모두 표시한 강조 텍스트와, 실행 가능한 내용에 대한 검사를 제자리인 빌드 단계에 넣을 수 있도록 아래에 둔 감사 코드입니다.
XML의 실패이기도 한 SVG의 실패
파서가 거부하는 SVG의 거의 전부가 이 항목들로 설명됩니다. 「잘못된 SVG」 한마디가 아니라 각각 정확한 위치와 함께 보고합니다.
- xmlns:xlink="http://www.w3.org/1999/xlink"가 유효 범위에 없는데 xlink:href를 쓴 경우. 묶이지 않은 접두사는 Namespaces in XML에서 치명적 오류이며, 스프라이트 시트에서 떼어 낸 조각에서 끊임없이 나타납니다.
- <path>, <image>, <use>, <stop>을 닫지 않은 경우. HTML에는 빈 요소가 있지만 XML에는 없으므로, 닫는 슬래시가 없는 <path d="...">는 닫히지 않은 태그이고, 오류는 한참 뒤 짝이 맞지 않게 된 태그에서 드러납니다.
- xlink:href의 쿼리 문자열 안에 그대로 쓴 앰퍼샌드. 속성값 안에서는 &로 써야 합니다. 또는 style 속성 안의 리터럴 <. XML은 >는 허용하면서도 그 자리의 <는 금지합니다.
- 한 요소에 같은 속성이 두 번. 변환을 잘못 합칠 때 일부 내보내기 파이프라인이 만들어 냅니다.
- SVG 1.1 DTD를 가리키는 옛 Illustrator DOCTYPE. 보고는 하되 결코 가져오지 않습니다. 여기서 그 DTD로 아무것도 검증하지 않는 이유이기도 합니다.
- 수백 메가바이트로 부풀어 오르는 내부 엔티티 서브셋. 업로드 폼이 받아 주기 때문에 SVG는 이 공격의 운반체로 즐겨 쓰입니다. 비용은 선언으로부터 계산하고, 파일은 2밀리초쯤에 거부합니다.
정형식 검사가 말해 줄 수 없는 것
이 점을 분명히 적는 편이 초록색 체크 표시보다 쓸모 있습니다. 아래 파일들은 모두 정형식 XML이고, 여기를 통과하며, 그래도 SVG로서는 망가져 있습니다.
- 루트 요소에 xmlns="http://www.w3.org/2000/svg"가 없는 경우. HTML 안에 인라인으로 넣으면 대개 그려집니다. HTML 파서가 SVG 이름공간을 가정하기 때문입니다. 하지만 <img>로 불러오거나 image/svg+xml로 내보내면 아무것도 그려지지 않습니다. 「내 SVG가 하얗게 나와요」의 가장 흔한 원인입니다.
- viewBox 대신 viewbox. XML 속성 이름은 대소문자를 구분하고 SVG는 낙타 표기만 정의하므로, 브라우저는 잘못 쓴 이름을 무시하고 아이콘은 실패하는 대신 엉뚱하게 확대·축소됩니다.
- viewBox 없이 width와 height만 준 경우. 크기가 조절되지 않는 이미지가 됩니다. 너비나 높이가 0인 viewBox는 아무것도 그리지 않습니다.
- 그러데이션, 필터, <use> 참조를 위해 다른 파일을 가리키는 xlink:href. 파일이 내 디스크에 있을 때는 해석되지만, SVG를 인라인으로 넣거나 다른 출처에서 내보내는 순간 깨집니다. 디자이너의 기계에만 있는 글꼴로 짠 <text> 요소도 마찬가지입니다.
올라온 SVG가 브라우저에 닿기 전에
그 파일이 사용자에게서 왔다면, 여기서 읽어 보는 것은 마지막 단계가 아니라 첫 단계로 여기세요. script라는 낱말로 맞춰 보는 것은 방어가 아닙니다. 페이로드는 base64 데이터 URI에, <set attributeName="onload">에, animate 요소에, 그리고 순진한 필터가 그냥 지나치는 이름공간 속임수에 숨습니다.
믿을 만한 선택지는 문자열을 훑는 대신 파싱하는 새니타이저(Node나 브라우저에서는 DOMPurify의 SVG 프로파일, PHP에서는 enshrined/svg-sanitize)이거나, 서버에서 PNG로 래스터화하는 것입니다. 원본을 꼭 내보내야 한다면 Content-Security-Policy를 설정한 별도 출처에서 내보내고, 인라인 대신 <img>로 참조하세요. 이 페이지는 보고할 뿐, 청소하지는 않습니다.
코드에서 SVG 감사하기
먼저 파일을 안전하게 파싱한 다음, 브라우저가 실행할 수 있는 것을 찾아 훑으세요. 아래 예제는 모두 엔티티 해석과 네트워크 접근을 먼저 끕니다. DOCTYPE을 실은 SVG는 평범한 XML 문서와 똑같은 의미에서 XXE 페이로드이기 때문입니다.
// Parses the SVG and lists what a browser could execute. Nothing here
// inserts the markup into the page, which is what keeps it safe to run
// against a file you do not trust.
const EXECUTABLE = ['javascript:', 'vbscript:', 'data:text/html'];
function auditSvg(source) {
const doc = new DOMParser().parseFromString(source, 'image/svg+xml');
if (doc.querySelector('parsererror')) {
return { wellFormed: false, findings: [] };
}
const findings = [];
const walk = (node) => {
const tag = node.localName.toLowerCase();
if (tag === 'script' || tag === 'handler' || tag === 'foreignobject') {
findings.push(node.nodeName + ' element');
}
for (const attr of node.attributes) {
const value = attr.value.trim().toLowerCase();
if (attr.name.toLowerCase().startsWith('on')) {
findings.push(attr.name + ' event handler');
}
if (EXECUTABLE.some((scheme) => value.startsWith(scheme))) {
findings.push('executable URI in ' + attr.name);
}
}
for (const child of node.children) walk(child);
};
walk(doc.documentElement);
return { wellFormed: true, findings };
}# resolve_entities and no_network are the flags that matter: an SVG with a
# DOCTYPE is an XXE vector like any other XML document.
from lxml import etree
DANGEROUS = {'script', 'handler', 'foreignObject'}
EXECUTABLE = ('javascript:', 'vbscript:', 'data:text/html')
parser = etree.XMLParser(
resolve_entities=False,
no_network=True,
load_dtd=False,
huge_tree=False,
)
root = etree.fromstring(svg_bytes, parser) # XMLSyntaxError if malformed
for el in root.iter():
if not isinstance(el.tag, str):
continue # comment or processing instruction
if etree.QName(el).localname in DANGEROUS:
print(el.sourceline, etree.QName(el).localname, 'element')
for name, value in el.attrib.items():
local = etree.QName(name).localname if name.startswith('{') else name
if local.lower().startswith('on'):
print(el.sourceline, name, 'event handler')
if value.strip().lower().startswith(EXECUTABLE):
print(el.sourceline, name, 'executable URI')import javax.xml.XMLConstants;
import javax.xml.parsers.SAXParserFactory;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.helpers.DefaultHandler;
import java.util.Locale;
import java.util.Set;
SAXParserFactory factory = SAXParserFactory.newInstance();
factory.setNamespaceAware(true);
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
// Old Illustrator exports carry a DOCTYPE. Drop the next line only if you
// must accept them, and keep the two features above false either way.
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
final Set<String> dangerous = Set.of("script", "handler", "foreignObject");
factory.newSAXParser().parse(
new InputSource(new java.io.StringReader(svg)),
new DefaultHandler() {
@Override
public void startElement(String uri, String local, String qName, Attributes atts) {
if (dangerous.contains(local)) {
System.out.println("element: " + qName);
}
for (int i = 0; i < atts.getLength(); i++) {
String name = atts.getQName(i);
String value = atts.getValue(i).trim().toLowerCase(Locale.ROOT);
if (name.toLowerCase(Locale.ROOT).startsWith("on")) {
System.out.println("event handler: " + name);
}
if (value.startsWith("javascript:") || value.startsWith("data:text/html")) {
System.out.println("executable URI in " + name);
}
}
}
});using System;
using System.Xml;
var dangerous = new[] { "script", "handler", "foreignObject" };
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit, // no DOCTYPE, no entity expansion
XmlResolver = null, // never fetch anything
MaxCharactersFromEntities = 1024 * 1024,
};
using var reader = XmlReader.Create(new StringReader(svg), settings);
var lineInfo = (IXmlLineInfo)reader;
while (reader.Read())
{
if (reader.NodeType != XmlNodeType.Element) continue;
if (Array.IndexOf(dangerous, reader.LocalName) >= 0)
Console.WriteLine($"line {lineInfo.LineNumber}: <{reader.Name}>");
while (reader.MoveToNextAttribute())
{
var value = reader.Value.Trim().ToLowerInvariant();
if (reader.LocalName.StartsWith("on", StringComparison.OrdinalIgnoreCase))
Console.WriteLine($"line {lineInfo.LineNumber}: {reader.Name} handler");
if (value.StartsWith("javascript:") || value.StartsWith("data:text/html"))
Console.WriteLine($"line {lineInfo.LineNumber}: URI in {reader.Name}");
}
reader.MoveToElement();
}<?php
// LIBXML_NONET blocks every external fetch, including a DOCTYPE's DTD.
libxml_use_internal_errors(true);
$doc = new DOMDocument();
if (!$doc->loadXML($svg, LIBXML_NONET)) {
fwrite(STDERR, "not well-formed XML\n");
exit(1);
}
$xpath = new DOMXPath($doc);
$xpath->registerNamespace('svg', 'http://www.w3.org/2000/svg');
foreach ($xpath->query('//svg:script | //svg:handler | //svg:foreignObject') as $node) {
printf("%s element on line %d\n", $node->nodeName, $node->getLineNo());
}
foreach ($xpath->query('//@*') as $attr) {
$name = strtolower($attr->nodeName);
$value = strtolower(trim($attr->nodeValue));
if (str_starts_with($name, 'on') || str_starts_with($value, 'javascript:')) {
printf("%s on line %d\n", $attr->nodeName, $attr->getLineNo());
}
}
// To clean rather than report, use enshrined/svg-sanitize.# Well-formedness first. An SVG that fails here fails as XML.
xmllint --noout --nonet icon.svg
# A smoke test for executable content. This is a grep, not a sanitiser: it
# misses base64 payloads, <set attributeName="onload">, and obfuscation.
grep -niE 'script|foreignObject|on[a-z]+ *=|javascript:|data:text/html' icon.svg
# Check a directory of exports and keep going after each failure:
find . -name '*.svg' -print0 | xargs -0 -n1 xmllint --noout --nonet
# svgo is a minifier and does not remove script by default.
# For sanitising, use DOMPurify in Node with its SVG profile.태그 이름과 속성 이름의 거부 목록은 보고용 도구이지 보안 경계가 아닙니다. 실제로 브라우저에 내보내는 것은 문서를 파싱해 허용 목록으로 다시 짓는 새니타이저를 거치거나, PNG로 래스터화해야 합니다.
자주 묻는 질문
제 SVG가 업로드되나요? 어딘가에 표시되나요?
둘 다 아닙니다. 파서는 이 탭에서 도는 자바스크립트라 파일이 전송되는 일이 없고, 페이지는 일부러 렌더링하지 않습니다. 미리보기 창도 없고, 여러분의 마크업을 가리키는 <img>도 없으며, innerHTML에 닿는 경로도 없습니다.
이 점은 다른 도구보다 여기서 더 중요합니다. 스크립트를 실은 SVG는 무언가가 그것을 렌더링할 때 비로소 위험해지기 때문입니다. 미리보기를 보여 주는 검사기는 신뢰할 수 없는 여러분의 파일을 자기 출처에서 실행하고 있는 셈입니다. 입력은 새로 고쳐도 잃지 않도록 이 브라우저의 localStorage에 보관합니다. 그것은 여러분의 기계에 머물고, 「지우기」 버튼으로 사라지며, 30만 자가 넘는 파일에서는 하지 않습니다.
SVG 파일에 자바스크립트가 들어갈 수 있나요?
들어갈 수 있습니다. SVG에는 <script> 요소가 있고, 어떤 요소에든 onload나 onclick 같은 이벤트 처리기 속성을 둘 수 있으며, javascript:로 시작하는 href 값도, 내장 HTML을 위한 <foreignObject>도 있습니다. 그 어느 것도 파일을 비정형으로 만들지 않습니다. 모두 형식의 일부입니다.
실행되는지는 파일을 어떻게 쓰느냐에 달렸습니다. <img> 태그나 CSS 배경을 거치면 스크립트는 실행되지 않습니다. 페이지에 인라인으로 넣거나 자기 URL로 열면, 그것을 내어 준 출처의 권한으로 실행됩니다. 아바타 업로드가 저장형 크로스사이트 스크립팅이 되는 길이 바로 이것입니다. 내보내기 전에 새니타이즈하고, 인라인보다 <img>를 택하세요.
이 검사는 통과하는데 SVG가 하얗게 나오는 이유는 무엇인가요?
거의 언제나 이름공간이 빠진 탓입니다. 루트 요소에는 xmlns="http://www.w3.org/2000/svg"가 필요합니다. 없어도 파일은 여전히 정형식 XML이고, HTML에 인라인으로 붙여넣으면 HTML 파서가 이름공간을 짐작하기 때문에 대개 그려지지만, <img>로 불러오거나 image/svg+xml로 내보내면 아무것도 나오지 않습니다.
다음으로 흔한 원인은 viewbox로 잘못 쓴 viewBox(XML 속성 이름은 대소문자를 구분하므로 브라우저가 무시합니다), 너비나 높이가 0인 viewBox, 그리고 이제는 옆에 없는 파일의 그러데이션을 가리키는 참조입니다. 어느 것도 XML 오류가 아니므로 문법 검사기는 보고하지 않습니다.
SVG 명세에 비추어 검증하나요?
아닙니다. 그런 척하지 않고 분명히 적어 둡니다. 검사하는 것은 파일이 정형식 XML인지, 그리고 이름공간 접두사가 선언되어 있는지입니다. <circle>에 r 속성이 있는지, path의 d 데이터가 파싱되는지, 길이 값이 적법한지는 보지 않습니다.
SVG 1.1에는 DTD가 있고 여기 DTD 검사기로 문서를 그것에 비추어 볼 수도 있지만, 어떤 브라우저도 그 DTD로 SVG를 검증하지 않고, SVG 2는 DTD를 아예 정의하지 않으며, 완벽하게 그려지는 파일도 여럿 떨어집니다. 정형식 여부에 더해 그 파일이 무엇을 실행하는지 살펴보는 것, 그것이 SVG의 실제 쓰임에 맞는 검사입니다.
「접두사 xlink가 이름공간에 묶여 있지 않습니다」는 무슨 뜻인가요?
파일 어딘가에서 xlink:href를 쓰면서 유효 범위에 xmlns:xlink="http://www.w3.org/1999/xlink" 선언이 없다는 뜻입니다. 접두사는 묶이기 전에는 의미가 없으므로 이것은 경고가 아니라 치명적 오류이고, 적합한 파서라면 모두 그 파일을 거부합니다.
대개는 스프라이트 시트에서 조각을 복사하면서 선언은 원래 루트에 두고 온 뒤에 나타납니다. 고치는 법은 지금 가진 파일의 루트 요소에 그 선언을 더하는 것입니다. SVG 2에서는 접두사가 필요 없는 href 속성이 xlink:href를 대신했고 요즘 브라우저는 이를 지원하므로, 여러분이 관리하는 파일이라면 접두사를 버리는 편이 더 간단할 때가 많습니다.