dmarcator 1.2.0 → 1.3.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 +44 -5
- data/lib/dmarcator/config.rb +120 -0
- data/lib/dmarcator/exceptions.rb +26 -0
- data/lib/dmarcator/internal/xml_guard.rb +174 -0
- data/lib/dmarcator/report/row.rb +12 -0
- data/lib/dmarcator/report.rb +12 -9
- data/lib/dmarcator/resolver/plain.rb +7 -2
- 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: 39f655b89bd8e953e40ba9ada0fe936fde9d754acfa93e5825378c8b261034c3
|
|
4
|
+
data.tar.gz: 19abc3658c2339af30ef478befdb01e5c4ab019d15abdf51c341e53e3ae9da9a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c64d68c98be76d6611d0a30407172f83dc7ed3c412123745007c21a46ced185d063a62654fd285ad0c81f94ed9d72e468223736aaf2f61024a9d0f345c7b0db8
|
|
7
|
+
data.tar.gz: 1dab899381cd01d67279ba81a173fec42599e46c585bbed0637e934cf059b8044d55676120ddd0b376c17ceb30bc081b9a773ab8cfae0f94433bca074c39f35c
|
data/ChangeLog.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
### 1.3.0 / 2026-11-01
|
|
2
|
+
|
|
3
|
+
* 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.
|
|
4
|
+
* 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.
|
|
5
|
+
* 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`.
|
|
6
|
+
* 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).
|
|
7
|
+
* Add `DMARCator::ReportTooComplex` and `DMARCator::ForbiddenXml` error classes (both `DMARCator::Error`).
|
|
8
|
+
|
|
9
|
+
### 1.2.1 / 2026-10-30
|
|
10
|
+
|
|
11
|
+
* 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`.
|
|
12
|
+
* Report a TCP response body shorter than its declared length as `short DNS response (expected N bytes, got M)`, instead of the opaque `malformed DNS response: limit exceeded`.
|
|
13
|
+
|
|
14
|
+
No public API changes.
|
|
15
|
+
|
|
1
16
|
### 1.2.0 / 2026-10-30
|
|
2
17
|
|
|
3
18
|
* Plaintext lookups now use DNS over TCP (`DMARCator::Resolver::Plain`) instead of `Resolv::DNS`. `Resolv` reads UDP replies into a 512-byte buffer, so a larger answer cannot be decoded, is discarded, and the resulting timeout is swallowed. Any TXT set over 512 bytes — common for SPF and for domains with several DKIM keys — was therefore returned as an empty array, which is indistinguishable from "no records published". TCP has no such limit, and failures raise `ResolutionError`.
|
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,11 +343,26 @@ 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.
|
|
351
|
+
* Report structural complexity is bounded before any DOM is built. A report
|
|
352
|
+
that exceeds a configured limit raises `DMARCator::ReportTooComplex`:
|
|
353
|
+
|
|
354
|
+
| Setting | Default | Bounds |
|
|
355
|
+
|---|---|---|
|
|
356
|
+
| `max_report_bytes` | 25 MiB | input size |
|
|
357
|
+
| `max_report_records` | 10,000 | `record` elements |
|
|
358
|
+
| `max_xml_nodes` | 100,000 | element/text/comment nodes |
|
|
359
|
+
| `max_xml_depth` | 50 | element nesting depth |
|
|
360
|
+
| `max_text_length` | 1 MiB | bytes per text node |
|
|
361
|
+
| `max_xml_attributes` | 100,000 | attribute nodes |
|
|
362
|
+
| `max_auth_results` | 100 | `auth_results` entries per record |
|
|
363
|
+
| `max_reasons` | 100 | `reason` entries per record |
|
|
364
|
+
|
|
365
|
+
Lower them to bound memory and CPU for reports from untrusted sources:
|
|
342
366
|
|
|
343
367
|
```ruby
|
|
344
368
|
DMARCator.configure { |config| config.max_report_bytes = 5 * 1024 * 1024 }
|
|
@@ -367,6 +391,21 @@ To run the RSpec tests:
|
|
|
367
391
|
rake spec
|
|
368
392
|
```
|
|
369
393
|
|
|
394
|
+
### Record parser corpus
|
|
395
|
+
|
|
396
|
+
`spec/support/record_corpus.txt` holds representative records — real-world
|
|
397
|
+
published records and edge cases — and `spec/support/record_corpus.json` the
|
|
398
|
+
outcomes they produced when the corpus was last accepted. The suite replays both
|
|
399
|
+
in lenient and strict mode, so a parser change that alters how any of them parses
|
|
400
|
+
fails with a diff.
|
|
401
|
+
|
|
402
|
+
If such a change is intentional, regenerate the file and review the JSON diff in
|
|
403
|
+
the commit:
|
|
404
|
+
|
|
405
|
+
```sh
|
|
406
|
+
ruby -Ilib script/generate_record_corpus.rb
|
|
407
|
+
```
|
|
408
|
+
|
|
370
409
|
## Releasing
|
|
371
410
|
|
|
372
411
|
Every published version must have an annotated tag pointing at the commit that
|
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,174 @@
|
|
|
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.
|
|
24
|
+
#
|
|
25
|
+
# Ruby's `Timeout` is deliberately not used here: it cannot reliably
|
|
26
|
+
# interrupt libxml2's C-level parsing. Callers that need a hard wall-clock
|
|
27
|
+
# bound must run the parse in a subprocess with resource limits; the limits
|
|
28
|
+
# enforced here bound the work the C parser is asked to do.
|
|
29
|
+
#
|
|
30
|
+
# @api private
|
|
31
|
+
#
|
|
32
|
+
class XmlGuard
|
|
33
|
+
# DTD and entity declarations, which are never legitimate in a report.
|
|
34
|
+
#
|
|
35
|
+
# Matched case-insensitively against the raw bytes as a cheap first line
|
|
36
|
+
# of defence; the streaming pass also rejects the parsed
|
|
37
|
+
# `DOCTYPE`/`ENTITY` nodes, which covers encodings this byte scan cannot
|
|
38
|
+
# see (for example UTF-16).
|
|
39
|
+
FORBIDDEN_DECLARATION = /<!doctype|<!entity/i
|
|
40
|
+
|
|
41
|
+
#
|
|
42
|
+
# Parse options shared by the streaming reader and the DOM parser.
|
|
43
|
+
#
|
|
44
|
+
# * `nonet` forbids network access (external entities are never fetched).
|
|
45
|
+
# * `nonoent`, `nodtdload` and `nodtdattr` keep entity substitution and
|
|
46
|
+
# DTD loading disabled.
|
|
47
|
+
# * `nohuge` keeps libxml2's own hardcoded limits in force.
|
|
48
|
+
# * The base options are strict (`ParseOptions.new`), so malformed input
|
|
49
|
+
# is reported rather than silently recovered.
|
|
50
|
+
#
|
|
51
|
+
# @return [Nokogiri::XML::ParseOptions]
|
|
52
|
+
#
|
|
53
|
+
def self.parse_options
|
|
54
|
+
Nokogiri::XML::ParseOptions.new.tap do |options|
|
|
55
|
+
options.nonet
|
|
56
|
+
options.nonoent
|
|
57
|
+
options.nodtdload
|
|
58
|
+
options.nodtdattr
|
|
59
|
+
options.nohuge
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#
|
|
64
|
+
# @param [String] xml
|
|
65
|
+
# The raw DMARC XML report.
|
|
66
|
+
# @param [Config] config
|
|
67
|
+
# The configuration whose limits are enforced.
|
|
68
|
+
#
|
|
69
|
+
def initialize(xml, config = DMARCator.config)
|
|
70
|
+
@xml = xml
|
|
71
|
+
@config = config
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
#
|
|
75
|
+
# Rejects forbidden declarations and enforces structural limits without
|
|
76
|
+
# building a DOM.
|
|
77
|
+
#
|
|
78
|
+
# @return [XmlGuard] self
|
|
79
|
+
#
|
|
80
|
+
# @raise [ForbiddenXml]
|
|
81
|
+
# The report contains a DTD or entity declaration.
|
|
82
|
+
# @raise [ReportTooComplex]
|
|
83
|
+
# The report exceeds a configured structural limit.
|
|
84
|
+
# @raise [InvalidReport]
|
|
85
|
+
# The report is not well-formed XML or its encoding is malformed.
|
|
86
|
+
#
|
|
87
|
+
def check!
|
|
88
|
+
reject_forbidden_declarations!
|
|
89
|
+
enforce_structural_limits!
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
#
|
|
94
|
+
# Parses the report into a hardened DOM.
|
|
95
|
+
#
|
|
96
|
+
# @return [Nokogiri::XML::Document]
|
|
97
|
+
#
|
|
98
|
+
# @raise [InvalidReport]
|
|
99
|
+
# The report is not well-formed XML or its encoding is malformed.
|
|
100
|
+
#
|
|
101
|
+
def document
|
|
102
|
+
@document ||= parse_document
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def reject_forbidden_declarations!
|
|
108
|
+
haystack = if @xml.encoding.ascii_compatible? && @xml.valid_encoding?
|
|
109
|
+
@xml
|
|
110
|
+
else
|
|
111
|
+
@xml.b
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
return unless haystack.match?(FORBIDDEN_DECLARATION)
|
|
115
|
+
|
|
116
|
+
raise ForbiddenXml, 'DMARC XML reports must not contain a DTD or entity declaration'
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def enforce_structural_limits!
|
|
120
|
+
reader = Nokogiri::XML::Reader(@xml, nil, nil, self.class.parse_options)
|
|
121
|
+
|
|
122
|
+
nodes = 0
|
|
123
|
+
records = 0
|
|
124
|
+
attributes = 0
|
|
125
|
+
|
|
126
|
+
while reader.read
|
|
127
|
+
case reader.node_type
|
|
128
|
+
when Nokogiri::XML::Reader::TYPE_DOCUMENT_TYPE,
|
|
129
|
+
Nokogiri::XML::Reader::TYPE_ENTITY
|
|
130
|
+
raise ForbiddenXml, 'DMARC XML reports must not contain a DTD or entity declaration'
|
|
131
|
+
when Nokogiri::XML::Reader::TYPE_ELEMENT
|
|
132
|
+
nodes += 1
|
|
133
|
+
records += 1 if reader.name == 'record'
|
|
134
|
+
attributes += reader.attribute_count
|
|
135
|
+
|
|
136
|
+
enforce(:max_xml_nodes, nodes)
|
|
137
|
+
enforce(:max_report_records, records)
|
|
138
|
+
enforce(:max_xml_depth, reader.depth + 1)
|
|
139
|
+
enforce(:max_xml_attributes, attributes)
|
|
140
|
+
when Nokogiri::XML::Reader::TYPE_TEXT,
|
|
141
|
+
Nokogiri::XML::Reader::TYPE_CDATA,
|
|
142
|
+
Nokogiri::XML::Reader::TYPE_WHITESPACE,
|
|
143
|
+
Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE,
|
|
144
|
+
Nokogiri::XML::Reader::TYPE_COMMENT,
|
|
145
|
+
Nokogiri::XML::Reader::TYPE_PROCESSING_INSTRUCTION,
|
|
146
|
+
Nokogiri::XML::Reader::TYPE_ENTITY_REFERENCE
|
|
147
|
+
nodes += 1
|
|
148
|
+
enforce(:max_xml_nodes, nodes)
|
|
149
|
+
|
|
150
|
+
value = reader.value
|
|
151
|
+
enforce(:max_text_length, value.bytesize) if value
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
155
|
+
raise InvalidReport, "invalid DMARC XML report: #{e.message}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def enforce(setting, value)
|
|
159
|
+
limit = @config.public_send(setting)
|
|
160
|
+
return unless value > limit
|
|
161
|
+
|
|
162
|
+
raise ReportTooComplex, "DMARC XML report exceeds #{setting} of #{limit}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def parse_document
|
|
166
|
+
document = Nokogiri::XML.parse(@xml, nil, nil, self.class.parse_options)
|
|
167
|
+
|
|
168
|
+
document
|
|
169
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
170
|
+
raise InvalidReport, "invalid DMARC XML report: #{e.message}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
data/lib/dmarcator/report/row.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../config'
|
|
4
|
+
require_relative '../exceptions'
|
|
3
5
|
require_relative '../internal/xml_helper'
|
|
4
6
|
require_relative 'auth_result'
|
|
5
7
|
require_relative 'reason'
|
|
@@ -106,6 +108,11 @@ module DMARCator
|
|
|
106
108
|
@spf ||= element.text.freeze
|
|
107
109
|
when 'reason'
|
|
108
110
|
@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
|
|
109
116
|
end
|
|
110
117
|
end
|
|
111
118
|
end
|
|
@@ -126,6 +133,11 @@ module DMARCator
|
|
|
126
133
|
def collect_auth_results(auth_results)
|
|
127
134
|
auth_results.element_children.each do |element|
|
|
128
135
|
@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
|
|
129
141
|
end
|
|
130
142
|
end
|
|
131
143
|
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
|
|
|
@@ -107,8 +107,13 @@ module DMARCator
|
|
|
107
107
|
header = socket.read(2)
|
|
108
108
|
raise ResolutionError, 'short DNS response' if header.nil? || header.bytesize < 2
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
length = header.unpack1('n')
|
|
111
|
+
body = socket.read(length)
|
|
112
|
+
|
|
113
|
+
if body.nil? || body.bytesize != length
|
|
114
|
+
raise ResolutionError,
|
|
115
|
+
"short DNS response (expected #{length} bytes, got #{body&.bytesize || 0})"
|
|
116
|
+
end
|
|
112
117
|
|
|
113
118
|
body
|
|
114
119
|
ensure
|
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.3.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
|