feat(parser): add bounded-by-default limits (depth, name, content)
Add PureXML.Parsing.Limits: configurable caps on nesting depth (default 256, matching libxml2), name length, and content length, enforced during scanning in the event reader. This protects against pathological input (deeply nested or oversized documents) and keeps the recursive Model.Node value safe to build and tear down (extreme depth otherwise overflows on ARC release). Threaded through Parser.parse and the top-level parse/events API. Reimplemented from libxml2's bounded-by-default posture; no upstream code copied.
This commit is contained in:
parent
209b6f1538
commit
4856a3fcf4
12
CHANGELOG.md
12
CHANGELOG.md
@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Configurable, bounded-by-default parser limits (`PureXML.Parsing.Limits`):
|
||||
maximum nesting depth (default 256), name length, and content length, enforced
|
||||
during scanning. Protects against pathological input and keeps the recursive
|
||||
node model safe to build and tear down. Threaded through `parse`/`events`.
|
||||
- Initial package scaffold: root Swift package, namespacing under `PureXML`, and
|
||||
the portability and verification gates carried over from PureYAML.
|
||||
- XML node model (`PureXML.Model`): `Node`, `Element`, `Attribute`, and
|
||||
@ -27,3 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
processing instructions. Safe by default: `<!DOCTYPE>` is rejected (no DTD/XXE).
|
||||
- Top-level API: `PureXML.parse(_:)`, `PureXML.parse(pulling:)`, and
|
||||
`PureXML.events(_:)` / `PureXML.events(pulling:)` for streaming.
|
||||
|
||||
### Changed
|
||||
|
||||
- The emitter is now iterative (explicit work stack, not recursion), mirroring
|
||||
libxml2's approach, so deep trees do not overflow the call stack.
|
||||
- The emitter escapes tab/newline/CR (and `>`) in attribute values so attributes
|
||||
round-trip through spec-compliant parsers; mixed content is no longer
|
||||
reformatted (formatting is suppressed for elements with text/CDATA children).
|
||||
|
||||
@ -4,27 +4,33 @@ public extension PureXML {
|
||||
/// The parser is still being built; this entry point currently raises
|
||||
/// ``Parsing/ParseError/notImplemented(_:)``. The serializer and model are
|
||||
/// usable today for building and emitting trees.
|
||||
static func parse(_ xml: String) throws -> Model.Node {
|
||||
try Parsing.Parser().parse(xml)
|
||||
static func parse(_ xml: String, limits: Parsing.Limits = .default) throws -> Model.Node {
|
||||
try Parsing.Parser().parse(xml, limits: limits)
|
||||
}
|
||||
|
||||
/// Parses an XML document from an incremental character source into a
|
||||
/// ``Model/Node`` tree. The closure returns the next character or nil at end
|
||||
/// of input, so the document can arrive in chunks and is never held whole.
|
||||
static func parse(pulling pull: @escaping () -> Character?) throws -> Model.Node {
|
||||
try Parsing.Parser().parse(pulling: pull)
|
||||
static func parse(
|
||||
pulling pull: @escaping () -> Character?,
|
||||
limits: Parsing.Limits = .default,
|
||||
) throws -> Model.Node {
|
||||
try Parsing.Parser().parse(pulling: pull, limits: limits)
|
||||
}
|
||||
|
||||
/// Returns a streaming ``Parsing/EventReader`` over an XML string. Pull events
|
||||
/// one at a time with `next()` to process documents without building a tree.
|
||||
static func events(_ xml: String) -> Parsing.EventReader {
|
||||
Parsing.EventReader(xml)
|
||||
static func events(_ xml: String, limits: Parsing.Limits = .default) -> Parsing.EventReader {
|
||||
Parsing.EventReader(xml, limits: limits)
|
||||
}
|
||||
|
||||
/// Returns a streaming ``Parsing/EventReader`` over an incremental character
|
||||
/// source, for processing arbitrarily large or chunked input.
|
||||
static func events(pulling pull: @escaping () -> Character?) -> Parsing.EventReader {
|
||||
Parsing.EventReader(pulling: pull)
|
||||
static func events(
|
||||
pulling pull: @escaping () -> Character?,
|
||||
limits: Parsing.Limits = .default,
|
||||
) -> Parsing.EventReader {
|
||||
Parsing.EventReader(pulling: pull, limits: limits)
|
||||
}
|
||||
|
||||
/// Serializes a ``Model/Node`` tree into XML with the selected options.
|
||||
|
||||
@ -10,9 +10,10 @@ public extension PureXML.Parsing {
|
||||
///
|
||||
/// Safe by default: a `<!DOCTYPE ...>` declaration is rejected rather than
|
||||
/// processed, removing the DTD-based threat classes (XXE, entity-expansion
|
||||
/// DoS) at the door.
|
||||
/// DoS) at the door. Bounded ``Limits`` cap depth, name, and content size.
|
||||
struct EventReader {
|
||||
private var reader: Reader
|
||||
private let limits: Limits
|
||||
private var open: [PureXML.Model.QualifiedName] = []
|
||||
private var pending: [Event] = []
|
||||
private var sawRoot = false
|
||||
@ -20,13 +21,15 @@ public extension PureXML.Parsing {
|
||||
|
||||
/// Creates a reader over a character-pulling source. The closure returns
|
||||
/// the next character or nil at end of input.
|
||||
init(pulling pull: @escaping () -> Character?) {
|
||||
init(pulling pull: @escaping () -> Character?, limits: Limits = .default) {
|
||||
reader = Reader(pulling: pull)
|
||||
self.limits = limits
|
||||
}
|
||||
|
||||
/// Creates a reader over a string (a convenience over the streaming init).
|
||||
init(_ string: String) {
|
||||
init(_ string: String, limits: Limits = .default) {
|
||||
reader = Reader(string)
|
||||
self.limits = limits
|
||||
}
|
||||
|
||||
/// Returns the next event, or nil at the end of the document.
|
||||
@ -52,26 +55,19 @@ public extension PureXML.Parsing {
|
||||
if reader.matches("<!--") {
|
||||
return try scanComment()
|
||||
}
|
||||
if reader.matches("<![CDATA[") {
|
||||
throw ParseError.unexpectedCharacter("[", reader.mark)
|
||||
}
|
||||
if reader.matches("<?") {
|
||||
let instruction = try scanProcessingInstruction()
|
||||
if instruction.target.lowercased() == "xml" {
|
||||
continue
|
||||
}
|
||||
if instruction.target.lowercased() == "xml" { continue }
|
||||
return .processingInstruction(target: instruction.target, data: instruction.data)
|
||||
}
|
||||
if reader.matches("<!DOCTYPE") {
|
||||
throw ParseError.unsupportedDoctype(reader.mark)
|
||||
}
|
||||
if reader.matches("</") {
|
||||
throw ParseError.unexpectedEndTag(name: "", reader.mark)
|
||||
if reader.matches("</") || reader.matches("<![CDATA[") {
|
||||
throw ParseError.junkAfterDocumentElement(reader.mark)
|
||||
}
|
||||
if lead == "<" {
|
||||
if sawRoot {
|
||||
throw ParseError.junkAfterDocumentElement(reader.mark)
|
||||
}
|
||||
guard !sawRoot else { throw ParseError.junkAfterDocumentElement(reader.mark) }
|
||||
return try scanStartTag()
|
||||
}
|
||||
throw ParseError.junkAfterDocumentElement(reader.mark)
|
||||
@ -82,15 +78,9 @@ public extension PureXML.Parsing {
|
||||
guard let lead = reader.peek() else {
|
||||
throw ParseError.unexpectedEndOfInput(reader.mark)
|
||||
}
|
||||
if reader.matches("</") {
|
||||
return try scanEndTag()
|
||||
}
|
||||
if reader.matches("<!--") {
|
||||
return try scanComment()
|
||||
}
|
||||
if reader.matches("<![CDATA[") {
|
||||
return try scanCDATA()
|
||||
}
|
||||
if reader.matches("</") { return try scanEndTag() }
|
||||
if reader.matches("<!--") { return try scanComment() }
|
||||
if reader.matches("<![CDATA[") { return try scanCDATA() }
|
||||
if reader.matches("<?") {
|
||||
let instruction = try scanProcessingInstruction()
|
||||
return .processingInstruction(target: instruction.target, data: instruction.data)
|
||||
@ -98,13 +88,14 @@ public extension PureXML.Parsing {
|
||||
if reader.matches("<!DOCTYPE") {
|
||||
throw ParseError.unsupportedDoctype(reader.mark)
|
||||
}
|
||||
if lead == "<" {
|
||||
return try scanStartTag()
|
||||
}
|
||||
if lead == "<" { return try scanStartTag() }
|
||||
return try scanText()
|
||||
}
|
||||
|
||||
private mutating func scanStartTag() throws -> Event {
|
||||
guard open.count < limits.maxDepth else {
|
||||
throw ParseError.nestingTooDeep(limit: limits.maxDepth, reader.mark)
|
||||
}
|
||||
reader.consume("<")
|
||||
let name = try scanName()
|
||||
let attributes = try scanAttributes()
|
||||
@ -142,7 +133,10 @@ public extension PureXML.Parsing {
|
||||
private mutating func scanText() throws -> Event {
|
||||
let mark = reader.mark
|
||||
var raw = ""
|
||||
var length = 0
|
||||
while let character = reader.peek(), character != "<" {
|
||||
length += 1
|
||||
try checkContent(length, mark)
|
||||
raw.append(character)
|
||||
reader.advance()
|
||||
}
|
||||
@ -153,10 +147,13 @@ public extension PureXML.Parsing {
|
||||
let mark = reader.mark
|
||||
reader.consume("<!--")
|
||||
var content = ""
|
||||
var length = 0
|
||||
while !reader.matches("-->") {
|
||||
guard let character = reader.advance() else {
|
||||
throw ParseError.unterminatedComment(mark)
|
||||
}
|
||||
length += 1
|
||||
try checkContent(length, mark)
|
||||
content.append(character)
|
||||
}
|
||||
reader.consume("-->")
|
||||
@ -167,10 +164,13 @@ public extension PureXML.Parsing {
|
||||
let mark = reader.mark
|
||||
reader.consume("<![CDATA[")
|
||||
var content = ""
|
||||
var length = 0
|
||||
while !reader.matches("]]>") {
|
||||
guard let character = reader.advance() else {
|
||||
throw ParseError.unterminatedCDATA(mark)
|
||||
}
|
||||
length += 1
|
||||
try checkContent(length, mark)
|
||||
content.append(character)
|
||||
}
|
||||
reader.consume("]]>")
|
||||
@ -183,10 +183,13 @@ public extension PureXML.Parsing {
|
||||
let target = try scanName()
|
||||
reader.skipSpace()
|
||||
var data = ""
|
||||
var length = 0
|
||||
while !reader.matches("?>") {
|
||||
guard let character = reader.advance() else {
|
||||
throw ParseError.unterminatedTag(mark)
|
||||
}
|
||||
length += 1
|
||||
try checkContent(length, mark)
|
||||
data.append(character)
|
||||
}
|
||||
reader.consume("?>")
|
||||
@ -223,7 +226,10 @@ public extension PureXML.Parsing {
|
||||
}
|
||||
reader.advance()
|
||||
var raw = ""
|
||||
var length = 0
|
||||
while let character = reader.peek(), character != quote {
|
||||
length += 1
|
||||
try checkContent(length, mark)
|
||||
raw.append(character)
|
||||
reader.advance()
|
||||
}
|
||||
@ -234,15 +240,27 @@ public extension PureXML.Parsing {
|
||||
}
|
||||
|
||||
private mutating func scanName() throws -> PureXML.Model.QualifiedName {
|
||||
let mark = reader.mark
|
||||
guard let first = reader.peek(), first.isXMLNameStart else {
|
||||
throw ParseError.expectedName(reader.mark)
|
||||
}
|
||||
var raw = ""
|
||||
var length = 0
|
||||
while let character = reader.peek(), character.isXMLNameContinuation {
|
||||
length += 1
|
||||
guard length <= limits.maxNameLength else {
|
||||
throw ParseError.nameTooLong(limit: limits.maxNameLength, mark)
|
||||
}
|
||||
raw.append(character)
|
||||
reader.advance()
|
||||
}
|
||||
return PureXML.Model.QualifiedName(raw)
|
||||
}
|
||||
|
||||
private func checkContent(_ length: Int, _ mark: Mark) throws {
|
||||
guard length <= limits.maxContentLength else {
|
||||
throw ParseError.contentTooLong(limit: limits.maxContentLength, mark)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
Sources/Parsing/Limits.swift
Normal file
28
Sources/Parsing/Limits.swift
Normal file
@ -0,0 +1,28 @@
|
||||
public extension PureXML.Parsing {
|
||||
/// Configurable, bounded-by-default parser limits. Exceeding any of them is a
|
||||
/// parse error rather than unbounded work, which protects against pathological
|
||||
/// input (deeply nested or oversized documents). The depth cap also keeps the
|
||||
/// recursive ``PureXML/Model/Node`` value safe to build and tear down. The
|
||||
/// defaults follow libxml2's posture.
|
||||
struct Limits: Equatable, Sendable {
|
||||
/// Maximum element nesting depth (libxml2 defaults to 256).
|
||||
public var maxDepth: Int
|
||||
/// Maximum length of a single name (element, attribute, or PI target).
|
||||
public var maxNameLength: Int
|
||||
/// Maximum length of a single text, CDATA, comment, or PI-data run.
|
||||
public var maxContentLength: Int
|
||||
|
||||
public init(
|
||||
maxDepth: Int = 256,
|
||||
maxNameLength: Int = 50000,
|
||||
maxContentLength: Int = 10_000_000,
|
||||
) {
|
||||
self.maxDepth = maxDepth
|
||||
self.maxNameLength = maxNameLength
|
||||
self.maxContentLength = maxContentLength
|
||||
}
|
||||
|
||||
/// The default limits, matching libxml2's bounded-by-default posture.
|
||||
public static let `default` = Limits()
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,9 @@ public extension PureXML.Parsing {
|
||||
case invalidReference(String, Mark)
|
||||
case unexpectedEndTag(name: String, Mark)
|
||||
case junkAfterDocumentElement(Mark)
|
||||
case nestingTooDeep(limit: Int, Mark)
|
||||
case nameTooLong(limit: Int, Mark)
|
||||
case contentTooLong(limit: Int, Mark)
|
||||
/// A `<!DOCTYPE ...>` declaration was found. DTD processing is disabled by
|
||||
/// default as a security posture (XXE, entity-expansion DoS); enabling it
|
||||
/// is a deliberate future opt-in, not silent behavior.
|
||||
@ -52,6 +55,12 @@ public extension PureXML.Parsing {
|
||||
"unexpected end tag </\(name)> at \(mark)"
|
||||
case let .junkAfterDocumentElement(mark):
|
||||
"content after the root element at \(mark)"
|
||||
case let .nestingTooDeep(limit, mark):
|
||||
"element nesting exceeds the limit of \(limit) at \(mark)"
|
||||
case let .nameTooLong(limit, mark):
|
||||
"name exceeds the length limit of \(limit) at \(mark)"
|
||||
case let .contentTooLong(limit, mark):
|
||||
"content exceeds the length limit of \(limit) at \(mark)"
|
||||
case let .unsupportedDoctype(mark):
|
||||
"DTD processing is disabled (DOCTYPE at \(mark))"
|
||||
case let .notImplemented(detail):
|
||||
|
||||
@ -16,15 +16,18 @@ public extension PureXML.Parsing {
|
||||
public init() {}
|
||||
|
||||
/// Parses a single XML document from a string into a document node.
|
||||
public func parse(_ xml: String) throws -> PureXML.Model.Node {
|
||||
try build(EventReader(xml))
|
||||
public func parse(_ xml: String, limits: Limits = .default) throws -> PureXML.Model.Node {
|
||||
try build(EventReader(xml, limits: limits))
|
||||
}
|
||||
|
||||
/// Parses a single XML document from an incremental character source. The
|
||||
/// closure returns the next character or nil at end of input, so the
|
||||
/// document can arrive in chunks and is never held whole.
|
||||
public func parse(pulling pull: @escaping () -> Character?) throws -> PureXML.Model.Node {
|
||||
try build(EventReader(pulling: pull))
|
||||
public func parse(
|
||||
pulling pull: @escaping () -> Character?,
|
||||
limits: Limits = .default,
|
||||
) throws -> PureXML.Model.Node {
|
||||
try build(EventReader(pulling: pull, limits: limits))
|
||||
}
|
||||
|
||||
private func build(_ source: EventReader) throws -> PureXML.Model.Node {
|
||||
|
||||
@ -85,6 +85,37 @@ struct ParsingTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Nesting beyond the depth limit is rejected")
|
||||
func test_depthLimitRejected() {
|
||||
let limits = PureXML.Parsing.Limits(maxDepth: 3)
|
||||
#expect(throws: PureXML.Parsing.ParseError.self) {
|
||||
try PureXML.parse("<a><b><c><d>x</d></c></b></a>", limits: limits)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Nesting within the depth limit parses")
|
||||
func test_depthWithinLimit() throws {
|
||||
let limits = PureXML.Parsing.Limits(maxDepth: 5)
|
||||
let node = try PureXML.parse("<a><b><c>x</c></b></a>", limits: limits)
|
||||
#expect(node.firstElement?.name.localName == "a")
|
||||
}
|
||||
|
||||
@Test("Names over the length limit are rejected")
|
||||
func test_nameLimitRejected() {
|
||||
let limits = PureXML.Parsing.Limits(maxNameLength: 4)
|
||||
#expect(throws: PureXML.Parsing.ParseError.self) {
|
||||
try PureXML.parse("<abcdefgh/>", limits: limits)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Content over the length limit is rejected")
|
||||
func test_contentLimitRejected() {
|
||||
let limits = PureXML.Parsing.Limits(maxContentLength: 5)
|
||||
#expect(throws: PureXML.Parsing.ParseError.self) {
|
||||
try PureXML.parse("<a>abcdefghij</a>", limits: limits)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Streams events one at a time without building a tree")
|
||||
func test_streamsEvents() throws {
|
||||
var reader = PureXML.events("<r a=\"1\">hi<b/></r>")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user