mta_sts 0.6.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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +262 -0
- data/lib/mta_sts/fetcher.rb +262 -0
- data/lib/mta_sts/policy.rb +179 -0
- data/lib/mta_sts/report/result.rb +35 -0
- data/lib/mta_sts/report.rb +105 -0
- data/lib/mta_sts/result.rb +47 -0
- data/lib/mta_sts/version.rb +3 -0
- data/lib/mta_sts.rb +153 -0
- data/mta_sts.gemspec +33 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d9411774536a59bfad560263255edf3e3d7d72d79fb943a6033027de9288dc9f
|
|
4
|
+
data.tar.gz: 272a5f2abd617892cec5cd21279300fa00b52fbaec16686c977657d6b9a37ddc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a3c37c313c46186d4073cc8576ce88e588f3b02aaf35c1f9b305df98a6ca82111c78efdad642a5f2fa755f7a3e9e27590dcfa5f04c0068d70b9301e3a37c4840
|
|
7
|
+
data.tar.gz: 5edd1bc7ff60ea8d161a8627ac90ba8b50d44d8f6938787f3b3dbb16512c90b750ec682acc32177457c11d64617045bf2a18c15e9d9e196017b899d194389272
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PostRider
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# mta_sts
|
|
2
|
+
|
|
3
|
+
🔒 SMTP MTA Strict Transport Security for Ruby.
|
|
4
|
+
|
|
5
|
+
Look up a recipient domain's MTA-STS policy before delivering a message, to know whether TLS must be enforced and which servers are legitimate.
|
|
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
|
+
For outbound MTAs deciding how to deliver a message: mail servers refusing to downgrade to plaintext or connect to an unlisted host, senders discovering where to submit TLSRPT aggregate reports.
|
|
10
|
+
|
|
11
|
+
**mta_sts** handles:
|
|
12
|
+
|
|
13
|
+
- MTA-STS policy lookup (RFC 8461) - which mail servers a domain vouches for, and whether TLS is optional or must be enforced
|
|
14
|
+
- status and policy kept as separate axes, so a DNS timeout can't be mistaken for a domain that dropped its policy
|
|
15
|
+
- policy persistence handled by the caller - hand back what you kept last time, and get told what to keep next
|
|
16
|
+
- TLSRPT record discovery (RFC 8460) - where a domain wants TLS failure reports sent
|
|
17
|
+
- injectable resolver and fetcher, with the fetcher validating the resolved address before it dials, since the address is a stranger's choosing
|
|
18
|
+
- policy publishing and parsing helpers, for domains serving their own policy
|
|
19
|
+
|
|
20
|
+
> Resolves a policy and tells you whether to trust a server. What you do with that answer - defer, refuse, downgrade - stays in your code.
|
|
21
|
+
|
|
22
|
+
## Contents
|
|
23
|
+
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [Looking Up a Policy](#looking-up-a-policy)
|
|
26
|
+
- [Results](#results)
|
|
27
|
+
- [Status and Policy Are Separate Axes](#status-and-policy-are-separate-axes)
|
|
28
|
+
- [Holding On to a Policy](#holding-on-to-a-policy)
|
|
29
|
+
- [TLSRPT Record Discovery](#tlsrpt-record-discovery)
|
|
30
|
+
- [Custom Resolver & Fetcher](#custom-resolver--fetcher)
|
|
31
|
+
- [Publishing a Policy](#publishing-a-policy)
|
|
32
|
+
- [What It Is Strict About](#what-it-is-strict-about)
|
|
33
|
+
- [What It Is Not That Strict](#what-it-is-not-that-strict)
|
|
34
|
+
- [What It Does Not Check](#what-it-does-not-check)
|
|
35
|
+
- [Testing](#testing)
|
|
36
|
+
- [History](#history)
|
|
37
|
+
- [Contributing](#contributing)
|
|
38
|
+
- [Acknowledgments](#acknowledgments)
|
|
39
|
+
- [License](#license)
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
Add this line to your application's Gemfile:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
gem "mta_sts"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requires Ruby >= 3.4
|
|
50
|
+
|
|
51
|
+
## Looking Up a Policy
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
result = MtaSts.lookup(domain, known:, resolver:, fetcher:, now:)
|
|
55
|
+
# => #<MtaSts::Result status:, comment:, policy:>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- **domain** - the recipient domain, the part after the `@`
|
|
59
|
+
- **known** - the policy this library returned last time, or `nil`. There's no cache here, so this is how a policy survives between calls. Required on purpose: `known: nil` should be a decision, not an omission
|
|
60
|
+
- **resolver** - defaults to `MailResolver::Resolver.new`, from the [mailresolver](https://github.com/mailpiece/mailresolver) gem
|
|
61
|
+
- **fetcher** - defaults to `MtaSts::Fetcher.new`
|
|
62
|
+
- **now** - a `Time`, for expiry; defaults to `Time.now`
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
result = MtaSts.lookup("gmail.com", known: store["gmail.com"])
|
|
66
|
+
store["gmail.com"] = result.policy if result.policy
|
|
67
|
+
|
|
68
|
+
if result.enforce? && !result.allows?(mx_host)
|
|
69
|
+
# the policy names which servers are legitimate, and this is not one of them
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Hand back what you kept, keep what comes back - both halves matter.
|
|
74
|
+
|
|
75
|
+
## Results
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
result.status # => "found"
|
|
79
|
+
result.policy.mode # => :enforce
|
|
80
|
+
result.policy.mx # => ["gmail-smtp-in.l.google.com", "*.gmail-smtp-in.l.google.com"]
|
|
81
|
+
result.policy.max_age # => 86400
|
|
82
|
+
result.policy.id # => "20190429T010101"
|
|
83
|
+
result.policy.domain # => "gmail.com"
|
|
84
|
+
result.policy.expires_at # => 2026-07-29 14:03:11 UTC
|
|
85
|
+
|
|
86
|
+
result.enforce? # => true
|
|
87
|
+
result.allows?("alt1.gmail-smtp-in.l.google.com") # => true
|
|
88
|
+
result.allows?("mx.attacker.example") # => false
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Two questions get asked at connect time: must TLS be verified and non-optional (`enforce?`), and is this hostname one the domain vouches for (`allows?`).
|
|
92
|
+
|
|
93
|
+
## Status and Policy Are Separate Axes
|
|
94
|
+
|
|
95
|
+
`status` describes what this lookup learned. `policy` describes what you must honor. Nothing raises - a result always comes back with a `comment`:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
result.status # => "temperror"
|
|
99
|
+
result.comment # => "DNS didn't answer for _mta-sts.example.com"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Four statuses, each with a predicate:
|
|
103
|
+
|
|
104
|
+
- `found` / `found?` - a policy was fetched, or renewed against an unchanged `id`
|
|
105
|
+
- `none` / `no_record?` - no `_mta-sts` record seen
|
|
106
|
+
- `temperror` / `temperror?` - DNS or the policy host didn't answer
|
|
107
|
+
- `permerror` / `permerror?` - the record or policy file is broken
|
|
108
|
+
|
|
109
|
+
`policy` is present whenever there's one to honor: fresh, renewed, or the `known` one you passed in that hasn't expired.
|
|
110
|
+
|
|
111
|
+
| what happened | status | policy | `enforce?` |
|
|
112
|
+
| ------------------------------------------ | ----------- | ------- | ---------- |
|
|
113
|
+
| fresh fetch, `mode: enforce` | `found` | present | true |
|
|
114
|
+
| DNS timeout, held an enforcing policy | `temperror` | held | true |
|
|
115
|
+
| TXT record gone, held an enforcing policy | `none` | held | **true** |
|
|
116
|
+
| never had one, no TXT record | `none` | nil | false |
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
Use `enforce?` and `allows?` for the connection decision, not `status`:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
if result.enforce? && !result.allows?(mx)
|
|
124
|
+
defer_or_fail
|
|
125
|
+
elsif result.temperror? && !result.policy
|
|
126
|
+
defer # we don't know; don't downgrade
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`temperror` is not `none`. A resolver timeout means "we could not ask"; reading it as "no policy published" hands an attacker a downgrade for the cost of dropping a UDP packet.
|
|
131
|
+
|
|
132
|
+
## Holding On to a Policy
|
|
133
|
+
|
|
134
|
+
There's no cache in this library. `lookup` takes the policy you kept last time and hands one back.
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
result = MtaSts.lookup("example.com", known: store["example.com"])
|
|
138
|
+
store["example.com"] = result.policy if result.policy
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Skip that round trip and §5.1 doesn't apply. Three rules govern what comes back:
|
|
142
|
+
|
|
143
|
+
- A matching `id` renews the expiry without refetching
|
|
144
|
+
- A temporary failure keeps a policy that hasn't expired
|
|
145
|
+
- A fetched `mode: none` replaces it immediately
|
|
146
|
+
|
|
147
|
+
A policy also carries the domain it was fetched for. Handing back another domain's policy under the wrong key is ignored rather than enforced. Store that too when you persist:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
row = { body: policy.to_s, id: policy.id, domain: policy.domain, fetched_at: policy.fetched_at }
|
|
151
|
+
MtaSts::Policy.parse(row[:body], id: row[:id], domain: row[:domain], fetched_at: row[:fetched_at])
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## TLSRPT Record Discovery
|
|
155
|
+
|
|
156
|
+
Where a domain wants TLS failure reports sent (RFC 8460). One TXT lookup at `_smtp._tls.<domain>` — not report generation or submission.
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
result = MtaSts.reporting("gmail.com")
|
|
160
|
+
# => #<MtaSts::Report::Result status:, comment:, report:>
|
|
161
|
+
|
|
162
|
+
result.status # => "found"
|
|
163
|
+
result.rua # => ["mailto:tlsrpt@smtp.gmail.com"]
|
|
164
|
+
result.report.mailto
|
|
165
|
+
result.report.https
|
|
166
|
+
result.report.ignored # destinations that were named but are neither mailto: nor https:
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Same status vocabulary as policy lookup (`found` / `none` / `temperror` / `permerror`), with `no_record?` for `"none"`. Unknown TXT fields on the name are ignored; only `mailto:` and `https:` destinations are kept.
|
|
170
|
+
|
|
171
|
+
## Custom Resolver & Fetcher
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
MtaSts.lookup("example.com", known: nil,
|
|
175
|
+
resolver: MyCachingResolver.new, # DNS, for discovery
|
|
176
|
+
fetcher: MyPolicyFetcher.new) # the HTTPS GET
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
- **resolver** - needs one method, `txt`, over [mailresolver](https://github.com/mailpiece/mailresolver) by default. Covers discovery only (`_mta-sts` and `_smtp._tls`). Raise `MailResolver::NotFound` for no such record, `MailResolver::Timeout` or `MailResolver::ServerFailure` for no answer. If your app also uses `mailauth` or talks to DNS itself, build one `MailResolver::Resolver` and pass it everywhere — see mailresolver's README for why.
|
|
180
|
+
- **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.
|
|
181
|
+
|
|
182
|
+
The fetcher resolves the policy host itself, so that the address it dials is one it has judged:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
MtaSts::Fetcher.new(
|
|
186
|
+
resolver: MyResolver.new, # needs `addresses` as well as `txt`
|
|
187
|
+
permitted: ->(address) { my_own_opinion(address) })
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- **permitted** - asked about each resolved address; each one admitted is dialled in turn until the host answers. 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.
|
|
191
|
+
|
|
192
|
+
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.
|
|
193
|
+
|
|
194
|
+
## Publishing a Policy
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
policy = MtaSts::Policy.new(mode: :enforce, mx: ["mx.example.com"], max_age: 604800)
|
|
198
|
+
puts policy.to_s
|
|
199
|
+
```
|
|
200
|
+
version: STSv1
|
|
201
|
+
mode: enforce
|
|
202
|
+
mx: mx.example.com
|
|
203
|
+
max_age: 604800
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
And read one back:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
MtaSts::Policy.parse(File.read("mta-sts.txt"))
|
|
210
|
+
# => #<MtaSts::Policy mode: :enforce, mx: ["mx.example.com"], max_age: 604800>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## What It Is Strict About
|
|
214
|
+
|
|
215
|
+
- HTTP 3xx are never followed
|
|
216
|
+
- HTTP caching (RFC 7234) is never used. Freshness comes from the TXT `id` and `max_age` only
|
|
217
|
+
- Only `200` is a policy
|
|
218
|
+
- The certificate is verified against `mta-sts.<domain>`, the policy host name
|
|
219
|
+
- The `_mta-sts` discovery record must match the ABNF (`v=STSv1;` case-sensitive, a single well-formed `id`)
|
|
220
|
+
- Fetch body size and total time are capped
|
|
221
|
+
|
|
222
|
+
## What It Is Not That Strict
|
|
223
|
+
|
|
224
|
+
- `Content-Type: text/plain` is checked but not enforced
|
|
225
|
+
- Line terminators may be bare `LF` as well as `CRLF`
|
|
226
|
+
- The policy file is read case-insensitively, so `version: stsv1` and `mode: ENFORCE` both parse, though the ABNF spells both case-sensitively. The discovery record is not read this way: strictness is worth more on the record that decides whether a policy exists at all than on the document it leads to
|
|
227
|
+
|
|
228
|
+
## What It Does Not Check
|
|
229
|
+
|
|
230
|
+
- **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.
|
|
231
|
+
- **The address the policy host resolves to.** Fetched at whatever address DNS returns. The certificate check is what protects you here.
|
|
232
|
+
- **DANE.** A separate mechanism needing DNSSEC validation and the peer certificate mid-handshake, which this library never sees.
|
|
233
|
+
- **TLSRPT report generation or submission.** This library finds the `rua`; building and sending aggregate reports is the application's job.
|
|
234
|
+
- **Anything about the SMTP connection.** This library opens one socket, to fetch a policy over HTTPS. It never speaks SMTP.
|
|
235
|
+
|
|
236
|
+
## Testing
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
bundle install
|
|
240
|
+
rake
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## History
|
|
244
|
+
|
|
245
|
+
View the [changelog](CHANGELOG.md).
|
|
246
|
+
|
|
247
|
+
## Contributing
|
|
248
|
+
|
|
249
|
+
Everyone is encouraged to help improve this project:
|
|
250
|
+
|
|
251
|
+
- [Report bugs](https://github.com/mailpiece/mta_sts/issues)
|
|
252
|
+
- Fix bugs and submit pull requests
|
|
253
|
+
- Write, clarify, or fix documentation
|
|
254
|
+
- Suggest or add new features
|
|
255
|
+
|
|
256
|
+
## Acknowledgments
|
|
257
|
+
|
|
258
|
+
View the [acknowledgments](ACKNOWLEDGMENTS.md).
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
require "ipaddr"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "mailresolver"
|
|
6
|
+
require "mta_sts/version"
|
|
7
|
+
|
|
8
|
+
module MtaSts
|
|
9
|
+
# HTTPS GET of mta-sts.<domain>/.well-known/mta-sts.txt (RFC 8461 §3.3)
|
|
10
|
+
class Fetcher
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class HttpError < Error; end
|
|
13
|
+
class CertificateError < Error; end
|
|
14
|
+
# A temperror like any other failed fetch, so a policy already held stands.
|
|
15
|
+
class BlockedAddress < Error; end
|
|
16
|
+
class Timeout < Error; end
|
|
17
|
+
|
|
18
|
+
# A failure the policy host never answered through
|
|
19
|
+
class Unanswered < StandardError
|
|
20
|
+
attr_reader :failure
|
|
21
|
+
|
|
22
|
+
def initialize(failure)
|
|
23
|
+
super(failure.message)
|
|
24
|
+
@failure = failure
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
private_constant :Unanswered
|
|
28
|
+
|
|
29
|
+
WELL_KNOWN_PATH = "/.well-known/mta-sts.txt"
|
|
30
|
+
PORT = 443
|
|
31
|
+
OPEN_TIMEOUT = 10
|
|
32
|
+
READ_TIMEOUT = 10
|
|
33
|
+
# read_timeout renews per read; this one does not — stops a slow-drip body
|
|
34
|
+
TOTAL_TIMEOUT = 30
|
|
35
|
+
MAX_BODY_BYTES = 64 * 1024
|
|
36
|
+
USER_AGENT = "mta_sts/#{VERSION}"
|
|
37
|
+
|
|
38
|
+
# RFC 8461 §3.2 SHOULD text/plain — reported, not enforced
|
|
39
|
+
class Body < String
|
|
40
|
+
attr_reader :content_type
|
|
41
|
+
|
|
42
|
+
def initialize(body, content_type: nil)
|
|
43
|
+
super(body)
|
|
44
|
+
@content_type = content_type
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def text_plain?
|
|
48
|
+
content_type.to_s.split(";").first.to_s.strip.casecmp?("text/plain")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Special-use IPv4 no policy is published in. RFC 1918, loopback and
|
|
53
|
+
# link-local are left to the IPAddr predicates.
|
|
54
|
+
RESERVED_V4 = %w[
|
|
55
|
+
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
|
|
56
|
+
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
|
|
57
|
+
].map { |range| IPAddr.new(range) }.freeze
|
|
58
|
+
|
|
59
|
+
RESERVED_V6 = %w[
|
|
60
|
+
::/128 64:ff9b:1::/48 100::/64 2001::/32 2001:2::/48 2001:db8::/32
|
|
61
|
+
2002::/16 fec0::/10 ff00::/8
|
|
62
|
+
].map { |range| IPAddr.new(range) }.freeze
|
|
63
|
+
|
|
64
|
+
# RFC 6052 §2.2 with a /96 prefix
|
|
65
|
+
NAT64 = IPAddr.new("64:ff9b::/96")
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
# The policy host is named by the domain being delivered to, so where it
|
|
69
|
+
# resolves is a stranger's to choose.
|
|
70
|
+
def public_address?(address)
|
|
71
|
+
ip = address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
|
|
72
|
+
|
|
73
|
+
case
|
|
74
|
+
# No resolver legitimately answers with an address wrapped in another,
|
|
75
|
+
# whatever it wraps.
|
|
76
|
+
when ip.ipv4_mapped? || ipv4_compat?(ip) then false
|
|
77
|
+
when ip.ipv4? then public_v4?(ip)
|
|
78
|
+
when NAT64.include?(ip) then public_v4?(embedded_v4(ip))
|
|
79
|
+
else public_v6?(ip)
|
|
80
|
+
end
|
|
81
|
+
rescue IPAddr::Error
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
# IPAddr#ipv4_compat? is obsolete; reimplemented since the check itself
|
|
87
|
+
# (the deprecated ::a.b.c.d form) is still one we need to catch.
|
|
88
|
+
def ipv4_compat?(ip)
|
|
89
|
+
if ip.ipv6?
|
|
90
|
+
low = ip.to_i & 0xffffffff
|
|
91
|
+
(ip.to_i >> 32).zero? && low != 0 && low != 1
|
|
92
|
+
else
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def public_v4?(ip)
|
|
98
|
+
!(ip.private? || ip.loopback? || ip.link_local? || covered_by?(RESERVED_V4, ip))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def embedded_v4(ip)
|
|
102
|
+
IPAddr.new(ip.to_i & 0xffffffff, Socket::AF_INET)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def public_v6?(ip)
|
|
106
|
+
!(ip.private? || ip.loopback? || ip.link_local? || covered_by?(RESERVED_V6, ip))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def covered_by?(ranges, ip)
|
|
110
|
+
ranges.any? { |range| range.include?(ip) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def initialize(open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT,
|
|
115
|
+
total_timeout: TOTAL_TIMEOUT, max_body_bytes: MAX_BODY_BYTES,
|
|
116
|
+
resolver: MailResolver::Resolver.new, permitted: Fetcher.method(:public_address?))
|
|
117
|
+
@open_timeout = open_timeout
|
|
118
|
+
@read_timeout = read_timeout
|
|
119
|
+
@total_timeout = total_timeout
|
|
120
|
+
@max_body_bytes = max_body_bytes
|
|
121
|
+
@resolver = resolver
|
|
122
|
+
@permitted = permitted
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def fetch(host)
|
|
126
|
+
get(normalize(host))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
def normalize(host)
|
|
131
|
+
host.to_s.downcase.chomp(".")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def get(host)
|
|
135
|
+
deadline = monotonic_now + @total_timeout
|
|
136
|
+
failure = nil
|
|
137
|
+
|
|
138
|
+
# A policy host with several addresses isn't written off because the first
|
|
139
|
+
# is down — the domain published all of them, and any one serves.
|
|
140
|
+
permitted_addresses(host).each do |address|
|
|
141
|
+
remaining = deadline - monotonic_now
|
|
142
|
+
break unless remaining.positive?
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
return policy_from(host, address, deadline, remaining)
|
|
146
|
+
rescue Unanswered => unanswered
|
|
147
|
+
failure = unanswered.failure
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
raise failure || Timeout.new("timed out fetching the policy from #{host}")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Resolved once here and carried into every connection: resolving again to
|
|
155
|
+
# dial would leave the second answer free to differ from the one judged.
|
|
156
|
+
def permitted_addresses(host)
|
|
157
|
+
addresses = resolve(host).select { |address| @permitted.call(address) }
|
|
158
|
+
raise BlockedAddress, "#{host} resolves to no address a policy may be published at" if addresses.empty?
|
|
159
|
+
|
|
160
|
+
addresses
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def resolve(host)
|
|
164
|
+
addresses = @resolver.addresses(host)
|
|
165
|
+
raise Error, "couldn't resolve #{host}" if addresses.empty?
|
|
166
|
+
|
|
167
|
+
addresses
|
|
168
|
+
rescue MailResolver::Error => error
|
|
169
|
+
raise Error, "couldn't resolve #{host}: #{error.message}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def policy_from(host, address, deadline, remaining)
|
|
173
|
+
with_response(host, address, remaining) do |response|
|
|
174
|
+
if response.is_a?(Net::HTTPOK)
|
|
175
|
+
body_from(response, host, deadline)
|
|
176
|
+
else
|
|
177
|
+
# RFC 8461 §3.3 MUST NOT follow redirects
|
|
178
|
+
raise HttpError, "#{host} answered #{response.code}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# `answered` is the whole distinction the attempt loop turns on. read_timeout
|
|
184
|
+
# renews with every header line, so the deadline also bounds the headers.
|
|
185
|
+
def with_response(host, address, remaining)
|
|
186
|
+
answered = false
|
|
187
|
+
result = nil
|
|
188
|
+
|
|
189
|
+
::Timeout.timeout(remaining) do
|
|
190
|
+
connection(host, address).start do |http|
|
|
191
|
+
http.request(policy_request) do |response|
|
|
192
|
+
answered = true
|
|
193
|
+
result = yield response
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
result
|
|
199
|
+
rescue OpenSSL::SSL::SSLError => error
|
|
200
|
+
raise attempt_failure(answered, CertificateError.new("#{host} presented a certificate that didn't verify: #{error.message}"))
|
|
201
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
202
|
+
raise attempt_failure(answered, Timeout.new("timed out fetching the policy from #{host}"))
|
|
203
|
+
rescue ::Timeout::Error
|
|
204
|
+
raise attempt_failure(answered, Timeout.new("#{host} took longer than #{@total_timeout} seconds to send the policy"))
|
|
205
|
+
rescue Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error => error
|
|
206
|
+
raise attempt_failure(answered, HttpError.new("#{host} answered with malformed HTTP: #{error.message}"))
|
|
207
|
+
rescue SystemCallError, SocketError, IOError, EOFError => error
|
|
208
|
+
raise attempt_failure(answered, Error.new("couldn't reach #{host}: #{error.message}"))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def connection(host, address)
|
|
212
|
+
# The nil proxy ignores http_proxy: a proxy would resolve the name again,
|
|
213
|
+
# and the connection would no longer go to the address judged.
|
|
214
|
+
Net::HTTP.new(host, PORT, nil).tap do |http|
|
|
215
|
+
# Dial the address already judged; the name stays behind for SNI, the
|
|
216
|
+
# Host header, and the certificate the whole fetch rests on.
|
|
217
|
+
http.ipaddr = address
|
|
218
|
+
http.use_ssl = true
|
|
219
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
220
|
+
http.verify_hostname = true
|
|
221
|
+
http.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
222
|
+
http.open_timeout = @open_timeout
|
|
223
|
+
http.read_timeout = @read_timeout
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# RFC 8461 §3.3 — no HTTP caching; freshness is TXT id + max_age
|
|
228
|
+
def policy_request
|
|
229
|
+
Net::HTTP::Get.new(WELL_KNOWN_PATH, "accept" => "text/plain", "user-agent" => USER_AGENT)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def attempt_failure(answered, error)
|
|
233
|
+
if answered
|
|
234
|
+
error
|
|
235
|
+
else
|
|
236
|
+
Unanswered.new(error)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def body_from(response, host, deadline)
|
|
241
|
+
body = +""
|
|
242
|
+
|
|
243
|
+
response.read_body do |chunk|
|
|
244
|
+
body << chunk
|
|
245
|
+
|
|
246
|
+
if body.bytesize > @max_body_bytes
|
|
247
|
+
raise HttpError, "#{host} sent more than #{@max_body_bytes} bytes of policy"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if monotonic_now > deadline
|
|
251
|
+
raise Timeout, "#{host} took longer than #{@total_timeout} seconds to send the policy"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
Body.new(body.force_encoding(Encoding::UTF_8), content_type: response["content-type"])
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def monotonic_now
|
|
259
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
require "simpleidn"
|
|
2
|
+
|
|
3
|
+
module MtaSts
|
|
4
|
+
class Policy
|
|
5
|
+
class Invalid < StandardError; end
|
|
6
|
+
|
|
7
|
+
POLICY_VERSION = "STSv1"
|
|
8
|
+
MODES = %i[ none testing enforce ].freeze
|
|
9
|
+
MAX_AGE = (0..31_557_600) # RFC 8461 §3.2
|
|
10
|
+
WILDCARD = "*."
|
|
11
|
+
TERMINATOR = /\r?\n/ # RFC 8461 §3.2 ABNF: sts-policy-term = LF / CRLF
|
|
12
|
+
DIGITS = /\A\d+\z/
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def parse(text, id: nil, domain: nil, fetched_at: Time.now)
|
|
16
|
+
fields = fields_in(text)
|
|
17
|
+
|
|
18
|
+
validate_version single(fields, "version")
|
|
19
|
+
|
|
20
|
+
new mode: single(fields, "mode"), mx: fields.fetch("mx", []), max_age: single(fields, "max_age"),
|
|
21
|
+
id: id, domain: domain, fetched_at: fetched_at
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def normalize(name)
|
|
25
|
+
name = scrubbed(name).strip.chomp(".")
|
|
26
|
+
|
|
27
|
+
if name.start_with?(WILDCARD)
|
|
28
|
+
WILDCARD + ascii(name.delete_prefix(WILDCARD)).downcase
|
|
29
|
+
else
|
|
30
|
+
ascii(name).downcase
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
# RFC 8461 §3.2 — ignore unrecognized lines
|
|
36
|
+
def fields_in(text)
|
|
37
|
+
validated_document(text).split(TERMINATOR).each_with_object({}) do |line, fields|
|
|
38
|
+
name, value = line.split(":", 2)
|
|
39
|
+
|
|
40
|
+
(fields[name.strip.downcase] ||= []) << value.strip if value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validated_document(text)
|
|
45
|
+
text = text.to_s
|
|
46
|
+
|
|
47
|
+
if text.valid_encoding?
|
|
48
|
+
text
|
|
49
|
+
else
|
|
50
|
+
raise Invalid, "policy is not valid #{text.encoding}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# RFC 8461 §3.2 - a duplicated non-mx field: keep the first, ignore the rest.
|
|
55
|
+
def single(fields, name)
|
|
56
|
+
fields.fetch(name, []).first
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate_version(version)
|
|
60
|
+
unless version&.casecmp?(POLICY_VERSION)
|
|
61
|
+
raise Invalid, "version must be #{POLICY_VERSION}, not #{version.inspect}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def scrubbed(name)
|
|
66
|
+
name = name.to_s
|
|
67
|
+
name.valid_encoding? ? name : name.scrub
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ascii(name)
|
|
71
|
+
name.ascii_only? ? name : SimpleIDN.to_ascii(name)
|
|
72
|
+
rescue StandardError
|
|
73
|
+
name
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
attr_reader :mode, :mx, :max_age, :id, :domain, :fetched_at
|
|
78
|
+
|
|
79
|
+
def initialize(mode:, max_age:, mx: [], id: nil, domain: nil, fetched_at: Time.now)
|
|
80
|
+
@mode = validated_mode(mode)
|
|
81
|
+
@mx = validated_mx(mx)
|
|
82
|
+
@max_age = validated_max_age(max_age)
|
|
83
|
+
@domain = domain && self.class.normalize(domain)
|
|
84
|
+
@id, @fetched_at = id, fetched_at
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def none?
|
|
88
|
+
mode == :none
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def testing?
|
|
92
|
+
mode == :testing
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def enforce?
|
|
96
|
+
mode == :enforce
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def allows?(host)
|
|
100
|
+
if none?
|
|
101
|
+
true
|
|
102
|
+
else
|
|
103
|
+
host = self.class.normalize(host)
|
|
104
|
+
|
|
105
|
+
mx.any? { |pattern| matches?(pattern, host) }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def expires_at
|
|
110
|
+
fetched_at + max_age
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def expired?(now = Time.now)
|
|
114
|
+
now >= expires_at
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def to_s
|
|
118
|
+
lines = [ "version: #{POLICY_VERSION}", "mode: #{mode}" ]
|
|
119
|
+
lines.concat mx.map { |pattern| "mx: #{pattern}" }
|
|
120
|
+
lines << "max_age: #{max_age}"
|
|
121
|
+
|
|
122
|
+
lines.map { |line| "#{line}\r\n" }.join
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def inspect
|
|
126
|
+
"#<#{self.class.name} mode: #{mode.inspect}, mx: #{mx.inspect}, max_age: #{max_age.inspect}>"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
def validated_mode(mode)
|
|
131
|
+
if known = MODES.find { |candidate| candidate.to_s == mode.to_s.strip.downcase }
|
|
132
|
+
known
|
|
133
|
+
else
|
|
134
|
+
raise Invalid, "mode must be one of #{MODES.join(", ")}, not #{mode.inspect}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# RFC 8461 §3.2 — at least one mx unless mode is none
|
|
139
|
+
def validated_mx(mx)
|
|
140
|
+
patterns = Array(mx).map { |pattern| validated_pattern(pattern) }.uniq
|
|
141
|
+
|
|
142
|
+
if patterns.empty? && !none?
|
|
143
|
+
raise Invalid, "mx must name at least one host unless mode is none"
|
|
144
|
+
else
|
|
145
|
+
patterns
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validated_pattern(pattern)
|
|
150
|
+
normalized = self.class.normalize(pattern)
|
|
151
|
+
|
|
152
|
+
if normalized.delete("*.").empty?
|
|
153
|
+
raise Invalid, "mx must name a host, not #{pattern.inspect}"
|
|
154
|
+
else
|
|
155
|
+
normalized
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validated_max_age(max_age)
|
|
160
|
+
# grammar is 1*DIGIT — not Integer(), which accepts 0x10 / 1_0
|
|
161
|
+
if max_age.to_s.match?(DIGITS) && MAX_AGE.cover?(seconds = max_age.to_i)
|
|
162
|
+
seconds
|
|
163
|
+
else
|
|
164
|
+
raise Invalid, "max_age must be a whole number of seconds in #{MAX_AGE}, not #{max_age.inspect}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# RFC 8461 §4.1 — "*." covers exactly one label
|
|
169
|
+
def matches?(pattern, host)
|
|
170
|
+
if pattern.start_with?(WILDCARD)
|
|
171
|
+
label, _, parent = host.partition(".")
|
|
172
|
+
|
|
173
|
+
!label.empty? && parent == pattern.delete_prefix(WILDCARD)
|
|
174
|
+
else
|
|
175
|
+
pattern == host
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module MtaSts
|
|
2
|
+
class Report
|
|
3
|
+
class Result
|
|
4
|
+
attr_reader :status, :report, :comment
|
|
5
|
+
|
|
6
|
+
def initialize(status:, report: nil, comment: nil)
|
|
7
|
+
@status, @report, @comment = status, report, comment
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def found?
|
|
11
|
+
status == "found"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def no_record?
|
|
15
|
+
status == "none"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def temperror?
|
|
19
|
+
status == "temperror"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def permerror?
|
|
23
|
+
status == "permerror"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rua
|
|
27
|
+
report&.rua || []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def inspect
|
|
31
|
+
"#<#{self.class.name} status: #{status.inspect}, report: #{report.inspect}, comment: #{comment.inspect}>"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "mta_sts/policy"
|
|
2
|
+
require "mta_sts/report/result"
|
|
3
|
+
|
|
4
|
+
module MtaSts
|
|
5
|
+
class Report
|
|
6
|
+
class Invalid < StandardError; end
|
|
7
|
+
|
|
8
|
+
REPORT_VERSION = "TLSRPTv1"
|
|
9
|
+
# RFC 8460 §3 has the same ABNF/prose contradiction as RFC 8461 §3.1 (see
|
|
10
|
+
# STS_RECORD) — the literal "v=TLSRPTv1;" prefix is what's enforced.
|
|
11
|
+
REPORT_RECORD = /\Av=#{REPORT_VERSION};/
|
|
12
|
+
FIELD_DELIM = ";"
|
|
13
|
+
RUA_FIELD = "rua=" # RFC 8460 §3
|
|
14
|
+
URI_DELIM = ","
|
|
15
|
+
MAILTO = /\Amailto:/i # RFC 3986 §3.1 — scheme is case-insensitive
|
|
16
|
+
HTTPS = /\Ahttps:/i
|
|
17
|
+
SCHEMES = [ MAILTO, HTTPS ].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def parse(text, domain: nil)
|
|
21
|
+
new rua: uris_in(fields_in(text)), domain: domain
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
def fields_in(text)
|
|
26
|
+
fields = validated_record(text).split(FIELD_DELIM).drop(1).map(&:strip)
|
|
27
|
+
|
|
28
|
+
if fields.empty?
|
|
29
|
+
raise Invalid, "record must carry at least one field after v=#{REPORT_VERSION}"
|
|
30
|
+
else
|
|
31
|
+
fields
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validated_record(text)
|
|
36
|
+
text = validated_encoding(text)
|
|
37
|
+
|
|
38
|
+
if text.match?(REPORT_RECORD)
|
|
39
|
+
text
|
|
40
|
+
else
|
|
41
|
+
raise Invalid, "record must begin with v=#{REPORT_VERSION};, not #{text.inspect}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validated_encoding(text)
|
|
46
|
+
text = text.to_s
|
|
47
|
+
|
|
48
|
+
if text.valid_encoding?
|
|
49
|
+
text
|
|
50
|
+
else
|
|
51
|
+
raise Invalid, "record is not valid #{text.encoding}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# RFC 8460 §3 — unknown fields ignored; multiple rua fields allowed
|
|
56
|
+
def uris_in(fields)
|
|
57
|
+
rua_fields = fields.select { |field| field.start_with?(RUA_FIELD) }
|
|
58
|
+
|
|
59
|
+
rua_fields.flat_map { |field| destinations_in(field.delete_prefix(RUA_FIELD)) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def destinations_in(value)
|
|
63
|
+
value.split(URI_DELIM, -1).map(&:strip)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :rua, :ignored, :domain
|
|
68
|
+
|
|
69
|
+
def initialize(rua:, domain: nil)
|
|
70
|
+
@rua, @ignored = validated_destinations(rua)
|
|
71
|
+
@domain = domain && Policy.normalize(domain)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def mailto
|
|
75
|
+
rua.grep(MAILTO)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def https
|
|
79
|
+
rua.grep(HTTPS)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_s
|
|
83
|
+
"v=#{REPORT_VERSION}; #{RUA_FIELD}#{rua.join(", ")}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inspect
|
|
87
|
+
"#<#{self.class.name} rua: #{rua.inspect}, ignored: #{ignored.inspect}>"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
def validated_destinations(uris)
|
|
92
|
+
usable, unusable = Array(uris).partition { |uri| supported?(uri) }
|
|
93
|
+
|
|
94
|
+
if usable.empty?
|
|
95
|
+
raise Invalid, "rua must name at least one mailto: or https: destination"
|
|
96
|
+
else
|
|
97
|
+
[ usable, unusable ]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def supported?(uri)
|
|
102
|
+
SCHEMES.any? { |scheme| uri.to_s.match?(scheme) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module MtaSts
|
|
2
|
+
# status = what this lookup learned; policy = what to honour (§5.1)
|
|
3
|
+
class Result
|
|
4
|
+
attr_reader :status, :policy, :comment
|
|
5
|
+
|
|
6
|
+
def initialize(status:, policy: nil, comment: nil)
|
|
7
|
+
@status, @policy, @comment = status, policy, comment
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def found?
|
|
11
|
+
status == "found"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# "no TXT this time" — not "no policy applies" (see Policy#none?)
|
|
15
|
+
def no_record?
|
|
16
|
+
status == "none"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def temperror?
|
|
20
|
+
status == "temperror"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def permerror?
|
|
24
|
+
status == "permerror"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def enforce?
|
|
28
|
+
policy&.enforce? || false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def testing?
|
|
32
|
+
policy&.testing? || false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def allows?(host)
|
|
36
|
+
if policy
|
|
37
|
+
policy.allows?(host)
|
|
38
|
+
else
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inspect
|
|
44
|
+
"#<#{self.class.name} status: #{status.inspect}, policy: #{policy.inspect}, comment: #{comment.inspect}>"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/mta_sts.rb
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
require "mailresolver"
|
|
2
|
+
require "mta_sts/version"
|
|
3
|
+
require "mta_sts/fetcher"
|
|
4
|
+
require "mta_sts/policy"
|
|
5
|
+
require "mta_sts/result"
|
|
6
|
+
require "mta_sts/report"
|
|
7
|
+
require "mta_sts/report/result"
|
|
8
|
+
|
|
9
|
+
module MtaSts
|
|
10
|
+
DISCOVERY_PREFIX = "_mta-sts."
|
|
11
|
+
POLICY_HOST_PREFIX = "mta-sts."
|
|
12
|
+
REPORTING_PREFIX = "_smtp._tls." # RFC 8460 §3
|
|
13
|
+
|
|
14
|
+
# RFC 8461 §3.1's ABNF would admit `v=STSv1 ; id=...`, but its prose discards
|
|
15
|
+
# anything not literally starting "v=STSv1;" — the prose is what everyone implements.
|
|
16
|
+
STS_RECORD = /\Av=STSv1;/
|
|
17
|
+
FIELD_DELIM = ";"
|
|
18
|
+
ID_FIELD = /\Aid=(.*)\z/
|
|
19
|
+
# RFC 8461 §3.1 ABNF: sts-id = 1*32(ALPHA / DIGIT)
|
|
20
|
+
STS_ID = /\A[a-zA-Z0-9]{1,32}\z/
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# `known:` is required so omitting it is a decision, not an accident (§5.1).
|
|
24
|
+
def lookup(domain, known:, resolver: MailResolver::Resolver.new, fetcher: Fetcher.new, now: Time.now)
|
|
25
|
+
domain = Policy.normalize(recipient_domain(domain))
|
|
26
|
+
held = usable(known, domain, now)
|
|
27
|
+
id = discover(domain, resolver)
|
|
28
|
+
|
|
29
|
+
case
|
|
30
|
+
when id == :unreachable then unreachable(domain, held)
|
|
31
|
+
when id == :absent then absent(domain, held)
|
|
32
|
+
when held && held.id == id then renewed(domain, held, now)
|
|
33
|
+
else fetched(domain, id, held, fetcher, now)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# RFC 8460 §3
|
|
38
|
+
def reporting(domain, resolver: MailResolver::Resolver.new)
|
|
39
|
+
domain = Policy.normalize(recipient_domain(domain))
|
|
40
|
+
|
|
41
|
+
case record = sole_record("#{REPORTING_PREFIX}#{domain}", matching: Report::REPORT_RECORD, resolver: resolver)
|
|
42
|
+
when :unreachable then unanswered(domain)
|
|
43
|
+
when :absent then unpublished(domain)
|
|
44
|
+
else reported(domain, record)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
def recipient_domain(domain)
|
|
50
|
+
domain.to_s.split("@").last.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def usable(policy, domain, now)
|
|
54
|
+
policy if policy && !policy.expired?(now) && belongs_to?(policy, domain)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def belongs_to?(policy, domain)
|
|
58
|
+
policy.domain.nil? || policy.domain == domain
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# RFC 8461 §3.1 — not exactly one usable record means the domain does not implement MTA-STS
|
|
62
|
+
def discover(domain, resolver)
|
|
63
|
+
case record = sole_record("#{DISCOVERY_PREFIX}#{domain}", matching: STS_RECORD, resolver: resolver)
|
|
64
|
+
when :absent, :unreachable then record
|
|
65
|
+
else policy_id(record) || :absent
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sole_record(name, matching:, resolver:)
|
|
70
|
+
records = resolver.txt(name).grep(matching)
|
|
71
|
+
|
|
72
|
+
if records.one?
|
|
73
|
+
records.first
|
|
74
|
+
else
|
|
75
|
+
:absent
|
|
76
|
+
end
|
|
77
|
+
rescue MailResolver::NotFound
|
|
78
|
+
:absent
|
|
79
|
+
rescue MailResolver::Error
|
|
80
|
+
:unreachable
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def policy_id(record)
|
|
84
|
+
ids = record.split(FIELD_DELIM).map(&:strip).filter_map { |field| field[ID_FIELD, 1] }
|
|
85
|
+
|
|
86
|
+
ids.first if ids.one? && ids.first.match?(STS_ID)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def unreachable(domain, held)
|
|
90
|
+
outcome(held, status: "temperror",
|
|
91
|
+
comment: "DNS didn't answer for #{DISCOVERY_PREFIX}#{domain}")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def absent(domain, held)
|
|
95
|
+
outcome(held, status: "none",
|
|
96
|
+
comment: "#{domain} publishes no MTA-STS policy")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# RFC 8461 §5.1 — matching id renews without refetching
|
|
100
|
+
def renewed(domain, held, now)
|
|
101
|
+
policy = Policy.new(mode: held.mode, mx: held.mx, max_age: held.max_age,
|
|
102
|
+
id: held.id, domain: domain, fetched_at: now)
|
|
103
|
+
|
|
104
|
+
found(policy, "policy id #{policy.id} is unchanged; renewed until #{policy.expires_at} without refetching")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fetched(domain, id, held, fetcher, now)
|
|
108
|
+
policy = Policy.parse(fetcher.fetch("#{POLICY_HOST_PREFIX}#{domain}"), id: id, domain: domain, fetched_at: now)
|
|
109
|
+
|
|
110
|
+
found(policy, "fetched policy id #{id} from #{POLICY_HOST_PREFIX}#{domain}")
|
|
111
|
+
rescue Fetcher::Error => error
|
|
112
|
+
outcome(held, status: "temperror",
|
|
113
|
+
comment: "couldn't fetch the policy for #{domain}: #{error.message}")
|
|
114
|
+
rescue Policy::Invalid => error
|
|
115
|
+
outcome(held, status: "permerror",
|
|
116
|
+
comment: "#{POLICY_HOST_PREFIX}#{domain} served a policy that isn't valid: #{error.message}")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# §5.1 — only an explicit successful answer may weaken a held policy; status still reports the failure
|
|
120
|
+
def outcome(held, status:, comment:)
|
|
121
|
+
if held
|
|
122
|
+
Result.new(status: status, policy: held,
|
|
123
|
+
comment: "#{comment}; the policy already held stands until #{held.expires_at}")
|
|
124
|
+
else
|
|
125
|
+
Result.new(status: status, comment: comment)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def found(policy, comment)
|
|
130
|
+
Result.new(status: "found", policy: policy, comment: comment)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def unanswered(domain)
|
|
134
|
+
Report::Result.new(status: "temperror",
|
|
135
|
+
comment: "DNS didn't answer for #{REPORTING_PREFIX}#{domain}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def unpublished(domain)
|
|
139
|
+
Report::Result.new(status: "none",
|
|
140
|
+
comment: "#{domain} publishes no usable TLSRPT record at #{REPORTING_PREFIX}#{domain}")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def reported(domain, record)
|
|
144
|
+
report = Report.parse(record, domain: domain)
|
|
145
|
+
|
|
146
|
+
Report::Result.new(status: "found", report: report,
|
|
147
|
+
comment: "#{REPORTING_PREFIX}#{domain} asks for TLS reports at #{report.rua.join(", ")}")
|
|
148
|
+
rescue Report::Invalid => error
|
|
149
|
+
Report::Result.new(status: "permerror",
|
|
150
|
+
comment: "#{REPORTING_PREFIX}#{domain} isn't a usable TLSRPT record: #{error.message}")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/mta_sts.gemspec
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
|
2
|
+
require_relative "lib/mta_sts/version"
|
|
3
|
+
|
|
4
|
+
Gem::Specification.new do |spec|
|
|
5
|
+
spec.name = "mta_sts"
|
|
6
|
+
spec.version = MtaSts::VERSION
|
|
7
|
+
spec.platform = Gem::Platform::RUBY
|
|
8
|
+
spec.required_ruby_version = ">= 3.4"
|
|
9
|
+
spec.authors = [ "Simon Lev" ]
|
|
10
|
+
|
|
11
|
+
spec.summary = "SMTP MTA Strict Transport Security for Ruby."
|
|
12
|
+
spec.description = "SMTP MTA Strict Transport Security for Ruby. Look up a recipient domain's MTA-STS policy before delivering a message, to know whether TLS must be enforced and which servers are legitimate."
|
|
13
|
+
|
|
14
|
+
spec.homepage = "https://github.com/mailpiece/mta_sts"
|
|
15
|
+
spec.license = "MIT"
|
|
16
|
+
|
|
17
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
19
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
20
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
21
|
+
|
|
22
|
+
spec.files = Dir[
|
|
23
|
+
"lib/**/*.rb",
|
|
24
|
+
"CHANGELOG.md",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"mta_sts.gemspec"
|
|
28
|
+
]
|
|
29
|
+
spec.require_paths = [ "lib" ]
|
|
30
|
+
|
|
31
|
+
spec.add_dependency "mailresolver"
|
|
32
|
+
spec.add_dependency "simpleidn", "~> 0.2"
|
|
33
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mta_sts
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.6.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Simon Lev
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: mailresolver
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: simpleidn
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.2'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.2'
|
|
40
|
+
description: SMTP MTA Strict Transport Security for Ruby. Look up a recipient domain's
|
|
41
|
+
MTA-STS policy before delivering a message, to know whether TLS must be enforced
|
|
42
|
+
and which servers are legitimate.
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- CHANGELOG.md
|
|
48
|
+
- LICENSE
|
|
49
|
+
- README.md
|
|
50
|
+
- lib/mta_sts.rb
|
|
51
|
+
- lib/mta_sts/fetcher.rb
|
|
52
|
+
- lib/mta_sts/policy.rb
|
|
53
|
+
- lib/mta_sts/report.rb
|
|
54
|
+
- lib/mta_sts/report/result.rb
|
|
55
|
+
- lib/mta_sts/result.rb
|
|
56
|
+
- lib/mta_sts/version.rb
|
|
57
|
+
- mta_sts.gemspec
|
|
58
|
+
homepage: https://github.com/mailpiece/mta_sts
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata:
|
|
62
|
+
source_code_uri: https://github.com/mailpiece/mta_sts
|
|
63
|
+
changelog_uri: https://github.com/mailpiece/mta_sts/blob/main/CHANGELOG.md
|
|
64
|
+
bug_tracker_uri: https://github.com/mailpiece/mta_sts/issues
|
|
65
|
+
rubygems_mfa_required: 'true'
|
|
66
|
+
rdoc_options: []
|
|
67
|
+
require_paths:
|
|
68
|
+
- lib
|
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '3.4'
|
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '0'
|
|
79
|
+
requirements: []
|
|
80
|
+
rubygems_version: 4.0.10
|
|
81
|
+
specification_version: 4
|
|
82
|
+
summary: SMTP MTA Strict Transport Security for Ruby.
|
|
83
|
+
test_files: []
|