mta_sts 0.1.0 โ†’ 0.2.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: 0de9b2cdb6adccb9fb59489860cebd39ea5ccd9b31bafad218d05334bbbd79c5
4
- data.tar.gz: 4d4390e9071edf2f2ca6a1decbd32a8a862f24572d042464241cc11abb226594
3
+ metadata.gz: 9b46a83fc4dd32476a29b8c197095b65617a18a5936e90b6121767376154ee2d
4
+ data.tar.gz: e20185095d16cccb6f717760f82e49e6a01963268ff34312bcf21e4d48316d99
5
5
  SHA512:
6
- metadata.gz: 836678183d43c161fdff348dc1b6c466a485f071bfc2e6606b805631c89ddd6848dbe8f1e511219f6861a7e40df83c3197c1cacf70cca0f89062f9e4aa4cd12a
7
- data.tar.gz: aeeadc31d4ccf52cbd5b54267050ea1fcb01b891e49a9295f7dbd19beff6ea64f064e6299ae3d11208c68c2ea30552b0e6be1eec05bb1b639fe7e9d49b938c6d
6
+ metadata.gz: 4d61ee904a79ac3e44dbb052f033cef8288b9417be6fc9fda21991c1591ef3f426284333ca6d44fed8226256c54f32e426890075bd89ead162af514c664583a6
7
+ data.tar.gz: e4912ee834c01be36fdae2fbada466899192dc3c42e97898c928d4a1c69a006de38bc311fce178c18a049061371c660be8938164c957c903c82cd6ba6c066e9e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - The fetcher resolves the policy host and judges the address before dialling it
6
+
7
+ ## 0.1.1
8
+
9
+ Documentation
10
+
3
11
  ## 0.1.0
4
12
 
5
13
  Initial release.
data/README.md CHANGED
@@ -1,10 +1,30 @@
1
1
  # mta_sts
2
2
 
