PureXML/Sources/Parsing/DoctypeScanner.swift
Mihaela Mihaljevic 01a35cc6e6 feat(parser): optional DTD with internal entities and billion-laughs defense
Add opt-in DTD processing behind Limits(allowDoctype:), off by default. When on,
DoctypeScanner reads the <!DOCTYPE> internal subset and honors internal
<!ENTITY name "value"> general entities, which EntityDecoder expands recursively
in text and attribute values.

Safety is mandatory and reimplemented from the Expat amplification-accounting
model (no upstream code copied):
- only entity-expanded characters count against a shared budget
  (Limits.maxEntityExpansion); overflow raises amplificationLimitExceeded, which
  defeats billion-laughs.
- a visiting set detects recursive entities (recursiveEntity).
- undeclared references raise undefinedEntity.
- external entities are never stored or loaded, so XXE stays closed.

The default still rejects <!DOCTYPE>. 8 DTD tests (incl. billion-laughs and the
external-entity refusal); 49 total.
2026-06-08 01:17:09 +02:00

111 lines
4.2 KiB
Swift

extension PureXML.Parsing {
/// Consumes a `<!DOCTYPE ...>` declaration.
///
/// When DTD processing is disabled (the default), the whole declaration is
/// refused, which keeps the XXE and entity-expansion threat classes closed.
/// When enabled, only internal general-entity declarations
/// (`<!ENTITY name "value">`) are honored. External entities are skipped (not
/// stored), so referencing one later fails as undefined; element, attribute,
/// and notation declarations are skipped as well. Parameter entities are not
/// supported and their declarations and references are skipped.
enum DoctypeScanner {
static func scan(_ reader: inout Reader, limits: Limits) throws -> [String: String] {
let mark = reader.mark
guard limits.allowDoctype else {
throw ParseError.unsupportedDoctype(mark)
}
reader.consume("<!DOCTYPE")
// Skip the root-element name and any external identifier up to the
// internal subset or the closing bracket.
while let character = reader.peek(), character != "[", character != ">" {
reader.advance()
}
var entities: [String: String] = [:]
if reader.consume("[") {
try scanInternalSubset(&reader, into: &entities, at: mark)
}
reader.skipSpace()
guard reader.consume(">") else {
throw ParseError.unterminatedTag(mark)
}
return entities
}
private static func scanInternalSubset(
_ reader: inout Reader,
into entities: inout [String: String],
at mark: Mark,
) throws {
while true {
reader.skipSpace()
guard let character = reader.peek() else {
throw ParseError.unsupportedDoctype(mark)
}
if reader.consume("]") {
return
}
if reader.matches("<!ENTITY") {
try scanEntityDeclaration(&reader, into: &entities)
} else if reader.matches("<!--") {
skip(&reader, until: "-->")
} else if reader.matches("<?") {
skip(&reader, until: "?>")
} else if reader.matches("<!") {
skip(&reader, until: ">")
} else if character == "%" {
skip(&reader, until: ";")
} else {
reader.advance()
}
}
}
private static func scanEntityDeclaration(
_ reader: inout Reader,
into entities: inout [String: String],
) throws {
reader.consume("<!ENTITY")
reader.skipSpace()
// Parameter-entity declaration (`<!ENTITY % name ...>`): not supported.
if reader.consume("%") {
skip(&reader, until: ">")
return
}
let name = scanName(&reader)
reader.skipSpace()
guard let quote = reader.peek(), quote == "\"" || quote == "'" else {
// External entity (SYSTEM/PUBLIC) or malformed: skip, do not store.
skip(&reader, until: ">")
return
}
reader.advance()
var value = ""
while let character = reader.peek(), character != quote {
value.append(character)
reader.advance()
}
reader.consume(String(quote))
skip(&reader, until: ">")
if !name.isEmpty {
entities[name] = value
}
}
private static func scanName(_ reader: inout Reader) -> String {
var name = ""
while let character = reader.peek(), character.isXMLNameContinuation {
name.append(character)
reader.advance()
}
return name
}
private static func skip(_ reader: inout Reader, until terminator: String) {
while reader.peek() != nil, !reader.matches(terminator) {
reader.advance()
}
reader.consume(terminator)
}
}
}