mta_sts 0.1.1 → 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 +4 -0
- data/README.md +13 -1
- 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 +1 -1
- metadata +3 -2
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
|
@@ -179,9 +179,21 @@ MtaSts.lookup("example.com", known: nil,
|
|
|
179
179
|
fetcher: MyPolicyFetcher.new) # the HTTPS GET
|
|
180
180
|
```
|
|
181
181
|
|
|
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.
|
|
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.
|
|
183
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.
|
|
184
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
|
+
|
|
185
197
|
### Publishing a Policy
|
|
186
198
|
|
|
187
199
|
```ruby
|
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
|
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.authors = [ "Simon Lev" ]
|
|
10
10
|
|
|
11
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?"
|
|
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."
|
|
13
13
|
|
|
14
14
|
spec.homepage = "https://github.com/mailpiece/mta_sts"
|
|
15
15
|
spec.license = "MIT"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
|
|
@@ -38,7 +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?
|
|
41
|
+
be encrypted, and to which servers? Implements MTA-STS policy discovery and TLSRPT
|
|
42
|
+
record discovery.'
|
|
42
43
|
executables:
|
|
43
44
|
- mta-sts
|
|
44
45
|
extensions: []
|