3
- ๐Ÿ”’ SMTP MTA Strict Transport Security for Ruby
4
-
5
- Answers one question before a message goes out: **must this connection be encrypted, and to which servers?**
6
-
7
- Resolve a recipient domain's MTA-STS policy (RFC 8461) and find out which of its mail servers may be used and whether TLS is optional. Also discovers where the domain wants TLS failure reports sent (RFC 8460 TLSRPT) โ€” discovery only; generating and submitting reports is yours.
3
+ ๐Ÿ”’ SMTP MTA Strict Transport Security for Ruby.
4
+
5
+ Answers one question before delivering a message: must this connection be encrypted, and to which servers?
6
+
7
+ **mta_sts** resolves a recipient domain's MTA-STS policy (RFC 8461) and finds out which of its mail servers may be used and whether TLS is optional. It also discovers where the domain wants TLS failure reports sent (RFC 8460 TLSRPT).
8
+
9
+ ## Contents
10
+
11
+ - [Installation](#installation)
12
+ - [How It Works](#how-it-works)
13
+ - [Looking Up a Policy](#looking-up-a-policy)
14
+ - [Results](#results)
15
+ - [Status and Policy Are Separate Axes](#status-and-policy-are-separate-axes)
16
+ - [Holding On to a Policy](#holding-on-to-a-policy)
17
+ - [TLSRPT Record Discovery](#tlsrpt-record-discovery)
18
+ - [Custom Resolver & Fetcher](#custom-resolver--fetcher)
19
+ - [Publishing a Policy](#publishing-a-policy)
20
+ - [Command Line](#command-line)
21
+ - [What It Is Strict About](#what-it-is-strict-about)
22
+ - [What It Is Not That Strict](#what-it-is-not-that-strict)
23
+ - [What It Does Not Check](#what-it-does-not-check)
24
+ - [Testing](#testing)
25
+ - [History](#history)
26
+ - [Contributing](#contributing)
27
+ - [License](#license)
8
28
 
9
29
  ## Installation
10
30
 
@@ -91,7 +111,6 @@ Four statuses, each with a predicate:
91
111
 
92
112
  `policy` is present whenever there's one to honor: fresh, renewed, or the `known` one you passed in that hasn't expired.
93
113
 
94
-
95
114
  | what happened | status | policy | `enforce?` |
96
115
  | ----------------------------------------- | ----------- | ------- | ---------- |
97
116
  | fresh fetch, `mode: enforce` | `found` | present | true |
@@ -99,7 +118,6 @@ Four statuses, each with a predicate:
99
118
  | TXT record gone, held an enforcing policy | `none` | held | **true** |
100
119
  | never had one, no TXT record | `none` | nil | false |
101
120
 
102
-
103
121
  The third row looks wrong and isn't. ยง5.1 keeps a policy in force for its `max_age` regardless of what DNS says now - deleting the TXT record does not withdraw enforcement, only serving `mode: none` does.
104
122
 
105
123
  Use `enforce?` and `allows?` for the connection decision, not `status`:
@@ -161,9 +179,21 @@ MtaSts.lookup("example.com", known: nil,
161
179
  fetcher: MyPolicyFetcher.new) # the HTTPS GET
162
180
  ```
163
181
 
164
- - **resolver** - needs one method, `txt`, over `dnsruby` by default. Covers discovery only (`_mta-sts` and `_smtp._tls`). Raise `NotFound` for no such record, `Timeout` or `ServerFailure` for no answer. The policy host itself is resolved by the HTTP stack inside the fetcher.
182
+ - **resolver** - needs one method, `txt`, over `dnsruby` by default. Covers discovery only (`_mta-sts` and `_smtp._tls`). Raise `NotFound` for no such record, `Timeout` or `ServerFailure` for no answer.
165
183
  - **fetcher** - one `GET`, over stdlib `Net::HTTP` by default, certificate verification on. Body size and a total wall-clock budget are capped so a hostile host cannot hold the thread forever.
166
184
 
185
+ The fetcher resolves the policy host itself, so that the address it dials is one it has judged:
186
+
187
+ ```ruby
188
+ MtaSts::Fetcher.new(
189
+ resolver: MyResolver.new, # needs `addresses` as well as `txt`
190
+ permitted: ->(address) { my_own_opinion(address) })
191
+ ```
192
+
193
+ - **permitted** - asked about each resolved address; the first one admitted is dialled. Defaults to `MtaSts::Fetcher.public_address?`, which admits public unicast and refuses private, loopback, link-local, and other special-use addresses, including ones wrapped or reached through NAT64. A deployment serving policies from inside its own network passes something more permissive.
194
+
195
+ The recipient domain names the policy host, so where it resolves is chosen by whoever is being sent mail โ€” without this, that's a request to an address of a stranger's choosing.
196
+
167
197
  ### Publishing a Policy
168
198
 
169
199
  ```ruby
@@ -219,7 +249,7 @@ Exit codes answer the MTA-STS question only. TLSRPT is printed for diagnostics a
219
249
  ## What It Is Strict About
220
250
 
221
251
  - HTTP 3xx are never followed
222
- - HTTP caching (RFC 7234) is never used - freshness comes from the TXT `id` and `max_age` only
252
+ - HTTP caching (RFC 7234) is never used. Freshness comes from the TXT `id` and `max_age` only
223
253
  - Only `200` is a policy
224
254
  - The certificate is verified against `mta-sts.<domain>`, the policy host name
225
255
  - The `_mta-sts` discovery record must match the ABNF (`v=STSv1;` case-sensitive, a single well-formed `id`)
@@ -227,16 +257,21 @@ Exit codes answer the MTA-STS question only. TLSRPT is printed for diagnostics a
227
257
 
228
258
  ## What It Is Not That Strict
229
259
 
230
- - `Content-Type: text/plain` is checked but not enforced - ยง3.2 says SHOULD, not MUST, and real policies in the wild omit it
260
+ - `Content-Type: text/plain` is checked but not enforced
231
261
  - Line terminators may be bare `LF` as well as `CRLF`
232
262
 
233
263
  ## What It Does Not Check
234
264
 
235
- - **Certificate revocation.** `Net::HTTP` doesn't check it, and this library doesn't wire up OCSP or CRL. Pass your own `fetcher` if you need it.
236
- - **The address the policy host resolves to.** Fetched at whatever address DNS returns. The certificate check is what protects you here.
237
- - **DANE.** A separate mechanism needing DNSSEC validation and the peer certificate mid-handshake, which this library never sees.
238
- - **TLSRPT report generation or submission.** This library finds the `rua`; building and sending aggregate reports is the application's job.
239
- - **Anything about the SMTP connection.** This library opens one socket, to fetch a policy over HTTPS. It never speaks SMTP.
265
+ - **Certificate revocation.**
266
+ `Net::HTTP` doesn't check it, and this library doesn't wire up OCSP or CRL. Pass your own `fetcher` if you need it.
267
+ - **The address the policy host resolves to.**
268
+ Fetched at whatever address DNS returns. The certificate check is what protects you here.
269
+ - **DANE.**
270
+ A separate mechanism needing DNSSEC validation and the peer certificate mid-handshake, which this library never sees.
271
+ - **TLSRPT report generation or submission.**
272
+ This library finds the `rua`; building and sending aggregate reports is the application's job.
273
+ - **Anything about the SMTP connection.**
274
+ This library opens one socket, to fetch a policy over HTTPS. It never speaks SMTP.
240
275
 
241
276
  ## Testing
242
277
 
@@ -245,30 +280,19 @@ bundle install
245
280
  rake
246
281
  ```
247
282
 
248
- Nothing leaves the machine. Every lookup takes a `resolver:` and a `fetcher:`, and the test doubles stand a hash in for DNS and a string in for the policy host.
249
-
250
283
  ## History
251
284
 
252
- View the [changelog](https://github.com/mailpiece/mta_sts/blob/main/CHANGELOG.md)
285
+ View the [changelog](CHANGELOG.md).
253
286
 
254
287
  ## Contributing
255
288
 
256
- Everyone is encouraged to help improve this project. Here are a few ways you can help:
289
+ Everyone is encouraged to help improve this project:
257
290
 
258
291
  - [Report bugs](https://github.com/mailpiece/mta_sts/issues)
259
- - Fix bugs and [submit pull requests](https://github.com/mailpiece/mta_sts/pulls)
292
+ - Fix bugs and submit pull requests
260
293
  - Write, clarify, or fix documentation
261
294
  - Suggest or add new features
262
295
 
263
- To get started with development:
264
-
265
- ```sh
266
- git clone https://github.com/mailpiece/mta_sts.git
267
- cd mta_sts
268
- bundle install
269
- bundle exec rakecl
270
- ```
271
-
272
296
  ## License
273
297
 
274
- MIT. See [LICENSE](LICENSE).
298
+ MIT. See [LICENSE](LICENSE).
@@ -1,6 +1,8 @@
1
+ require "ipaddr"
1
2
  require "net/http"
2
3
  require "openssl"
3
4
  require "zlib"
5
+ require "mta_sts/resolver"
4
6
  require "mta_sts/version"
5
7
 
6
8
  module MtaSts
@@ -9,6 +11,8 @@ module MtaSts
9
11
  class Error < StandardError; end
10
12
  class HttpError < Error; end
11
13
  class CertificateError < Error; end
14
+ # A temperror like any other failed fetch, so a policy already held stands.
15
+ class BlockedAddress < Error; end
12
16
  class Timeout < Error; end
13
17
 
14
18
  WELL_KNOWN_PATH = "/.well-known/mta-sts.txt"
@@ -34,12 +38,75 @@ module MtaSts
34
38
  end
35
39
  end
36
40
 
41
+ # Special-use IPv4 no policy is published in. RFC 1918, loopback and
42
+ # link-local are left to the IPAddr predicates.
43
+ RESERVED_V4 = %w[
44
+ 0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24
45
+ 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
46
+ ].map { |range| IPAddr.new(range) }.freeze
47
+
48
+ RESERVED_V6 = %w[
49
+ ::/128 64:ff9b:1::/48 100::/64 2001::/32 2001:2::/48 2001:db8::/32
50
+ 2002::/16 fec0::/10 ff00::/8
51
+ ].map { |range| IPAddr.new(range) }.freeze
52
+
53
+ # RFC 6052 ยง2.2 with a /96 prefix
54
+ NAT64 = IPAddr.new("64:ff9b::/96")
55
+
56
+ class << self
57
+ # The policy host is named by the domain being delivered to, so where it
58
+ # resolves is a stranger's to choose.
59
+ def public_address?(address)
60
+ ip = address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
61
+
62
+ case
63
+ # No resolver legitimately answers with an address wrapped in another,
64
+ # whatever it wraps.
65
+ when ip.ipv4_mapped? || ipv4_compat?(ip) then false
66
+ when ip.ipv4? then public_v4?(ip)
67
+ when NAT64.include?(ip) then public_v4?(embedded_v4(ip))
68
+ else public_v6?(ip)
69
+ end
70
+ rescue IPAddr::Error
71
+ false
72
+ end
73
+
74
+ private
75
+ def public_v4?(ip)
76
+ !(ip.private? || ip.loopback? || ip.link_local? || covered_by?(RESERVED_V4, ip))
77
+ end
78
+
79
+ def public_v6?(ip)
80
+ !(ip.private? || ip.loopback? || ip.link_local? || covered_by?(RESERVED_V6, ip))
81
+ end
82
+
83
+ def embedded_v4(ip)
84
+ IPAddr.new(ip.to_i & 0xffffffff, Socket::AF_INET)
85
+ end
86
+
87
+ def covered_by?(ranges, ip)
88
+ ranges.any? { |range| range.include?(ip) }
89
+ end
90
+
91
+ # IPAddr#ipv4_compat? is obsolete; reimplemented since the check itself
92
+ # (the deprecated ::a.b.c.d form) is still one we need to catch.
93
+ def ipv4_compat?(ip)
94
+ return false unless ip.ipv6?
95
+
96
+ low = ip.to_i & 0xffffffff
97
+ (ip.to_i >> 32).zero? && low != 0 && low != 1
98
+ end
99
+ end
100
+
37
101
  def initialize(open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT,
38
- total_timeout: TOTAL_TIMEOUT, max_body_bytes: MAX_BODY_BYTES)
102
+ total_timeout: TOTAL_TIMEOUT, max_body_bytes: MAX_BODY_BYTES,
103
+ resolver: Resolver.new, permitted: Fetcher.method(:public_address?))
39
104
  @open_timeout = open_timeout
40
105
  @read_timeout = read_timeout
41
106
  @total_timeout = total_timeout
42
107
  @max_body_bytes = max_body_bytes
108
+ @resolver = resolver
109
+ @permitted = permitted
43
110
  end
44
111
 
45
112
  def fetch(host)
@@ -53,8 +120,9 @@ module MtaSts
53
120
 
54
121
  def get(host)
55
122
  deadline = monotonic_now + @total_timeout
123
+ address = permitted_address(host)
56
124
 
57
- with_response(host) do |response|
125
+ with_response(host, address) do |response|
58
126
  if response.is_a?(Net::HTTPOK)
59
127
  body_from(response, host, deadline)
60
128
  else
@@ -64,10 +132,26 @@ module MtaSts
64
132
  end
65
133
  end
66
134
 
67
- def with_response(host)
135
+ # Resolved once here and carried into the connection: resolving again to
136
+ # dial would leave the second answer free to differ from the one judged.
137
+ def permitted_address(host)
138
+ resolve(host).find { |address| @permitted.call(address) } or
139
+ raise BlockedAddress, "#{host} resolves to no address a policy may be published at"
140
+ end
141
+
142
+ def resolve(host)
143
+ addresses = @resolver.addresses(host)
144
+ raise Error, "couldn't resolve #{host}" if addresses.empty?
145
+
146
+ addresses
147
+ rescue Resolver::Error => error
148
+ raise Error, "couldn't resolve #{host}: #{error.message}"
149
+ end
150
+
151
+ def with_response(host, address)
68
152
  result = nil
69
153
 
70
- connection(host).start do |http|
154
+ connection(host, address).start do |http|
71
155
  http.request(policy_request) { |response| result = yield response }
72
156
  end
73
157
 
@@ -82,8 +166,11 @@ module MtaSts
82
166
  raise Error, "couldn't reach #{host}: #{error.message}"
83
167
  end
84
168
 
85
- def connection(host)
169
+ def connection(host, address)
86
170
  Net::HTTP.new(host, PORT).tap do |http|
171
+ # Dial the address already judged; the name stays behind for SNI, the
172
+ # Host header, and the certificate the whole fetch rests on.
173
+ http.ipaddr = address
87
174
  http.use_ssl = true
88
175
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER
89
176
  http.verify_hostname = true
@@ -14,25 +14,37 @@ module MtaSts
14
14
  end
15
15
 
16
16
  def txt(name)
17
- records(name).map { |record| record.strings.join }
17
+ records(name, "TXT").map { |record| record.strings.join }
18
+ end
19
+
20
+ def addresses(name)
21
+ %w[ A AAAA ].flat_map { |type| family(name, type) }
18
22
  end
19
23
 
20
24
  private
21
- def records(name)
22
- answer = resolver.query(name.to_s, "TXT").answer.grep(Dnsruby::RR::IN::TXT)
25
+ def family(name, type)
26
+ records(name, type).map { |record| record.address.to_s.downcase }
27
+ rescue NotFound
28
+ []
29
+ end
30
+
31
+ # Filter by type: a CNAME answer carries the alias beside the records it
32
+ # leads to.
33
+ def records(name, type)
34
+ answer = resolver.query(name.to_s, type).answer.select { |record| record.type == type }
23
35
 
24
36
  if answer.any?
25
37
  answer
26
38
  else
27
- raise NotFound, "no TXT records for #{name}"
39
+ raise NotFound, "no #{type} records for #{name}"
28
40
  end
29
41
  rescue Dnsruby::NXDomain
30
42
  raise NotFound, "no such name #{name}"
31
43
  rescue Dnsruby::ResolvTimeout
32
- raise Timeout, "timed out resolving TXT #{name}"
44
+ raise Timeout, "timed out resolving #{type} #{name}"
33
45
  # Fall through to ServerFailure, never NotFound โ€” "couldn't ask" must not become "no policy"
34
46
  rescue Dnsruby::ResolvError, IOError, SocketError, SystemCallError => error
35
- raise ServerFailure, "couldn't resolve TXT #{name}: #{reason(error)}"
47
+ raise ServerFailure, "couldn't resolve #{type} #{name}: #{reason(error)}"
36
48
  end
37
49
 
38
50
  def reason(error)
@@ -1,3 +1,3 @@
1
1
  module MtaSts
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/mta_sts.gemspec CHANGED
@@ -6,11 +6,10 @@ Gem::Specification.new do |spec|
6
6
  spec.version = MtaSts::VERSION
7
7
  spec.platform = Gem::Platform::RUBY
8
8
  spec.required_ruby_version = ">= 3.3.4"
9
- spec.authors = [ "PostRider" ]
10
- spec.email = [ "support@postrider.dev" ]
9
+ spec.authors = [ "Simon Lev" ]
11
10
 
12
- spec.summary = "MTA-STS policy discovery and enforcement for outbound SMTP in Ruby."
13
- spec.description = "Answers one question before delivering a message: must this connection be encrypted, and to which servers?"
11
+ spec.summary = "SMTP MTA Strict Transport Security for Ruby."
12
+ spec.description = "Answers one question before delivering a message: must this connection be encrypted, and to which servers? Implements MTA-STS policy discovery and TLSRPT record discovery."
14
13
 
15
14
  spec.homepage = "https://github.com/mailpiece/mta_sts"
16
15
  spec.license = "MIT"
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mta_sts
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
- - PostRider
7
+ - Simon Lev
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -38,9 +38,8 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.2'
40
40
  description: 'Answers one question before delivering a message: must this connection
41
- be encrypted, and to which servers?'
42
- email:
43
- - support@postrider.dev
41
+ be encrypted, and to which servers? Implements MTA-STS policy discovery and TLSRPT
42
+ record discovery.'
44
43
  executables:
45
44
  - mta-sts
46
45
  extensions: []
@@ -84,5 +83,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
84
83
  requirements: []
85
84
  rubygems_version: 4.0.3
86
85
  specification_version: 4
87
- summary: MTA-STS policy discovery and enforcement for outbound SMTP in Ruby.
86
+ summary: SMTP MTA Strict Transport Security for Ruby.
88
87
  test_files: []