dmarcator 1.2.1 → 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 +15 -0
- data/README.md +38 -5
- data/lib/dmarcator/config.rb +120 -0
- data/lib/dmarcator/exceptions.rb +26 -0
- data/lib/dmarcator/internal/xml_guard.rb +232 -0
- data/lib/dmarcator/report.rb +12 -9
- data/lib/dmarcator/version.rb +1 -1
- metadata +2 -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,18 @@
|
|
|
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
|
+
|
|
8
|
+
### 1.3.0 / 2026-11-01
|
|
9
|
+
|
|
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.
|
|
11
|
+
* Parse report XML with an explicit hardening policy: `NONET` on, entity substitution, DTD loading, default DTD attributes and libxml2's `HUGE` relaxation all off, and strict (non-recovering) parsing.
|
|
12
|
+
* Bound report structural complexity with configurable limits enforced by a streaming reader before any DOM is built. Exceeding a limit raises `DMARCator::ReportTooComplex` rather than `InvalidReport`.
|
|
13
|
+
* Add `Config` limits `max_report_records` (10,000), `max_xml_nodes` (100,000), `max_xml_depth` (50), `max_text_length` (1 MiB), `max_xml_attributes` (100,000), `max_auth_results` (100) and `max_reasons` (100).
|
|
14
|
+
* Add `DMARCator::ReportTooComplex` and `DMARCator::ForbiddenXml` error classes (both `DMARCator::Error`).
|
|
15
|
+
|
|
1
16
|
### 1.2.1 / 2026-10-30
|
|
2
17
|
|
|
3
18
|
* Add a record-parser corpus regression net: 143 records (published real-world records plus edge cases) with their accepted outcomes in lenient and strict mode, replayed by the test suite. A parser change that alters how any record parses now fails with a diff. Regenerate with `script/generate_record_corpus.rb`.
|
data/README.md
CHANGED
|
@@ -202,6 +202,15 @@ Process-wide defaults can be set from code, and take precedence over the
|
|
|
202
202
|
DMARCator.configure do |config|
|
|
203
203
|
config.resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
|
|
204
204
|
config.max_report_bytes = 5 * 1024 * 1024
|
|
205
|
+
|
|
206
|
+
# XML report complexity limits (defaults shown)
|
|
207
|
+
config.max_report_records = 10_000
|
|
208
|
+
config.max_xml_nodes = 100_000
|
|
209
|
+
config.max_xml_depth = 50
|
|
210
|
+
config.max_text_length = 1 * 1024 * 1024
|
|
211
|
+
config.max_xml_attributes = 100_000
|
|
212
|
+
config.max_auth_results = 100
|
|
213
|
+
config.max_reasons = 100
|
|
205
214
|
end
|
|
206
215
|
```
|
|
207
216
|
|
|
@@ -334,15 +343,39 @@ DMARC records and XML reports are treated as untrusted input. See
|
|
|
334
343
|
[SECURITY.md](https://github.com/bermannoah/dmarcator/blob/main/SECURITY.md)
|
|
335
344
|
for the reporting policy and scope.
|
|
336
345
|
|
|
337
|
-
* XML reports are
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
346
|
+
* XML reports are treated as hostile: DTDs and entity declarations are
|
|
347
|
+
rejected outright (`DMARCator::ForbiddenXml`), and parsing disables network
|
|
348
|
+
access, entity substitution, DTD loading, and libxml2's `HUGE` relaxation.
|
|
349
|
+
External-entity (XXE) and entity-expansion (billion-laughs) attacks are
|
|
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:
|
|
357
|
+
|
|
358
|
+
| Setting | Default | Bounds |
|
|
359
|
+
|---|---|---|
|
|
360
|
+
| `max_report_bytes` | 25 MiB | input size |
|
|
361
|
+
| `max_report_records` | 10,000 | `record` elements |
|
|
362
|
+
| `max_xml_nodes` | 100,000 | element/text/comment nodes |
|
|
363
|
+
| `max_xml_depth` | 50 | element nesting depth |
|
|
364
|
+
| `max_text_length` | 1 MiB | bytes per text node |
|
|
365
|
+
| `max_xml_attributes` | 100,000 | attribute nodes |
|
|
366
|
+
| `max_auth_results` | 100 | `auth_results` entries per record |
|
|
367
|
+
| `max_reasons` | 100 | `reason` entries per record |
|
|
368
|
+
|
|
369
|
+
Lower them to bound memory and CPU for reports from untrusted sources:
|
|
342
370
|
|
|
343
371
|
```ruby
|
|
344
372
|
DMARCator.configure { |config| config.max_report_bytes = 5 * 1024 * 1024 }
|
|
345
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.
|
|
346
379
|
* A warning is emitted at load time if the bundled `resolv` gem is affected by
|
|
347
380
|
CVE-2026-80212/CVE-2026-80213.
|
|
348
381
|
* Domain names are validated before resolution (labels ≤ 63 octets, total ≤
|
data/lib/dmarcator/config.rb
CHANGED
|
@@ -16,6 +16,54 @@ module DMARCator
|
|
|
16
16
|
#
|
|
17
17
|
DEFAULT_MAX_REPORT_BYTES = 25 * 1024 * 1024
|
|
18
18
|
|
|
19
|
+
# Default maximum number of `record` elements accepted in a report.
|
|
20
|
+
#
|
|
21
|
+
# @since 1.3.0
|
|
22
|
+
#
|
|
23
|
+
DEFAULT_MAX_REPORT_RECORDS = 10_000
|
|
24
|
+
|
|
25
|
+
# Default maximum number of XML nodes accepted in a report.
|
|
26
|
+
#
|
|
27
|
+
# Counts element, text, CDATA, whitespace, comment and processing
|
|
28
|
+
# instruction nodes; attributes are counted separately by
|
|
29
|
+
# {DEFAULT_MAX_XML_ATTRIBUTES}.
|
|
30
|
+
#
|
|
31
|
+
# @since 1.3.0
|
|
32
|
+
#
|
|
33
|
+
DEFAULT_MAX_XML_NODES = 100_000
|
|
34
|
+
|
|
35
|
+
# Default maximum element nesting depth accepted in a report.
|
|
36
|
+
#
|
|
37
|
+
# The root element is depth 1.
|
|
38
|
+
#
|
|
39
|
+
# @since 1.3.0
|
|
40
|
+
#
|
|
41
|
+
DEFAULT_MAX_XML_DEPTH = 50
|
|
42
|
+
|
|
43
|
+
# Default maximum length in bytes of any single text node.
|
|
44
|
+
#
|
|
45
|
+
# @since 1.3.0
|
|
46
|
+
#
|
|
47
|
+
DEFAULT_MAX_TEXT_LENGTH = 1 * 1024 * 1024
|
|
48
|
+
|
|
49
|
+
# Default maximum number of attribute nodes accepted in a report.
|
|
50
|
+
#
|
|
51
|
+
# @since 1.3.0
|
|
52
|
+
#
|
|
53
|
+
DEFAULT_MAX_XML_ATTRIBUTES = 100_000
|
|
54
|
+
|
|
55
|
+
# Default maximum number of `auth_results` entries per record.
|
|
56
|
+
#
|
|
57
|
+
# @since 1.3.0
|
|
58
|
+
#
|
|
59
|
+
DEFAULT_MAX_AUTH_RESULTS = 100
|
|
60
|
+
|
|
61
|
+
# Default maximum number of `reason` entries per record.
|
|
62
|
+
#
|
|
63
|
+
# @since 1.3.0
|
|
64
|
+
#
|
|
65
|
+
DEFAULT_MAX_REASONS = 100
|
|
66
|
+
|
|
19
67
|
#
|
|
20
68
|
# The default resolver used when a lookup is not given one.
|
|
21
69
|
#
|
|
@@ -57,6 +105,78 @@ module DMARCator
|
|
|
57
105
|
|
|
58
106
|
@max_report_bytes = value
|
|
59
107
|
end
|
|
108
|
+
|
|
109
|
+
#
|
|
110
|
+
# XML report complexity limits.
|
|
111
|
+
#
|
|
112
|
+
# Each accepts a positive Integer or `nil` (to restore the default). A
|
|
113
|
+
# report that exceeds one of these raises {ReportTooComplex} when parsed.
|
|
114
|
+
# They bound parser work on hostile input; see {DMARCator::Report}.
|
|
115
|
+
#
|
|
116
|
+
# @!method max_report_records
|
|
117
|
+
# @return [Integer] the maximum number of `record` elements.
|
|
118
|
+
# @!method max_report_records=(value)
|
|
119
|
+
# @param [Integer, nil] value
|
|
120
|
+
# @raise [ArgumentError] the value is not a positive Integer or nil.
|
|
121
|
+
#
|
|
122
|
+
# @!method max_xml_nodes
|
|
123
|
+
# @return [Integer] the maximum number of XML nodes.
|
|
124
|
+
# @!method max_xml_nodes=(value)
|
|
125
|
+
# @param [Integer, nil] value
|
|
126
|
+
# @raise [ArgumentError] the value is not a positive Integer or nil.
|
|
127
|
+
#
|
|
128
|
+
# @!method max_xml_depth
|
|
129
|
+
# @return [Integer] the maximum element nesting depth (root is 1).
|
|
130
|
+
# @!method max_xml_depth=(value)
|
|
131
|
+
# @param [Integer, nil] value
|
|
132
|
+
# @raise [ArgumentError] the value is not a positive Integer or nil.
|
|
133
|
+
#
|
|
134
|
+
# @!method max_text_length
|
|
135
|
+
# @return [Integer] the maximum text node length in bytes.
|
|
136
|
+
# @!method max_text_length=(value)
|
|
137
|
+
# @param [Integer, nil] value
|
|
138
|
+
# @raise [ArgumentError] the value is not a positive Integer or nil.
|
|
139
|
+
#
|
|
140
|
+
# @!method max_xml_attributes
|
|
141
|
+
# @return [Integer] the maximum number of attribute nodes.
|
|
142
|
+
# @!method max_xml_attributes=(value)
|
|
143
|
+
# @param [Integer, nil] value
|
|
144
|
+
# @raise [ArgumentError] the value is not a positive Integer or nil.
|
|
145
|
+
#
|
|
146
|
+
# @!method max_auth_results
|
|
147
|
+
# @return [Integer] the maximum number of `auth_results` entries per record.
|
|
148
|
+
# @!method max_auth_results=(value)
|
|
149
|
+
# @param [Integer, nil] value
|
|
150
|
+
# @raise [ArgumentError] the value is not a positive Integer or nil.
|
|
151
|
+
#
|
|
152
|
+
# @!method max_reasons
|
|
153
|
+
# @return [Integer] the maximum number of `reason` entries per record.
|
|
154
|
+
# @!method max_reasons=(value)
|
|
155
|
+
# @param [Integer, nil] value
|
|
156
|
+
# @raise [ArgumentError] the value is not a positive Integer or nil.
|
|
157
|
+
#
|
|
158
|
+
{
|
|
159
|
+
max_report_records: DEFAULT_MAX_REPORT_RECORDS,
|
|
160
|
+
max_xml_nodes: DEFAULT_MAX_XML_NODES,
|
|
161
|
+
max_xml_depth: DEFAULT_MAX_XML_DEPTH,
|
|
162
|
+
max_text_length: DEFAULT_MAX_TEXT_LENGTH,
|
|
163
|
+
max_xml_attributes: DEFAULT_MAX_XML_ATTRIBUTES,
|
|
164
|
+
max_auth_results: DEFAULT_MAX_AUTH_RESULTS,
|
|
165
|
+
max_reasons: DEFAULT_MAX_REASONS
|
|
166
|
+
}.each do |name, default|
|
|
167
|
+
define_method(name) do
|
|
168
|
+
instance_variable_get(:"@#{name}") || default
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
define_method(:"#{name}=") do |value|
|
|
172
|
+
unless value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
173
|
+
raise ArgumentError,
|
|
174
|
+
"#{name} must be a positive Integer or nil, got #{value.inspect}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
instance_variable_set(:"@#{name}", value)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
60
180
|
end
|
|
61
181
|
|
|
62
182
|
#
|
data/lib/dmarcator/exceptions.rb
CHANGED
|
@@ -28,6 +28,32 @@ module DMARCator
|
|
|
28
28
|
class InvalidReport < Error
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
#
|
|
32
|
+
# Raised when a DMARC XML report is well-formed but exceeds a configured
|
|
33
|
+
# structural limit (record count, node count, nesting depth, text length,
|
|
34
|
+
# attribute count, or per-record auth_results/reasons entries).
|
|
35
|
+
#
|
|
36
|
+
# Distinct from {InvalidReport} so an application can audit or reject
|
|
37
|
+
# complexity attacks without treating them as ordinary malformed input.
|
|
38
|
+
#
|
|
39
|
+
# @since 1.3.0
|
|
40
|
+
#
|
|
41
|
+
class ReportTooComplex < Error
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
#
|
|
45
|
+
# Raised when a DMARC XML report contains a DTD or entity declaration.
|
|
46
|
+
#
|
|
47
|
+
# DMARC reports never need a DTD or custom entities, so declaring them is
|
|
48
|
+
# rejected outright. This eliminates external-entity (XXE) and
|
|
49
|
+
# entity-expansion (billion-laughs) attacks rather than merely disabling
|
|
50
|
+
# their resolution.
|
|
51
|
+
#
|
|
52
|
+
# @since 1.3.0
|
|
53
|
+
#
|
|
54
|
+
class ForbiddenXml < Error
|
|
55
|
+
end
|
|
56
|
+
|
|
31
57
|
#
|
|
32
58
|
# Raised when a DNS transport fails to return a usable answer.
|
|
33
59
|
#
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
require_relative '../config'
|
|
6
|
+
require_relative '../exceptions'
|
|
7
|
+
|
|
8
|
+
module DMARCator
|
|
9
|
+
#
|
|
10
|
+
# Implementation details of the library. Nothing here is covered by the
|
|
11
|
+
# 1.0 compatibility promise.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
#
|
|
15
|
+
module Internal
|
|
16
|
+
#
|
|
17
|
+
# Hardens DMARC XML report parsing against hostile input.
|
|
18
|
+
#
|
|
19
|
+
# A DMARC report never needs a DTD or custom entities, so declarations are
|
|
20
|
+
# rejected outright, and libxml2 is configured to disable everything that
|
|
21
|
+
# could fetch or expand them. Structural complexity is bounded with a
|
|
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. 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.
|
|
29
|
+
#
|
|
30
|
+
# Ruby's `Timeout` is deliberately not used here: it cannot reliably
|
|
31
|
+
# interrupt libxml2's C-level parsing. Callers that need a hard wall-clock
|
|
32
|
+
# bound must run the parse in a subprocess with resource limits; the limits
|
|
33
|
+
# enforced here bound the work the C parser is asked to do.
|
|
34
|
+
#
|
|
35
|
+
# @api private
|
|
36
|
+
#
|
|
37
|
+
class XmlGuard
|
|
38
|
+
# DTD and entity declarations, which are never legitimate in a report.
|
|
39
|
+
#
|
|
40
|
+
# Matched case-insensitively against the raw bytes as a cheap first line
|
|
41
|
+
# of defence; the streaming pass also rejects the parsed
|
|
42
|
+
# `DOCTYPE`/`ENTITY` nodes, which covers encodings this byte scan cannot
|
|
43
|
+
# see (for example UTF-16).
|
|
44
|
+
FORBIDDEN_DECLARATION = /<!doctype|<!entity/i
|
|
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
|
+
|
|
52
|
+
#
|
|
53
|
+
# Parse options shared by the streaming reader and the DOM parser.
|
|
54
|
+
#
|
|
55
|
+
# * `nonet` forbids network access (external entities are never fetched).
|
|
56
|
+
# * `nonoent`, `nodtdload` and `nodtdattr` keep entity substitution and
|
|
57
|
+
# DTD loading disabled.
|
|
58
|
+
# * `nohuge` keeps libxml2's own hardcoded limits in force.
|
|
59
|
+
# * The base options are strict (`ParseOptions.new`), so malformed input
|
|
60
|
+
# is reported rather than silently recovered.
|
|
61
|
+
#
|
|
62
|
+
# @return [Nokogiri::XML::ParseOptions]
|
|
63
|
+
#
|
|
64
|
+
def self.parse_options
|
|
65
|
+
Nokogiri::XML::ParseOptions.new.tap do |options|
|
|
66
|
+
options.nonet
|
|
67
|
+
options.nonoent
|
|
68
|
+
options.nodtdload
|
|
69
|
+
options.nodtdattr
|
|
70
|
+
options.nohuge
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
#
|
|
75
|
+
# @param [String] xml
|
|
76
|
+
# The raw DMARC XML report.
|
|
77
|
+
# @param [Config] config
|
|
78
|
+
# The configuration whose limits are enforced.
|
|
79
|
+
#
|
|
80
|
+
def initialize(xml, config = DMARCator.config)
|
|
81
|
+
@xml = xml
|
|
82
|
+
@config = config
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#
|
|
86
|
+
# Rejects forbidden declarations and enforces structural limits without
|
|
87
|
+
# building a DOM.
|
|
88
|
+
#
|
|
89
|
+
# @return [XmlGuard] self
|
|
90
|
+
#
|
|
91
|
+
# @raise [ForbiddenXml]
|
|
92
|
+
# The report contains a DTD or entity declaration.
|
|
93
|
+
# @raise [ReportTooComplex]
|
|
94
|
+
# The report exceeds a configured structural limit.
|
|
95
|
+
# @raise [InvalidReport]
|
|
96
|
+
# The report is not well-formed XML or its encoding is malformed.
|
|
97
|
+
#
|
|
98
|
+
def check!
|
|
99
|
+
reject_forbidden_declarations!
|
|
100
|
+
enforce_structural_limits!
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
#
|
|
105
|
+
# Parses the report into a hardened DOM.
|
|
106
|
+
#
|
|
107
|
+
# @return [Nokogiri::XML::Document]
|
|
108
|
+
#
|
|
109
|
+
# @raise [InvalidReport]
|
|
110
|
+
# The report is not well-formed XML or its encoding is malformed.
|
|
111
|
+
#
|
|
112
|
+
def document
|
|
113
|
+
@document ||= parse_document
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def reject_forbidden_declarations!
|
|
119
|
+
haystack = if @xml.encoding.ascii_compatible? && @xml.valid_encoding?
|
|
120
|
+
@xml
|
|
121
|
+
else
|
|
122
|
+
@xml.b
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
return unless haystack.match?(FORBIDDEN_DECLARATION)
|
|
126
|
+
|
|
127
|
+
raise ForbiddenXml, FORBIDDEN_MESSAGE
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def enforce_structural_limits!
|
|
131
|
+
reader = Nokogiri::XML::Reader(@xml, nil, nil, self.class.parse_options)
|
|
132
|
+
|
|
133
|
+
nodes = 0
|
|
134
|
+
records = 0
|
|
135
|
+
attributes = 0
|
|
136
|
+
|
|
137
|
+
stack = []
|
|
138
|
+
root_seen = false
|
|
139
|
+
record_auth_results = 0
|
|
140
|
+
record_reasons = 0
|
|
141
|
+
|
|
142
|
+
while reader.read
|
|
143
|
+
case reader.node_type
|
|
144
|
+
when Nokogiri::XML::Reader::TYPE_DOCUMENT_TYPE,
|
|
145
|
+
Nokogiri::XML::Reader::TYPE_ENTITY
|
|
146
|
+
raise ForbiddenXml, FORBIDDEN_MESSAGE
|
|
147
|
+
when Nokogiri::XML::Reader::TYPE_ELEMENT
|
|
148
|
+
nodes += 1
|
|
149
|
+
attributes += reader.attribute_count
|
|
150
|
+
|
|
151
|
+
enforce(:max_xml_nodes, nodes)
|
|
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?
|
|
180
|
+
when Nokogiri::XML::Reader::TYPE_TEXT,
|
|
181
|
+
Nokogiri::XML::Reader::TYPE_CDATA,
|
|
182
|
+
Nokogiri::XML::Reader::TYPE_WHITESPACE,
|
|
183
|
+
Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE,
|
|
184
|
+
Nokogiri::XML::Reader::TYPE_COMMENT,
|
|
185
|
+
Nokogiri::XML::Reader::TYPE_PROCESSING_INSTRUCTION,
|
|
186
|
+
Nokogiri::XML::Reader::TYPE_ENTITY_REFERENCE
|
|
187
|
+
nodes += 1
|
|
188
|
+
enforce(:max_xml_nodes, nodes)
|
|
189
|
+
|
|
190
|
+
value = reader.value
|
|
191
|
+
enforce(:max_text_length, value.bytesize) if value
|
|
192
|
+
when Nokogiri::XML::Reader::TYPE_END_ELEMENT
|
|
193
|
+
stack.pop
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
197
|
+
raise InvalidReport, sanitize_syntax_error(e)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def enforce(setting, value)
|
|
201
|
+
limit = @config.public_send(setting)
|
|
202
|
+
return unless value > limit
|
|
203
|
+
|
|
204
|
+
raise ReportTooComplex, "DMARC XML report exceeds #{setting} of #{limit}"
|
|
205
|
+
end
|
|
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
|
+
|
|
223
|
+
def parse_document
|
|
224
|
+
document = Nokogiri::XML.parse(@xml, nil, nil, self.class.parse_options)
|
|
225
|
+
|
|
226
|
+
document
|
|
227
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
228
|
+
raise InvalidReport, sanitize_syntax_error(e)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
data/lib/dmarcator/report.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'nokogiri'
|
|
4
|
-
|
|
5
3
|
require_relative 'config'
|
|
4
|
+
require_relative 'internal/xml_guard'
|
|
6
5
|
require_relative 'internal/xml_helper'
|
|
7
6
|
require_relative 'exceptions'
|
|
8
7
|
require_relative 'report/metadata'
|
|
@@ -13,8 +12,10 @@ module DMARCator
|
|
|
13
12
|
#
|
|
14
13
|
# Represents a parsed DMARC aggregate/forensic XML report.
|
|
15
14
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# Report XML is treated as untrusted input: DTDs and entity declarations are
|
|
16
|
+
# rejected, network access and entity expansion are disabled, and structural
|
|
17
|
+
# complexity (nodes, depth, records, text and attribute counts) is bounded by
|
|
18
|
+
# {DMARCator.configure} limits before any DOM is built.
|
|
18
19
|
#
|
|
19
20
|
class Report
|
|
20
21
|
include Internal::XmlHelper
|
|
@@ -63,6 +64,10 @@ module DMARCator
|
|
|
63
64
|
# @raise [InvalidReport]
|
|
64
65
|
# The report is not a String, exceeds
|
|
65
66
|
# {Config#max_report_bytes}, or is not well-formed XML.
|
|
67
|
+
# @raise [ForbiddenXml]
|
|
68
|
+
# The report contains a DTD or entity declaration.
|
|
69
|
+
# @raise [ReportTooComplex]
|
|
70
|
+
# The report exceeds a configured structural limit.
|
|
66
71
|
#
|
|
67
72
|
def initialize(xml)
|
|
68
73
|
unless xml.is_a?(String)
|
|
@@ -77,12 +82,10 @@ module DMARCator
|
|
|
77
82
|
raise InvalidReport, "DMARC XML report exceeds maximum size of #{limit} bytes"
|
|
78
83
|
end
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
unless @node.errors.empty?
|
|
83
|
-
raise InvalidReport, "invalid DMARC XML report: #{@node.errors.first}"
|
|
84
|
-
end
|
|
85
|
+
guard = Internal::XmlGuard.new(xml)
|
|
86
|
+
guard.check!
|
|
85
87
|
|
|
88
|
+
@node = guard.document
|
|
86
89
|
@node.remove_namespaces!
|
|
87
90
|
end
|
|
88
91
|
|
data/lib/dmarcator/version.rb
CHANGED
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.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Noah Berman
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- lib/dmarcator/config.rb
|
|
39
39
|
- lib/dmarcator/exceptions.rb
|
|
40
40
|
- lib/dmarcator/internal/parser.rb
|
|
41
|
+
- lib/dmarcator/internal/xml_guard.rb
|
|
41
42
|
- lib/dmarcator/internal/xml_helper.rb
|
|
42
43
|
- lib/dmarcator/query.rb
|
|
43
44
|
- lib/dmarcator/record.rb
|