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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +54 -30
- data/lib/mta_sts/fetcher.rb +92 -5
- data/lib/mta_sts/resolver.rb +18 -6
- data/lib/mta_sts/version.rb +1 -1
- data/mta_sts.gemspec +3 -4
- metadata +5 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b46a83fc4dd32476a29b8c197095b65617a18a5936e90b6121767376154ee2d
|
|
4
|
+
data.tar.gz: e20185095d16cccb6f717760f82e49e6a01963268ff34312bcf21e4d48316d99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d61ee904a79ac3e44dbb052f033cef8288b9417be6fc9fda21991c1591ef3f426284333ca6d44fed8226256c54f32e426890075bd89ead162af514c664583a6
|
|
7
|
+
data.tar.gz: e4912ee834c01be36fdae2fbada466899192dc3c42e97898c928d4a1c69a006de38bc311fce178c18a049061371c660be8938164c957c903c82cd6ba6c066e9e
|
data/CHANGELOG.md
CHANGED
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
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.**
|
|
236
|
-
|
|
237
|
-
- **
|
|
238
|
-
|
|
239
|
-
- **
|
|
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](
|
|
285
|
+
View the [changelog](CHANGELOG.md).
|
|
253
286
|
|
|
254
287
|
## Contributing
|
|
255
288
|
|
|
256
|
-
Everyone is encouraged to help improve this project
|
|
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
|
|
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).
|
data/lib/mta_sts/fetcher.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/mta_sts/resolver.rb
CHANGED
|
@@ -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
|
|
22
|
-
|
|
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
|
|
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
|
|
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
|
|
47
|
+
raise ServerFailure, "couldn't resolve #{type} #{name}: #{reason(error)}"
|
|
36
48
|
end
|
|
37
49
|
|
|
38
50
|
def reason(error)
|
data/lib/mta_sts/version.rb
CHANGED
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 = [ "
|
|
10
|
-
spec.email = [ "support@postrider.dev" ]
|
|
9
|
+
spec.authors = [ "Simon Lev" ]
|
|
11
10
|
|
|
12
|
-
spec.summary = "MTA
|
|
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
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
|
-
|
|
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
|
|
86
|
+
summary: SMTP MTA Strict Transport Security for Ruby.
|
|
88
87
|
test_files: []
|