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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a086ee4ab9118b9e83ca143ba5c353410b2ff8700e5e322211678b7dfa9bbe4
4
- data.tar.gz: ea8ff974a581d31003e7a535d896c76d8c440842ea07114f9481778d2116a3c1
3
+ metadata.gz: 9b46a83fc4dd32476a29b8c197095b65617a18a5936e90b6121767376154ee2d
4
+ data.tar.gz: e20185095d16cccb6f717760f82e49e6a01963268ff34312bcf21e4d48316d99
5
5
  SHA512:
6
- metadata.gz: 0a0eae0d2cdc73838df1045457af6a3c27f7110aef3aed1375d78a1a05744a22df90438f95bc9c9de8be2a96a4e16a28409ca52bcb949ade8e04a2c799a3463e
7
- data.tar.gz: 0de333d23526642595a595745048b94c9b525c8f89e19ad76b1fba4a00a07ddc435533c60d766088f882e9505f8962325abe4db36bef24dd78e5900924bed8bc
6
+ metadata.gz: 4d61ee904a79ac3e44dbb052f033cef8288b9417be6fc9fda21991c1591ef3f426284333ca6d44fed8226256c54f32e426890075bd89ead162af514c664583a6
7
+ data.tar.gz: e4912ee834c01be36fdae2fbada466899192dc3c42e97898c928d4a1c69a006de38bc311fce178c18a049061371c660be8938164c957c903c82cd6ba6c066e9e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## 0.1.1
4
8
 
5
9
  Documentation
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. 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.
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
@@ -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.1"
2
+ VERSION = "0.2.0"
3
3
  end
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.1.1
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: []