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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39f655b89bd8e953e40ba9ada0fe936fde9d754acfa93e5825378c8b261034c3
4
- data.tar.gz: 19abc3658c2339af30ef478befdb01e5c4ab019d15abdf51c341e53e3ae9da9a
3
+ metadata.gz: cbafae55c92fefeee56b1e5d5e714433cd595facbeb078925b5f54ae55c05ffb
4
+ data.tar.gz: 9be9ee7752f5ec16a6c7c6e2da499e909a9ff4d5d05c11af8057bf760bff4bac
5
5
  SHA512:
6
- metadata.gz: c64d68c98be76d6611d0a30407172f83dc7ed3c412123745007c21a46ced185d063a62654fd285ad0c81f94ed9d72e468223736aaf2f61024a9d0f345c7b0db8
7
- data.tar.gz: 1dab899381cd01d67279ba81a173fec42599e46c585bbed0637e934cf059b8044d55676120ddd0b376c17ceb30bc081b9a773ab8cfae0f94433bca074c39f35c
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
- * Report structural complexity is bounded before any DOM is built. A report
352
- that exceeds a configured limit raises `DMARCator::ReportTooComplex`:
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, 'DMARC XML reports must not contain a DTD or entity declaration'
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, 'DMARC XML reports must not contain a DTD or entity declaration'
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, "invalid DMARC XML report: #{e.message}"
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, "invalid DMARC XML report: #{e.message}"
228
+ raise InvalidReport, sanitize_syntax_error(e)
171
229
  end
172
230
  end
173
231
  end
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DMARCator
4
4
  # dmarcator version
5
- VERSION = '1.3.0'
5
+ VERSION = '1.4.0'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dmarcator
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noah Berman