dmarcator 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ChangeLog.md +7 -0
- data/README.md +12 -3
- data/lib/dmarcator/internal/xml_guard.rb +66 -8
- data/lib/dmarcator/report/row.rb +0 -12
- data/lib/dmarcator/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cbafae55c92fefeee56b1e5d5e714433cd595facbeb078925b5f54ae55c05ffb
|
|
4
|
+
data.tar.gz: 9be9ee7752f5ec16a6c7c6e2da499e909a9ff4d5d05c11af8057bf760bff4bac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4206b0cd61595bcffb31487edf88fc6bb7bbc36a3148ab94dec83c5911b49a9d5b05de05dac01c13c232b733ed3a3d40bcd3ee1c96ed123a0a6dbbe812fded3e
|
|
7
|
+
data.tar.gz: dafdc7d98f728a42398a5a8e9c9c05709592b6e5b341d6b7e5848176b6a3563388608432f30f4e5a538621ba163107880357bdf7ff2e85aee6d1ba7623e576a1
|
data/ChangeLog.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
### 1.4.0 / 2026-11-01
|
|
2
|
+
|
|
3
|
+
* Enforce the per-record `max_auth_results` and `max_reasons` limits eagerly during the streaming pre-check, before any DOM is built. Previously a report with one record containing an enormous `auth_results`/`reason` collection reached DOM construction unless another global limit caught it.
|
|
4
|
+
* Require a `<feedback>` root element: well-formed XML that is not a DMARC aggregate report is rejected with `InvalidReport` instead of being parsed into an empty report.
|
|
5
|
+
* Stop echoing libxml2 error text (which can contain tag, attribute or entity names from the untrusted input) in `InvalidReport` messages; report only line and column position.
|
|
6
|
+
* Add near-limit regression tests (reports just below every configured limit still parse) and a deterministic fuzz test that random bytes, tag soup, huge text and many attributes only ever raise `DMARCator::Error`.
|
|
7
|
+
|
|
1
8
|
### 1.3.0 / 2026-11-01
|
|
2
9
|
|
|
3
10
|
* Treat report XML as hostile by rejecting DTD and entity declarations outright (`DMARCator::ForbiddenXml`). DMARC reports never need a DTD, so external-entity (XXE) and entity-expansion (billion-laughs) attacks are rejected before parsing rather than merely having their resolution disabled.
|
data/README.md
CHANGED
|
@@ -347,9 +347,13 @@ for the reporting policy and scope.
|
|
|
347
347
|
rejected outright (`DMARCator::ForbiddenXml`), and parsing disables network
|
|
348
348
|
access, entity substitution, DTD loading, and libxml2's `HUGE` relaxation.
|
|
349
349
|
External-entity (XXE) and entity-expansion (billion-laughs) attacks are
|
|
350
|
-
therefore impossible rather than merely mitigated.
|
|
351
|
-
|
|
352
|
-
|
|
350
|
+
therefore impossible rather than merely mitigated. A report must have a
|
|
351
|
+
`<feedback>` root element; arbitrary well-formed XML is rejected with
|
|
352
|
+
`DMARCator::InvalidReport`.
|
|
353
|
+
* Report structural complexity is bounded before any DOM is built. Every limit
|
|
354
|
+
below — including the per-record `auth_results`/`reason` limits — is enforced
|
|
355
|
+
by a streaming pre-check, so a report that exceeds a configured limit raises
|
|
356
|
+
`DMARCator::ReportTooComplex` before a DOM is allocated:
|
|
353
357
|
|
|
354
358
|
| Setting | Default | Bounds |
|
|
355
359
|
|---|---|---|
|
|
@@ -367,6 +371,11 @@ for the reporting policy and scope.
|
|
|
367
371
|
```ruby
|
|
368
372
|
DMARCator.configure { |config| config.max_report_bytes = 5 * 1024 * 1024 }
|
|
369
373
|
```
|
|
374
|
+
|
|
375
|
+
These limits are not the outer boundary. They bound the work the C parser is
|
|
376
|
+
asked to do, but a libxml2 bug can still bypass Ruby-level checks. Applications
|
|
377
|
+
parsing reports from untrusted sources should still run the parse in an
|
|
378
|
+
isolated worker or subprocess with its own resource limits.
|
|
370
379
|
* A warning is emitted at load time if the bundled `resolv` gem is affected by
|
|
371
380
|
CVE-2026-80212/CVE-2026-80213.
|
|
372
381
|
* Domain names are validated before resolution (labels ≤ 63 octets, total ≤
|
|
@@ -20,7 +20,12 @@ module DMARCator
|
|
|
20
20
|
# rejected outright, and libxml2 is configured to disable everything that
|
|
21
21
|
# could fetch or expand them. Structural complexity is bounded with a
|
|
22
22
|
# streaming reader pass before any DOM is built, so a report that exceeds a
|
|
23
|
-
# limit is rejected without first allocating a tree for it.
|
|
23
|
+
# limit is rejected without first allocating a tree for it. Every configured
|
|
24
|
+
# limit — including the per-record `auth_results` and `reason` limits — is
|
|
25
|
+
# enforced here, before DOM allocation.
|
|
26
|
+
#
|
|
27
|
+
# A report must have a `<feedback>` root element; arbitrary well-formed XML
|
|
28
|
+
# that is not a DMARC aggregate report is rejected.
|
|
24
29
|
#
|
|
25
30
|
# Ruby's `Timeout` is deliberately not used here: it cannot reliably
|
|
26
31
|
# interrupt libxml2's C-level parsing. Callers that need a hard wall-clock
|
|
@@ -38,6 +43,12 @@ module DMARCator
|
|
|
38
43
|
# see (for example UTF-16).
|
|
39
44
|
FORBIDDEN_DECLARATION = /<!doctype|<!entity/i
|
|
40
45
|
|
|
46
|
+
# Message raised when a DTD or entity declaration is present.
|
|
47
|
+
#
|
|
48
|
+
# Kept free of input-derived content: declaration details are never
|
|
49
|
+
# echoed to callers.
|
|
50
|
+
FORBIDDEN_MESSAGE = 'DMARC XML reports must not contain a DTD or entity declaration'
|
|
51
|
+
|
|
41
52
|
#
|
|
42
53
|
# Parse options shared by the streaming reader and the DOM parser.
|
|
43
54
|
#
|
|
@@ -113,7 +124,7 @@ module DMARCator
|
|
|
113
124
|
|
|
114
125
|
return unless haystack.match?(FORBIDDEN_DECLARATION)
|
|
115
126
|
|
|
116
|
-
raise ForbiddenXml,
|
|
127
|
+
raise ForbiddenXml, FORBIDDEN_MESSAGE
|
|
117
128
|
end
|
|
118
129
|
|
|
119
130
|
def enforce_structural_limits!
|
|
@@ -123,20 +134,49 @@ module DMARCator
|
|
|
123
134
|
records = 0
|
|
124
135
|
attributes = 0
|
|
125
136
|
|
|
137
|
+
stack = []
|
|
138
|
+
root_seen = false
|
|
139
|
+
record_auth_results = 0
|
|
140
|
+
record_reasons = 0
|
|
141
|
+
|
|
126
142
|
while reader.read
|
|
127
143
|
case reader.node_type
|
|
128
144
|
when Nokogiri::XML::Reader::TYPE_DOCUMENT_TYPE,
|
|
129
145
|
Nokogiri::XML::Reader::TYPE_ENTITY
|
|
130
|
-
raise ForbiddenXml,
|
|
146
|
+
raise ForbiddenXml, FORBIDDEN_MESSAGE
|
|
131
147
|
when Nokogiri::XML::Reader::TYPE_ELEMENT
|
|
132
148
|
nodes += 1
|
|
133
|
-
records += 1 if reader.name == 'record'
|
|
134
149
|
attributes += reader.attribute_count
|
|
135
150
|
|
|
136
151
|
enforce(:max_xml_nodes, nodes)
|
|
137
|
-
enforce(:max_report_records, records)
|
|
138
|
-
enforce(:max_xml_depth, reader.depth + 1)
|
|
139
152
|
enforce(:max_xml_attributes, attributes)
|
|
153
|
+
enforce(:max_xml_depth, reader.depth + 1)
|
|
154
|
+
|
|
155
|
+
unless root_seen
|
|
156
|
+
root_seen = true
|
|
157
|
+
|
|
158
|
+
unless reader.name == 'feedback'
|
|
159
|
+
raise InvalidReport, 'DMARC XML report must have a <feedback> root element'
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if reader.name == 'record'
|
|
164
|
+
records += 1
|
|
165
|
+
enforce(:max_report_records, records)
|
|
166
|
+
|
|
167
|
+
record_auth_results = 0
|
|
168
|
+
record_reasons = 0
|
|
169
|
+
elsif stack.include?('record')
|
|
170
|
+
if stack.last == 'auth_results'
|
|
171
|
+
record_auth_results += 1
|
|
172
|
+
enforce(:max_auth_results, record_auth_results)
|
|
173
|
+
elsif stack.last == 'policy_evaluated' && reader.name == 'reason'
|
|
174
|
+
record_reasons += 1
|
|
175
|
+
enforce(:max_reasons, record_reasons)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
stack.push(reader.name) unless reader.empty_element?
|
|
140
180
|
when Nokogiri::XML::Reader::TYPE_TEXT,
|
|
141
181
|
Nokogiri::XML::Reader::TYPE_CDATA,
|
|
142
182
|
Nokogiri::XML::Reader::TYPE_WHITESPACE,
|
|
@@ -149,10 +189,12 @@ module DMARCator
|
|
|
149
189
|
|
|
150
190
|
value = reader.value
|
|
151
191
|
enforce(:max_text_length, value.bytesize) if value
|
|
192
|
+
when Nokogiri::XML::Reader::TYPE_END_ELEMENT
|
|
193
|
+
stack.pop
|
|
152
194
|
end
|
|
153
195
|
end
|
|
154
196
|
rescue Nokogiri::XML::SyntaxError => e
|
|
155
|
-
raise InvalidReport,
|
|
197
|
+
raise InvalidReport, sanitize_syntax_error(e)
|
|
156
198
|
end
|
|
157
199
|
|
|
158
200
|
def enforce(setting, value)
|
|
@@ -162,12 +204,28 @@ module DMARCator
|
|
|
162
204
|
raise ReportTooComplex, "DMARC XML report exceeds #{setting} of #{limit}"
|
|
163
205
|
end
|
|
164
206
|
|
|
207
|
+
#
|
|
208
|
+
# Renders a parse error using only position information. Nokogiri's
|
|
209
|
+
# messages can echo tag, attribute or entity names from the input, and
|
|
210
|
+
# those are untrusted; line/column numbers are not.
|
|
211
|
+
#
|
|
212
|
+
def sanitize_syntax_error(error)
|
|
213
|
+
line = error.respond_to?(:line) ? error.line : nil
|
|
214
|
+
column = error.respond_to?(:column) ? error.column : nil
|
|
215
|
+
|
|
216
|
+
if line && column
|
|
217
|
+
"invalid DMARC XML report at line #{line}, column #{column}"
|
|
218
|
+
else
|
|
219
|
+
'invalid DMARC XML report'
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
165
223
|
def parse_document
|
|
166
224
|
document = Nokogiri::XML.parse(@xml, nil, nil, self.class.parse_options)
|
|
167
225
|
|
|
168
226
|
document
|
|
169
227
|
rescue Nokogiri::XML::SyntaxError => e
|
|
170
|
-
raise InvalidReport,
|
|
228
|
+
raise InvalidReport, sanitize_syntax_error(e)
|
|
171
229
|
end
|
|
172
230
|
end
|
|
173
231
|
end
|
data/lib/dmarcator/report/row.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '../config'
|
|
4
|
-
require_relative '../exceptions'
|
|
5
3
|
require_relative '../internal/xml_helper'
|
|
6
4
|
require_relative 'auth_result'
|
|
7
5
|
require_relative 'reason'
|
|
@@ -108,11 +106,6 @@ module DMARCator
|
|
|
108
106
|
@spf ||= element.text.freeze
|
|
109
107
|
when 'reason'
|
|
110
108
|
@reason_nodes << element
|
|
111
|
-
|
|
112
|
-
if @reason_nodes.length > DMARCator.config.max_reasons
|
|
113
|
-
raise ReportTooComplex,
|
|
114
|
-
"DMARC XML report exceeds max_reasons of #{DMARCator.config.max_reasons} per record"
|
|
115
|
-
end
|
|
116
109
|
end
|
|
117
110
|
end
|
|
118
111
|
end
|
|
@@ -133,11 +126,6 @@ module DMARCator
|
|
|
133
126
|
def collect_auth_results(auth_results)
|
|
134
127
|
auth_results.element_children.each do |element|
|
|
135
128
|
@auth_result_nodes << element
|
|
136
|
-
|
|
137
|
-
if @auth_result_nodes.length > DMARCator.config.max_auth_results
|
|
138
|
-
raise ReportTooComplex,
|
|
139
|
-
"DMARC XML report exceeds max_auth_results of #{DMARCator.config.max_auth_results} per record"
|
|
140
|
-
end
|
|
141
129
|
end
|
|
142
130
|
end
|
|
143
131
|
end
|
data/lib/dmarcator/version.rb
CHANGED