DTD 검사기
내부든 붙여넣기든 DTD로 검사. 업로드는 없습니다.
모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.
내부 서브셋, 곧 DOCTYPE의 대괄호 사이에 있는 선언을 담은 문서를 붙여넣고 「DTD로 검사」를 누르세요. DTD가 별도 파일에 있다면 오른쪽 창에 붙여넣으면 됩니다. 어느 쪽이든 WebAssembly로 컴파일된 libxml2가 이 탭 안에서 검사를 돌리고, 선언되지 않은 요소, 내용 모델 위반, 속성 문제를 줄과 열과 함께 알려 줍니다.
이것은 다른 어떤 무료 온라인 도구도 제공하지 않는 검사입니다. 찾아보면 나오는 것이라고는 데스크톱 제품의 홍보 페이지, 튜토리얼 사이트, O’Reilly 책의 발췌, 그리고 경로에 previous-versions가 들어간 마이크로소프트 URL입니다. 그러는 사이에도 이 형식은 날마다 쓰입니다. 학술지 출판에서, 애플의 속성 목록에서, DocBook 4 툴체인에서, 그리고 길게 이어지는 EDI 파이프라인에서.
시작하기 전에 알아 둘 동작이 하나 있습니다. SYSTEM이나 PUBLIC으로 참조된 외부 DTD는 결코 가져오지 않습니다. 의도한 것이고, 이유는 아래에 있습니다.
붙여넣은 DTD는 어떻게 적용되나
DTD는 XSD처럼 별개의 문서가 아니라 문서 프롤로그의 일부입니다. 그래서 붙여넣은 DTD는 파싱 전에 끼워 넣습니다. 도구는 첫 시작 태그를 DOCTYPE 이름으로 삼고, 이미 있던 DOCTYPE은 버리고, XML 선언은 남기고, 여러분의 선언을 내부 서브셋으로 써 넣습니다. 외부 서브셋을 불러오는 대신 다시 쓰는 방식이므로, 파일이나 URL을 열 수 있는 코드 경로 자체가 존재하지 않습니다.
여기에는 한 가지 결과가 따르는데, 이 도구가 아니라 XML 명세에서 오는 것입니다. 내부 서브셋에서 파라미터 엔티티 참조는 마크업 선언이 올 수 있는 자리에는 올 수 있지만 선언 안에는 올 수 없습니다. 그래서 <!ELEMENT article (%block.mix;)>를 담은 DTD는 외부 서브셋으로는 적법하지만 여기에 붙여넣으면 오류가 됩니다. JATS나 DocBook처럼 .ent 모듈로 조립한 DTD라면 먼저 하나로 펼쳐 주세요.
문법, 그리고 사람들이 걸려 넘어지는 곳
DTD에는 선언이 네 종류 있고 짧고 날카로운 문법이 있습니다. 요소 선언은 내용 모델을 주고, 속성 목록 선언은 형과 기본값을 주고, 엔티티 선언은 텍스트 치환을 주고, 표기법 선언은 외부 형식에 이름을 붙입니다. 대부분이 걸리는 곳은 혼합 내용의 표기입니다. 텍스트와 자식을 함께 담는 요소는 정확히 (#PCDATA | child)* 형태로, #PCDATA를 맨 앞에 두고 그룹 전체를 반복 가능하게 써야 합니다. (#PCDATA, em)*는 다른 뜻이 아니라 문법 오류입니다.
- <!ELEMENT catalog (product+)>는 자식을 선언합니다. 연산자는 나열의 , 와 선택의 |, 그리고 선택적·0회 이상·1회 이상을 뜻하는 ? * + 입니다. EMPTY와 ANY가 특별한 모델입니다.
- <!ATTLIST product id ID #REQUIRED status (draft|live) "draft">는 속성을 선언합니다. 기본값은 #REQUIRED, #IMPLIED, #FIXED "값", 또는 리터럴 기본값입니다.
- 속성 형은 CDATA, 토큰화된 ID, IDREF, IDREFS, ENTITY, ENTITIES, NMTOKEN, NMTOKENS, 그리고 열거입니다. 한 요소가 가질 수 있는 ID 속성은 최대 하나입니다.
- ID 값은 XML 이름이어야 하므로 id="1"은 무효이고 id="n1"은 괜찮습니다. XSD의 xs:ID도 같은 규칙을 물려받습니다.
- <!ENTITY corp "Acme Ltd.">는 내부 일반 엔티티를 선언합니다. SYSTEM과 PUBLIC은 외부 엔티티를 선언하고, NDATA는 NOTATION이 필요한 비파싱 엔티티를 표시합니다.
- <!ENTITY % common SYSTEM "common.mod">는 파라미터 엔티티를 선언하며 %common;으로 참조합니다. 이것이 DTD의 유일한 모듈화 수단입니다.
외부 DTD를 결코 가져오지 않는 것, 그것이 핵심
문서에 <!DOCTYPE article SYSTEM "JATS-journalpublishing1.dtd">라고 적혀 있으면, 그것을 존중하는 검사기는 그 파일을 가지러 가야 합니다. 여러분의 기계에서, 여러분의 네트워크로, 남이 쓴 내용이 몰고 가는 요청입니다. 여기서는 그런 일을 하지 않습니다. 모든 파싱이 XML_PARSE_NO_XXE와 NONET을 쓰고, 이 빌드는 리소스 로더를 전혀 등록하지 않으므로 SYSTEM 식별자는 URL이든 경로든 아무것으로도 해석되지 않습니다.
DTD는 XML의 고전적인 공격면이고, 그래서 이것은 한계가 아니라 성질입니다. DOCTYPE 안의 외부 엔티티 선언이 곧 XXE 전부입니다. file:// 식별자를 요소 안으로 읽어 들이기, 경계 안쪽에서 인트라넷 주소 떠보기, 파라미터 엔티티로 대역 외 통로 만들기. 중첩 엔티티는 billion laughs의 수법이며, libxml2는 자기 문서에서 신뢰할 수 없는 입력에 DTD 검증을 켜서는 안 된다고 경고합니다. XML_PARSE_HUGE도 결코 켜지 않으므로 상한이 그대로 남습니다. 요소 깊이 256, 엔티티 중첩 20, 그리고 증폭 한도.
DTD가 지금도 진짜로 쓰이는 곳
DTD는 XML 명세 자체에 들어 있는 유일한 스키마 언어라서, 적합한 파서라면 어떤 것이든 추가 의존성 없이 DTD로 검증할 수 있습니다. 스키마 라이브러리를 더하는 선택지가 없는 자리에서 살아남는 이유입니다. 지금 가장 튼튼하게 살아 있는 생태계는 학술 출판입니다. 학술지 논문 XML의 NISO Z39.96 표준인 JATS는 2024년 11월에 1.4 DOCTYPE을 추가했습니다.
- JATS와 BITS, 학술지와 도서 출판에서. DTD, RELAX NG, W3C Schema 판으로 배포되지만 대부분의 입수 시스템이 검사하는 것은 DTD입니다.
- 애플의 속성 목록. macOS와 iOS 도구가 내놓는 모든 plist는 지금도 PLIST 1.0의 PUBLIC 식별자를 답니다.
- DocBook 4.x, 옮겨 가지 않은 툴체인에서. DocBook 5.0은 RELAX NG로 바꾸었으니 자기 버전을 확인하세요.
- TEI. 주 스키마는 RELAX NG이지만 지금도 DTD 파생본을 생성합니다.
- XHTML 독타입. 이제는 거의 장식이지만 일부 출판 파이프라인에서는 아직 검사합니다.
- 오래된 SOAP과 EDI 파이프라인. DTD를 한 번 써 놓은 뒤 형식이 그대로인 곳들입니다.
코드에서 DTD로 검증하기
DTD 검증은 안전한 기본값과 원하는 기능을 정면으로 부딪치게 만듭니다. 표준적인 강화 지침은 DOCTYPE 선언을 금지하라는 것인데, 그렇게 하면서 DTD로 검증할 수는 없습니다. 각 예제는 선언은 남겨 두고 대신 외부 해석을 꺼 둡니다.
// npm i libxml2-wasm, the same engine this page runs.
import { XmlDocument, ParseOption } from 'libxml2-wasm';
// DTDVALID turns validation on. NO_XXE keeps the external subset and any
// external entities unresolved, which is what makes this safe on input you
// did not write.
const OPTS =
ParseOption.XML_PARSE_DTDVALID |
ParseOption.XML_PARSE_NO_XXE |
ParseOption.XML_PARSE_NONET;
export function validateDtd(xmlText, dtdText) {
// A separately supplied DTD is spliced in as an internal subset rather than
// loaded, exactly as this page does it.
let source = xmlText;
if (dtdText) {
const root = xmlText.match(/<([A-Za-z_:][\w.\-:]*)[\s>/]/)?.[1] ?? 'root';
const stripped = xmlText.replace(/<!DOCTYPE[^[>]*(\[[\s\S]*?\])?\s*>/, '');
source = `<!DOCTYPE ${root} [\n${dtdText}\n]>\n${stripped.trimStart()}`;
}
let doc = null;
try {
doc = XmlDocument.fromString(source, { option: OPTS });
return { valid: true, errors: [] };
} catch (err) {
// libxml2 reports a broken document and a document that does not follow
// the DTD through the same exception. details is [{ line, col, message }].
const details = err?.details ?? [{ message: String(err?.message ?? err) }];
return { valid: false, errors: details };
} finally {
doc?.dispose();
}
}# pip install lxml
from io import StringIO
from lxml import etree
parser = etree.XMLParser(
resolve_entities=False, # do not expand entities from the DOCTYPE
no_network=True, # never fetch an external subset
huge_tree=False, # keep the depth and amplification limits
)
doc = etree.parse('article.xml', parser)
# A DTD held separately: validate without touching the document's own prolog.
with open('article.dtd', encoding='utf-8') as fh:
dtd = etree.DTD(StringIO(fh.read()))
if dtd.validate(doc):
print('valid')
else:
for e in dtd.error_log.filter_from_errors():
print(f'{e.line}:{e.column} {e.message}')
# To validate against an internal subset instead, parse with dtd_validation=True
# and read parser.error_log:
# etree.XMLParser(dtd_validation=True, no_network=True, resolve_entities=False)import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.xml.sax.InputSource;
import org.xml.sax.SAXParseException;
import org.xml.sax.helpers.DefaultHandler;
import java.io.StringReader;
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setValidating(true); // DTD validation on
factory.setNamespaceAware(true);
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
// disallow-doctype-decl CANNOT be used here: it would reject the DOCTYPE you
// are trying to validate against. Block the fetch, not the declaration.
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
DocumentBuilder builder = factory.newDocumentBuilder();
// Supply the DTD text yourself instead of letting the parser go and find it.
builder.setEntityResolver((publicId, systemId) ->
new InputSource(new StringReader(dtdText)));
builder.setErrorHandler(new DefaultHandler() {
@Override public void error(SAXParseException e) {
System.out.printf("%d:%d %s%n", e.getLineNumber(), e.getColumnNumber(), e.getMessage());
}
});
builder.parse(new InputSource(new StringReader(xmlText)));using System;
using System.Xml;
var settings = new XmlReaderSettings
{
// Parse, not Prohibit: DTD validation needs the DOCTYPE read.
DtdProcessing = DtdProcessing.Parse,
ValidationType = ValidationType.DTD,
// A null resolver is what stops an external subset or entity being
// fetched. This is the single most important line here.
XmlResolver = null,
MaxCharactersFromEntities = 1024 * 1024,
MaxCharactersInDocument = 20L * 1024 * 1024,
};
settings.ValidationEventHandler += (_, e) =>
Console.WriteLine($"{e.Exception.LineNumber}:{e.Exception.LinePosition} {e.Message}");
using var reader = XmlReader.Create("article.xml", settings);
while (reader.Read()) { } // errors arrive through the handler as it reads
// With XmlResolver = null, a document whose DTD lives in a separate file will
// report that no DTD was found. Inline the declarations into the DOCTYPE first.<?php
// LIBXML_DTDVALID validates against the DOCTYPE. LIBXML_NONET blocks the fetch.
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$loaded = $doc->loadXML($source, LIBXML_DTDVALID | LIBXML_NONET);
foreach (libxml_get_errors() as $e) {
// level 1 is a warning, 2 an error, 3 fatal.
fprintf(STDERR, "%d:%d %s\n", $e->line, $e->column, trim($e->message));
}
libxml_clear_errors();
if (!$loaded) {
exit(1);
}
// DOMDocument::validate() runs the same check on an already-parsed document,
// which is useful when you want the tree even if it turns out to be invalid.# Validate against the document's own internal subset or DOCTYPE:
xmllint --noout --nonet --valid article.xml
# Validate against a DTD held separately, ignoring any DOCTYPE in the file.
# This is the form for JATS, DocBook and other modular DTDs: xmllint resolves
# the .ent modules from disk, which a browser cannot do.
xmllint --noout --nonet --dtdvalid JATS-journalpublishing1.dtd article.xml
# Exit status is 0 when the document is valid. --nonet is not the default, and
# without it xmllint will happily fetch a DTD over HTTP.Java와 .NET 예제는 같은 긴장을 양쪽에서 보여 줍니다. 표준적인 XXE 완화책은 DTD 검증과 양립하지 않으므로, 실제로 통하는 강화는 선언은 유지하고 리졸버를 없애는 것입니다.
자주 묻는 질문
문서가 참조하는 DTD를 왜 가져오지 않나요?
그 요청은 여러분이 통제하지 못할 수도 있는 내용이 몰고 가는 것이고, DTD야말로 XML에서 가장 나쁜 보안 문제가 사는 곳이기 때문입니다. 로컬 파일을 요소 안으로 읽어 들이는 외부 엔티티, 네트워크 안쪽에서 인트라넷 주소를 떠보는 외부 엔티티. 확실히 막는 방법은 리졸버를 아예 등록하지 않는 것입니다.
대가는 수동 단계 하나입니다. DTD를 직접 받아서 붙여넣으세요.
붙여넣은 것이 브라우저 밖으로 나가나요?
아닙니다. libxml2는 이 탭의 웹 워커 안에서 WebAssembly로 돌기 때문에 문서도 DTD도 여기에 머뭅니다. 업로드 엔드포인트도 없고, 편집기에 접근하는 분석 도구도 없습니다.
이 점은 지금도 DTD를 쓰는 사람들에게 중요합니다. 엠바고가 걸린 학술 논문, 고객 기기에서 뽑아낸 plist, 협력사 가격이 담긴 EDI 메시지. 어느 것도 폼 전송에 실릴 것이 아닙니다.
DocBook, JATS, XHTML의 DTD로 검증할 수 있나요?
하나로 펼친 단일 파일로 가지고 있다면 가능합니다. 모듈 구성 계열의 최상위 DTD로는 되지 않습니다. 그것들은 파라미터 엔티티와 SYSTEM 식별자로 .ent와 .mod 파일을 끌어오는데, 그런 참조는 여기서 하나도 해석되지 않습니다.
명세 규칙도 있습니다. 내부 서브셋에서 파라미터 엔티티 참조는 선언 하나를 통째로 대신할 수는 있어도 선언 안에는 들어갈 수 없으므로, (%block.mix;)는 전개가 아니라 오류입니다. 이런 계열에는 로컬에서 xmllint의 --dtdvalid를 쓰세요.
DTD를 써야 하나요, XSD를 써야 하나요?
데이터 형, 이름공간, 또는 「선택적」과 「반복 가능」보다 정밀한 개수 제약이 필요하면 XSD를 쓰세요. DTD는 어떤 필드가 1에서 99 사이의 정수라고 말하지 못하고, 접두사가 붙은 이름을 글자 그대로 맞추기 때문에 같은 이름공간을 다른 접두사에 묶은 문서는 통과하지 못합니다.
여러분이 데이터를 넣는 시스템이 DTD를 요구한다면 DTD를 쓰세요. 출판 쪽 대부분이 여기에 해당합니다. 라이브러리가 전혀 필요 없다는 점이 마음에 든다면 그것도 이유가 됩니다. 40줄짜리 DTD가 150줄짜리 XSD만큼을 말해 줍니다.
id="1" 속성이 왜 거부되나요?
ID 값은 XML 이름이어야 하는데, 이름은 숫자로 시작할 수 없기 때문입니다. 값 앞에 글자나 밑줄을 붙이면 오류가 사라집니다.
관련된 규칙 둘이 사람들을 걸려 넘어지게 합니다. 한 요소가 가질 수 있는 ID 속성은 최대 하나이며, 그것은 #REQUIRED나 #IMPLIED로 선언해야 하고 기본값을 붙여서는 안 됩니다. XSD의 xs:ID도 같은 제약을 물려받습니다.
남에게서 받은 문서를 검증해도 안전한가요?
여기서는 안전합니다. 그렇게 말할 수 있는 일이 드물어서 굳이 적어 둡니다. DTD에서의 위험은 외부 해석과 엔티티 전개 둘인데, 둘 다 막혀 있습니다. 리소스 로더를 등록하지 않고, XML_PARSE_HUGE를 결코 켜지 않으므로 깊이 상한 256, 엔티티 중첩 상한 20, 증폭 한도가 모두 적용됩니다.
자기 코드에서는 더 조심하세요. Java와 옛 PHP는 기본값으로 외부 엔티티를 해석합니다.