mta_sts 0.1.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 +12 -0
- data/LICENSE +21 -0
- data/README.md +274 -0
- data/exe/mta-sts +5 -0
- data/lib/mta_sts/cli.rb +113 -0
- data/lib/mta_sts/fetcher.rb +123 -0
- data/lib/mta_sts/policy.rb +184 -0
- data/lib/mta_sts/report/result.rb +35 -0
- data/lib/mta_sts/report.rb +104 -0
- data/lib/mta_sts/resolver.rb +74 -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 +37 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0de9b2cdb6adccb9fb59489860cebd39ea5ccd9b31bafad218d05334bbbd79c5
|
|
4
|
+
data.tar.gz: 4d4390e9071edf2f2ca6a1decbd32a8a862f24572d042464241cc11abb226594
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 836678183d43c161fdff348dc1b6c466a485f071bfc2e6606b805631c89ddd6848dbe8f1e511219f6861a7e40df83c3197c1cacf70cca0f89062f9e4aa4cd12a
|
|
7
|
+
data.tar.gz: aeeadc31d4ccf52cbd5b54267050ea1fcb01b891e49a9295f7dbd19beff6ea64f064e6299ae3d11208c68c2ea30552b0e6be1eec05bb1b639fe7e9d49b938c6d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- MTA-STS lookup (RFC 8461): discover, fetch, parse, MX match
|
|
8
|
+
- `known:` refresh (ยง5.1) โ required keyword; no in-library cache
|
|
9
|
+
- Dual-axis `status` / `policy`; `"none"` is `no_record?`
|
|
10
|
+
- TLSRPT discovery (RFC 8460) โ `rua` only; no report submission
|
|
11
|
+
- dnsruby resolver (SERVFAIL โ absence); DNSSEC and answer cache off
|
|
12
|
+
- CLI with exit 0 / 1 / 2 for answered / refused / unknown
|
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,274 @@
|
|
|
1
|
+
# mta_sts
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Add this line to your application's Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "mta_sts"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
And run:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
bundle install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Look up your first policy with:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "mta_sts"
|
|
27
|
+
|
|
28
|
+
result = MtaSts.lookup("gmail.com", known: nil)
|
|
29
|
+
result.enforce? # => true
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## How It Works
|
|
33
|
+
|
|
34
|
+
### Looking Up a Policy
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
result = MtaSts.lookup(domain, known:, resolver:, fetcher:, now:)
|
|
38
|
+
# => #<MtaSts::Result status:, comment:, policy:>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- **domain** - the recipient domain, the part after the `@`
|
|
42
|
+
- **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
|
|
43
|
+
- **resolver** - defaults to `MtaSts::Resolver.new`
|
|
44
|
+
- **fetcher** - defaults to `MtaSts::Fetcher.new`
|
|
45
|
+
- **now** - a `Time`, for expiry; defaults to `Time.now`
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
result = MtaSts.lookup("gmail.com", known: store["gmail.com"])
|
|
49
|
+
store["gmail.com"] = result.policy if result.policy
|
|
50
|
+
|
|
51
|
+
if result.enforce? && !result.allows?(mx_host)
|
|
52
|
+
# the policy names which servers are legitimate, and this is not one of them
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Hand back what you kept, keep what comes back - both halves matter.
|
|
57
|
+
|
|
58
|
+
### Results
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
result.status # => "found"
|
|
62
|
+
result.policy.mode # => :enforce
|
|
63
|
+
result.policy.mx # => ["gmail-smtp-in.l.google.com", "*.gmail-smtp-in.l.google.com"]
|
|
64
|
+
result.policy.max_age # => 86400
|
|
65
|
+
result.policy.id # => "20190429T010101"
|
|
66
|
+
result.policy.domain # => "gmail.com"
|
|
67
|
+
result.policy.expires_at # => 2026-07-29 14:03:11 UTC
|
|
68
|
+
|
|
69
|
+
result.enforce? # => true
|
|
70
|
+
result.allows?("alt1.gmail-smtp-in.l.google.com") # => true
|
|
71
|
+
result.allows?("mx.attacker.example") # => false
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
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?`).
|
|
75
|
+
|
|
76
|
+
### Status and Policy Are Separate Axes
|
|
77
|
+
|
|
78
|
+
`status` describes what this lookup learned. `policy` describes what you must honor. Nothing raises - a result always comes back with a `comment`:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
result.status # => "temperror"
|
|
82
|
+
result.comment # => "DNS didn't answer for _mta-sts.example.com"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Four statuses, each with a predicate:
|
|
86
|
+
|
|
87
|
+
- `found` / `found?` - a policy was fetched, or renewed against an unchanged `id`
|
|
88
|
+
- `none` / `no_record?` - no `_mta-sts` record seen
|
|
89
|
+
- `temperror` / `temperror?` - DNS or the policy host didn't answer
|
|
90
|
+
- `permerror` / `permerror?` - the record or policy file is broken
|
|
91
|
+
|
|
92
|
+
`policy` is present whenever there's one to honor: fresh, renewed, or the `known` one you passed in that hasn't expired.
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
| what happened | status | policy | `enforce?` |
|
|
96
|
+
| ----------------------------------------- | ----------- | ------- | ---------- |
|
|
97
|
+
| fresh fetch, `mode: enforce` | `found` | present | true |
|
|
98
|
+
| DNS timeout, held an enforcing policy | `temperror` | held | true |
|
|
99
|
+
| TXT record gone, held an enforcing policy | `none` | held | **true** |
|
|
100
|
+
| never had one, no TXT record | `none` | nil | false |
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
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
|
+
|
|
105
|
+
Use `enforce?` and `allows?` for the connection decision, not `status`:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
if result.enforce? && !result.allows?(mx)
|
|
109
|
+
defer_or_fail
|
|
110
|
+
elsif result.temperror? && !result.policy
|
|
111
|
+
defer # we don't know; don't downgrade
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`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.
|
|
116
|
+
|
|
117
|
+
### Holding On to a Policy
|
|
118
|
+
|
|
119
|
+
There's no cache in this library. `lookup` takes the policy you kept last time and hands one back.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
result = MtaSts.lookup("example.com", known: store["example.com"])
|
|
123
|
+
store["example.com"] = result.policy if result.policy
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Skip that round trip and ยง5.1 doesn't apply. Three rules govern what comes back:
|
|
127
|
+
|
|
128
|
+
- A matching `id` renews the expiry without refetching
|
|
129
|
+
- A temporary failure keeps a policy that hasn't expired
|
|
130
|
+
- A fetched `mode: none` replaces it immediately
|
|
131
|
+
|
|
132
|
+
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:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
row = { body: policy.to_s, id: policy.id, domain: policy.domain, fetched_at: policy.fetched_at }
|
|
136
|
+
MtaSts::Policy.parse(row[:body], id: row[:id], domain: row[:domain], fetched_at: row[:fetched_at])
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### TLSRPT Record Discovery
|
|
140
|
+
|
|
141
|
+
Where a domain wants TLS failure reports sent (RFC 8460). One TXT lookup at `_smtp._tls.<domain>` โ not report generation or submission.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
result = MtaSts.reporting("gmail.com")
|
|
145
|
+
# => #<MtaSts::Report::Result status:, comment:, report:>
|
|
146
|
+
|
|
147
|
+
result.status # => "found"
|
|
148
|
+
result.rua # => ["mailto:tlsrpt@smtp.gmail.com"]
|
|
149
|
+
result.report.mailto
|
|
150
|
+
result.report.https
|
|
151
|
+
result.report.ignored # destinations that were named but are neither mailto: nor https:
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
### Custom Resolver & Fetcher
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
MtaSts.lookup("example.com", known: nil,
|
|
160
|
+
resolver: MyCachingResolver.new, # DNS, for discovery
|
|
161
|
+
fetcher: MyPolicyFetcher.new) # the HTTPS GET
|
|
162
|
+
```
|
|
163
|
+
|
|
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.
|
|
165
|
+
- **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
|
+
|
|
167
|
+
### Publishing a Policy
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
policy = MtaSts::Policy.new(mode: :enforce, mx: ["mx.example.com"], max_age: 604800)
|
|
171
|
+
puts policy.to_s
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
version: STSv1
|
|
176
|
+
mode: enforce
|
|
177
|
+
mx: mx.example.com
|
|
178
|
+
max_age: 604800
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
And read one back:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
MtaSts::Policy.parse(File.read("mta-sts.txt"))
|
|
185
|
+
# => #<MtaSts::Policy mode: :enforce, mx: ["mx.example.com"], max_age: 604800>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Command Line
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
mta-sts gmail.com alt1.gmail-smtp-in.l.google.com
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
domain: gmail.com
|
|
196
|
+
status: found
|
|
197
|
+
comment: fetched policy id 20190429T010101 from mta-sts.gmail.com
|
|
198
|
+
mode: enforce
|
|
199
|
+
id: 20190429T010101
|
|
200
|
+
max_age: 86400
|
|
201
|
+
expires: 2026-07-29 15:51:41 UTC
|
|
202
|
+
mx:
|
|
203
|
+
smtp.google.com
|
|
204
|
+
gmail-smtp-in.l.google.com
|
|
205
|
+
*.gmail-smtp-in.l.google.com
|
|
206
|
+
rua:
|
|
207
|
+
mailto:tlsrpt@smtp.gmail.com
|
|
208
|
+
|
|
209
|
+
alt1.gmail-smtp-in.l.google.com: accepted
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Exit codes answer the MTA-STS question only. TLSRPT is printed for diagnostics and never changes the exit:
|
|
213
|
+
|
|
214
|
+
- `0` - answered, a policy vouches for the MX, or the domain publishes none
|
|
215
|
+
- `1` - refused, a policy applies and does not name it
|
|
216
|
+
- `2` - unknown, DNS or the policy host didn't answer, or what it served was broken
|
|
217
|
+
- `64` - usage
|
|
218
|
+
|
|
219
|
+
## What It Is Strict About
|
|
220
|
+
|
|
221
|
+
- HTTP 3xx are never followed
|
|
222
|
+
- HTTP caching (RFC 7234) is never used - freshness comes from the TXT `id` and `max_age` only
|
|
223
|
+
- Only `200` is a policy
|
|
224
|
+
- The certificate is verified against `mta-sts.<domain>`, the policy host name
|
|
225
|
+
- The `_mta-sts` discovery record must match the ABNF (`v=STSv1;` case-sensitive, a single well-formed `id`)
|
|
226
|
+
- Fetch body size and total time are capped
|
|
227
|
+
|
|
228
|
+
## What It Is Not That Strict
|
|
229
|
+
|
|
230
|
+
- `Content-Type: text/plain` is checked but not enforced - ยง3.2 says SHOULD, not MUST, and real policies in the wild omit it
|
|
231
|
+
- Line terminators may be bare `LF` as well as `CRLF`
|
|
232
|
+
|
|
233
|
+
## What It Does Not Check
|
|
234
|
+
|
|
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.
|
|
240
|
+
|
|
241
|
+
## Testing
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
bundle install
|
|
245
|
+
rake
|
|
246
|
+
```
|
|
247
|
+
|
|
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
|
+
## History
|
|
251
|
+
|
|
252
|
+
View the [changelog](https://github.com/mailpiece/mta_sts/blob/main/CHANGELOG.md)
|
|
253
|
+
|
|
254
|
+
## Contributing
|
|
255
|
+
|
|
256
|
+
Everyone is encouraged to help improve this project. Here are a few ways you can help:
|
|
257
|
+
|
|
258
|
+
- [Report bugs](https://github.com/mailpiece/mta_sts/issues)
|
|
259
|
+
- Fix bugs and [submit pull requests](https://github.com/mailpiece/mta_sts/pulls)
|
|
260
|
+
- Write, clarify, or fix documentation
|
|
261
|
+
- Suggest or add new features
|
|
262
|
+
|
|
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
|
+
## License
|
|
273
|
+
|
|
274
|
+
MIT. See [LICENSE](LICENSE).
|
data/exe/mta-sts
ADDED
data/lib/mta_sts/cli.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module MtaSts
|
|
2
|
+
# Exit: 0 answered, 1 refused, 2 unknown, 64 usage
|
|
3
|
+
class Cli
|
|
4
|
+
ANSWERED = 0
|
|
5
|
+
REFUSED = 1
|
|
6
|
+
UNKNOWN = 2
|
|
7
|
+
USAGE_ERROR = 64
|
|
8
|
+
|
|
9
|
+
USAGE = <<~TEXT.freeze
|
|
10
|
+
usage: mta-sts <domain> [mx-hostname]
|
|
11
|
+
|
|
12
|
+
exit: #{ANSWERED} answered, #{REFUSED} refused, #{UNKNOWN} unknown, #{USAGE_ERROR} usage
|
|
13
|
+
TEXT
|
|
14
|
+
|
|
15
|
+
def initialize(argv, resolver: Resolver.new, fetcher: Fetcher.new, out: $stdout, err: $stderr)
|
|
16
|
+
@domain, @mx_host, @extra = argv
|
|
17
|
+
@resolver, @fetcher = resolver, fetcher
|
|
18
|
+
@out, @err = out, err
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
if usable_arguments?
|
|
23
|
+
answer
|
|
24
|
+
else
|
|
25
|
+
@err.puts USAGE
|
|
26
|
+
USAGE_ERROR
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
def usable_arguments?
|
|
32
|
+
!(@domain.nil? || @domain.empty? || @domain.start_with?("-") || @extra)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def answer
|
|
36
|
+
result = MtaSts.lookup(@domain, known: nil, resolver: @resolver, fetcher: @fetcher)
|
|
37
|
+
|
|
38
|
+
describe(result)
|
|
39
|
+
describe_reporting
|
|
40
|
+
|
|
41
|
+
if @mx_host
|
|
42
|
+
judge(result)
|
|
43
|
+
elsif answerable?(result)
|
|
44
|
+
ANSWERED
|
|
45
|
+
else
|
|
46
|
+
UNKNOWN
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def describe(result)
|
|
51
|
+
@out.puts "domain: #{@domain}"
|
|
52
|
+
@out.puts "status: #{result.status}"
|
|
53
|
+
@out.puts "comment: #{result.comment}" if result.comment
|
|
54
|
+
|
|
55
|
+
if policy = result.policy
|
|
56
|
+
@out.puts "mode: #{policy.mode}"
|
|
57
|
+
@out.puts "id: #{policy.id}"
|
|
58
|
+
@out.puts "max_age: #{policy.max_age}"
|
|
59
|
+
@out.puts "expires: #{policy.expires_at.utc}"
|
|
60
|
+
@out.puts "mx:"
|
|
61
|
+
policy.mx.each { |pattern| @out.puts " #{pattern}" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if result.no_record? && result.policy.nil?
|
|
65
|
+
@out.puts "note: holds no earlier policy, so this is only what DNS says now"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def describe_reporting
|
|
70
|
+
result = MtaSts.reporting(@domain, resolver: @resolver)
|
|
71
|
+
|
|
72
|
+
if result.found?
|
|
73
|
+
describe_destinations(result.report)
|
|
74
|
+
else
|
|
75
|
+
@out.puts "rua: #{result.comment}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def describe_destinations(report)
|
|
80
|
+
@out.puts "rua:"
|
|
81
|
+
report.rua.each { |destination| @out.puts " #{destination}" }
|
|
82
|
+
|
|
83
|
+
describe_ignored(report)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def describe_ignored(report)
|
|
87
|
+
if report.ignored.any?
|
|
88
|
+
@out.puts "ignored: named in rua but neither mailto: nor https:, so no report goes there"
|
|
89
|
+
report.ignored.each { |destination| @out.puts " #{destination}" }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def answerable?(result)
|
|
94
|
+
!result.policy.nil? || result.no_record?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def judge(result)
|
|
98
|
+
@out.puts
|
|
99
|
+
|
|
100
|
+
case
|
|
101
|
+
when !answerable?(result)
|
|
102
|
+
@out.puts "#{@mx_host}: unknown โ no policy could be read, so this can't be answered"
|
|
103
|
+
UNKNOWN
|
|
104
|
+
when result.allows?(@mx_host)
|
|
105
|
+
@out.puts "#{@mx_host}: accepted"
|
|
106
|
+
ANSWERED
|
|
107
|
+
else
|
|
108
|
+
@out.puts "#{@mx_host}: refused โ this policy does not name it"
|
|
109
|
+
REFUSED
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "mta_sts/version"
|
|
5
|
+
|
|
6
|
+
module MtaSts
|
|
7
|
+
# HTTPS GET of mta-sts.<domain>/.well-known/mta-sts.txt (RFC 8461 ยง3.3)
|
|
8
|
+
class Fetcher
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
class HttpError < Error; end
|
|
11
|
+
class CertificateError < Error; end
|
|
12
|
+
class Timeout < Error; end
|
|
13
|
+
|
|
14
|
+
WELL_KNOWN_PATH = "/.well-known/mta-sts.txt"
|
|
15
|
+
PORT = 443
|
|
16
|
+
OPEN_TIMEOUT = 10
|
|
17
|
+
READ_TIMEOUT = 10
|
|
18
|
+
# read_timeout renews per read; this one does not โ stops a slow-drip body
|
|
19
|
+
TOTAL_TIMEOUT = 30
|
|
20
|
+
MAX_BODY_BYTES = 64 * 1024
|
|
21
|
+
USER_AGENT = "mta_sts/#{VERSION} (+https://github.com/mailpiece/mta_sts)"
|
|
22
|
+
|
|
23
|
+
# RFC 8461 ยง3.2 SHOULD text/plain โ reported, not enforced
|
|
24
|
+
class Body < String
|
|
25
|
+
attr_reader :content_type
|
|
26
|
+
|
|
27
|
+
def initialize(body, content_type: nil)
|
|
28
|
+
super(body)
|
|
29
|
+
@content_type = content_type
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def text_plain?
|
|
33
|
+
content_type.to_s.split(";").first.to_s.strip.casecmp?("text/plain")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT,
|
|
38
|
+
total_timeout: TOTAL_TIMEOUT, max_body_bytes: MAX_BODY_BYTES)
|
|
39
|
+
@open_timeout = open_timeout
|
|
40
|
+
@read_timeout = read_timeout
|
|
41
|
+
@total_timeout = total_timeout
|
|
42
|
+
@max_body_bytes = max_body_bytes
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch(host)
|
|
46
|
+
get(normalize(host))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
def normalize(host)
|
|
51
|
+
host.to_s.downcase.chomp(".")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get(host)
|
|
55
|
+
deadline = monotonic_now + @total_timeout
|
|
56
|
+
|
|
57
|
+
with_response(host) do |response|
|
|
58
|
+
if response.is_a?(Net::HTTPOK)
|
|
59
|
+
body_from(response, host, deadline)
|
|
60
|
+
else
|
|
61
|
+
# RFC 8461 ยง3.3 MUST NOT follow redirects
|
|
62
|
+
raise HttpError, "#{host} answered #{response.code}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def with_response(host)
|
|
68
|
+
result = nil
|
|
69
|
+
|
|
70
|
+
connection(host).start do |http|
|
|
71
|
+
http.request(policy_request) { |response| result = yield response }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
rescue OpenSSL::SSL::SSLError => error
|
|
76
|
+
raise CertificateError, "#{host} presented a certificate that didn't verify: #{error.message}"
|
|
77
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, ::Timeout::Error
|
|
78
|
+
raise Timeout, "timed out fetching the policy from #{host}"
|
|
79
|
+
rescue Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error => error
|
|
80
|
+
raise HttpError, "#{host} answered with malformed HTTP: #{error.message}"
|
|
81
|
+
rescue SystemCallError, SocketError, IOError, EOFError => error
|
|
82
|
+
raise Error, "couldn't reach #{host}: #{error.message}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def connection(host)
|
|
86
|
+
Net::HTTP.new(host, PORT).tap do |http|
|
|
87
|
+
http.use_ssl = true
|
|
88
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
89
|
+
http.verify_hostname = true
|
|
90
|
+
http.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
91
|
+
http.open_timeout = @open_timeout
|
|
92
|
+
http.read_timeout = @read_timeout
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# RFC 8461 ยง3.3 โ no HTTP caching; freshness is TXT id + max_age
|
|
97
|
+
def policy_request
|
|
98
|
+
Net::HTTP::Get.new(WELL_KNOWN_PATH, "accept" => "text/plain", "user-agent" => USER_AGENT)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def body_from(response, host, deadline)
|
|
102
|
+
body = +""
|
|
103
|
+
|
|
104
|
+
response.read_body do |chunk|
|
|
105
|
+
body << chunk
|
|
106
|
+
|
|
107
|
+
if body.bytesize > @max_body_bytes
|
|
108
|
+
raise HttpError, "#{host} sent more than #{@max_body_bytes} bytes of policy"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if monotonic_now > deadline
|
|
112
|
+
raise Timeout, "#{host} took longer than #{@total_timeout} seconds to send the policy"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Body.new(body.force_encoding(Encoding::UTF_8), content_type: response["content-type"])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def monotonic_now
|
|
120
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
def scrubbed(name)
|
|
55
|
+
name = name.to_s
|
|
56
|
+
name.valid_encoding? ? name : name.scrub
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ascii(name)
|
|
60
|
+
name.ascii_only? ? name : SimpleIDN.to_ascii(name)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
name
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def single(fields, name)
|
|
66
|
+
values = fields.fetch(name, [])
|
|
67
|
+
|
|
68
|
+
if values.size > 1
|
|
69
|
+
raise Invalid, "#{name} appears #{values.size} times; it may appear once"
|
|
70
|
+
else
|
|
71
|
+
values.first
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_version(version)
|
|
76
|
+
unless version&.casecmp?(POLICY_VERSION)
|
|
77
|
+
raise Invalid, "version must be #{POLICY_VERSION}, not #{version.inspect}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
attr_reader :mode, :mx, :max_age, :id, :domain, :fetched_at
|
|
83
|
+
|
|
84
|
+
def initialize(mode:, max_age:, mx: [], id: nil, domain: nil, fetched_at: Time.now)
|
|
85
|
+
@mode = validated_mode(mode)
|
|
86
|
+
@mx = validated_mx(mx)
|
|
87
|
+
@max_age = validated_max_age(max_age)
|
|
88
|
+
@domain = domain && self.class.normalize(domain)
|
|
89
|
+
@id, @fetched_at = id, fetched_at
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def none?
|
|
93
|
+
mode == :none
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def testing?
|
|
97
|
+
mode == :testing
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def enforce?
|
|
101
|
+
mode == :enforce
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def allows?(host)
|
|
105
|
+
if none?
|
|
106
|
+
true
|
|
107
|
+
else
|
|
108
|
+
host = self.class.normalize(host)
|
|
109
|
+
|
|
110
|
+
mx.any? { |pattern| matches?(pattern, host) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def expires_at
|
|
115
|
+
fetched_at + max_age
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def expired?(now = Time.now)
|
|
119
|
+
now >= expires_at
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def to_s
|
|
123
|
+
lines = [ "version: #{POLICY_VERSION}", "mode: #{mode}" ]
|
|
124
|
+
lines.concat mx.map { |pattern| "mx: #{pattern}" }
|
|
125
|
+
lines << "max_age: #{max_age}"
|
|
126
|
+
|
|
127
|
+
lines.map { |line| "#{line}\r\n" }.join
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def inspect
|
|
131
|
+
"#<#{self.class.name} mode: #{mode.inspect}, mx: #{mx.inspect}, max_age: #{max_age.inspect}>"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
def validated_mode(mode)
|
|
136
|
+
if known = MODES.find { |candidate| candidate.to_s == mode.to_s.strip.downcase }
|
|
137
|
+
known
|
|
138
|
+
else
|
|
139
|
+
raise Invalid, "mode must be one of #{MODES.join(", ")}, not #{mode.inspect}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# RFC 8461 ยง3.2 โ at least one mx unless mode is none
|
|
144
|
+
def validated_mx(mx)
|
|
145
|
+
patterns = Array(mx).map { |pattern| validated_pattern(pattern) }.uniq
|
|
146
|
+
|
|
147
|
+
if patterns.empty? && !none?
|
|
148
|
+
raise Invalid, "mx must name at least one host unless mode is none"
|
|
149
|
+
else
|
|
150
|
+
patterns
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def validated_pattern(pattern)
|
|
155
|
+
normalized = self.class.normalize(pattern)
|
|
156
|
+
|
|
157
|
+
if normalized.delete("*.").empty?
|
|
158
|
+
raise Invalid, "mx must name a host, not #{pattern.inspect}"
|
|
159
|
+
else
|
|
160
|
+
normalized
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validated_max_age(max_age)
|
|
165
|
+
# grammar is 1*DIGIT โ not Integer(), which accepts 0x10 / 1_0
|
|
166
|
+
if max_age.to_s.match?(DIGITS) && MAX_AGE.cover?(seconds = max_age.to_i)
|
|
167
|
+
seconds
|
|
168
|
+
else
|
|
169
|
+
raise Invalid, "max_age must be a whole number of seconds in #{MAX_AGE}, not #{max_age.inspect}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# RFC 8461 ยง4.1 โ "*." covers exactly one label
|
|
174
|
+
def matches?(pattern, host)
|
|
175
|
+
if pattern.start_with?(WILDCARD)
|
|
176
|
+
label, _, parent = host.partition(".")
|
|
177
|
+
|
|
178
|
+
!label.empty? && parent == pattern.delete_prefix(WILDCARD)
|
|
179
|
+
else
|
|
180
|
+
pattern == host
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
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,104 @@
|
|
|
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: tlsrpt-version = %s"v=TLSRPTv1", then 1*(field-delim tlsrpt-field)
|
|
10
|
+
REPORT_RECORD = /\Av=#{REPORT_VERSION};/
|
|
11
|
+
FIELD_DELIM = ";"
|
|
12
|
+
RUA_FIELD = "rua=" # RFC 8460 ยง3
|
|
13
|
+
URI_DELIM = ","
|
|
14
|
+
MAILTO = /\Amailto:/i # RFC 3986 ยง3.1 โ scheme is case-insensitive
|
|
15
|
+
HTTPS = /\Ahttps:/i
|
|
16
|
+
SCHEMES = [ MAILTO, HTTPS ].freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def parse(text, domain: nil)
|
|
20
|
+
new rua: uris_in(fields_in(text)), domain: domain
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
def fields_in(text)
|
|
25
|
+
fields = validated_record(text).split(FIELD_DELIM).drop(1).map(&:strip)
|
|
26
|
+
|
|
27
|
+
if fields.empty?
|
|
28
|
+
raise Invalid, "record must carry at least one field after v=#{REPORT_VERSION}"
|
|
29
|
+
else
|
|
30
|
+
fields
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validated_record(text)
|
|
35
|
+
text = validated_encoding(text)
|
|
36
|
+
|
|
37
|
+
if text.match?(REPORT_RECORD)
|
|
38
|
+
text
|
|
39
|
+
else
|
|
40
|
+
raise Invalid, "record must begin with v=#{REPORT_VERSION};, not #{text.inspect}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validated_encoding(text)
|
|
45
|
+
text = text.to_s
|
|
46
|
+
|
|
47
|
+
if text.valid_encoding?
|
|
48
|
+
text
|
|
49
|
+
else
|
|
50
|
+
raise Invalid, "record is not valid #{text.encoding}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# RFC 8460 ยง3 โ unknown fields ignored; multiple rua fields allowed
|
|
55
|
+
def uris_in(fields)
|
|
56
|
+
rua_fields = fields.select { |field| field.start_with?(RUA_FIELD) }
|
|
57
|
+
|
|
58
|
+
rua_fields.flat_map { |field| destinations_in(field.delete_prefix(RUA_FIELD)) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def destinations_in(value)
|
|
62
|
+
value.split(URI_DELIM, -1).map(&:strip)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :rua, :ignored, :domain
|
|
67
|
+
|
|
68
|
+
def initialize(rua:, domain: nil)
|
|
69
|
+
@rua, @ignored = validated_destinations(rua)
|
|
70
|
+
@domain = domain && Policy.normalize(domain)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def mailto
|
|
74
|
+
rua.grep(MAILTO)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def https
|
|
78
|
+
rua.grep(HTTPS)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_s
|
|
82
|
+
"v=#{REPORT_VERSION}; #{RUA_FIELD}#{rua.join(", ")}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def inspect
|
|
86
|
+
"#<#{self.class.name} rua: #{rua.inspect}, ignored: #{ignored.inspect}>"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
def validated_destinations(uris)
|
|
91
|
+
usable, unusable = Array(uris).partition { |uri| supported?(uri) }
|
|
92
|
+
|
|
93
|
+
if usable.empty?
|
|
94
|
+
raise Invalid, "rua must name at least one mailto: or https: destination"
|
|
95
|
+
else
|
|
96
|
+
[ usable, unusable ]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def supported?(uri)
|
|
101
|
+
SCHEMES.any? { |scheme| uri.to_s.match?(scheme) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require "dnsruby"
|
|
2
|
+
|
|
3
|
+
module MtaSts
|
|
4
|
+
class Resolver
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class NotFound < Error; end # NXDOMAIN / NODATA
|
|
7
|
+
class Timeout < Error; end
|
|
8
|
+
class ServerFailure < Error; end # SERVFAIL, REFUSED, transport failure
|
|
9
|
+
|
|
10
|
+
QUERY_TIMEOUT = 5
|
|
11
|
+
|
|
12
|
+
def initialize(timeout: QUERY_TIMEOUT)
|
|
13
|
+
@timeout = timeout
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def txt(name)
|
|
17
|
+
records(name).map { |record| record.strings.join }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
def records(name)
|
|
22
|
+
answer = resolver.query(name.to_s, "TXT").answer.grep(Dnsruby::RR::IN::TXT)
|
|
23
|
+
|
|
24
|
+
if answer.any?
|
|
25
|
+
answer
|
|
26
|
+
else
|
|
27
|
+
raise NotFound, "no TXT records for #{name}"
|
|
28
|
+
end
|
|
29
|
+
rescue Dnsruby::NXDomain
|
|
30
|
+
raise NotFound, "no such name #{name}"
|
|
31
|
+
rescue Dnsruby::ResolvTimeout
|
|
32
|
+
raise Timeout, "timed out resolving TXT #{name}"
|
|
33
|
+
# Fall through to ServerFailure, never NotFound โ "couldn't ask" must not become "no policy"
|
|
34
|
+
rescue Dnsruby::ResolvError, IOError, SocketError, SystemCallError => error
|
|
35
|
+
raise ServerFailure, "couldn't resolve TXT #{name}: #{reason(error)}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reason(error)
|
|
39
|
+
if error.is_a?(Dnsruby::ResolvError)
|
|
40
|
+
rcode(error)
|
|
41
|
+
else
|
|
42
|
+
without_backtrace(error.message)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rcode(error)
|
|
47
|
+
name = error.class.name&.split("::")&.last
|
|
48
|
+
|
|
49
|
+
case
|
|
50
|
+
when name.nil? then error.message
|
|
51
|
+
when error.message == error.class.name then name
|
|
52
|
+
else "#{name}, #{error.message}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def without_backtrace(message)
|
|
57
|
+
message.sub(/\s*\[".*\z/m, "")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# DNSSEC off: MTA-STS trusts the policy-host certificate, not DNS.
|
|
61
|
+
# Caching off: dnsruby's cache is process-global and would hide SERVFAIL behind a stale hit.
|
|
62
|
+
def resolver
|
|
63
|
+
@resolver ||= Dnsruby::Resolver.new(**config).tap do |dns|
|
|
64
|
+
dns.dnssec = false
|
|
65
|
+
dns.do_caching = false
|
|
66
|
+
dns.query_timeout = @timeout
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def config
|
|
71
|
+
{}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
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 "mta_sts/version"
|
|
2
|
+
require "mta_sts/resolver"
|
|
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
|
+
require "mta_sts/cli"
|
|
9
|
+
|
|
10
|
+
module MtaSts
|
|
11
|
+
DISCOVERY_PREFIX = "_mta-sts."
|
|
12
|
+
POLICY_HOST_PREFIX = "mta-sts."
|
|
13
|
+
REPORTING_PREFIX = "_smtp._tls." # RFC 8460 ยง3
|
|
14
|
+
|
|
15
|
+
# RFC 8461 ยง3.1 ABNF: sts-version = %s"v=STSv1", then sts-field-delim
|
|
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: 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: 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 Resolver::NotFound
|
|
78
|
+
:absent
|
|
79
|
+
rescue Resolver::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,37 @@
|
|
|
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.3.4"
|
|
9
|
+
spec.authors = [ "PostRider" ]
|
|
10
|
+
spec.email = [ "support@postrider.dev" ]
|
|
11
|
+
|
|
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?"
|
|
14
|
+
|
|
15
|
+
spec.homepage = "https://github.com/mailpiece/mta_sts"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
|
|
18
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
20
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
21
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
22
|
+
|
|
23
|
+
spec.files = Dir[
|
|
24
|
+
"lib/**/*.rb",
|
|
25
|
+
"exe/**/*",
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"mta_sts.gemspec"
|
|
30
|
+
]
|
|
31
|
+
spec.bindir = "exe"
|
|
32
|
+
spec.executables = [ "mta-sts" ]
|
|
33
|
+
spec.require_paths = [ "lib" ]
|
|
34
|
+
|
|
35
|
+
spec.add_dependency "dnsruby", "~> 1.74"
|
|
36
|
+
spec.add_dependency "simpleidn", "~> 0.2"
|
|
37
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mta_sts
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- PostRider
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: dnsruby
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.74'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.74'
|
|
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: 'Answers one question before delivering a message: must this connection
|
|
41
|
+
be encrypted, and to which servers?'
|
|
42
|
+
email:
|
|
43
|
+
- support@postrider.dev
|
|
44
|
+
executables:
|
|
45
|
+
- mta-sts
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- exe/mta-sts
|
|
53
|
+
- lib/mta_sts.rb
|
|
54
|
+
- lib/mta_sts/cli.rb
|
|
55
|
+
- lib/mta_sts/fetcher.rb
|
|
56
|
+
- lib/mta_sts/policy.rb
|
|
57
|
+
- lib/mta_sts/report.rb
|
|
58
|
+
- lib/mta_sts/report/result.rb
|
|
59
|
+
- lib/mta_sts/resolver.rb
|
|
60
|
+
- lib/mta_sts/result.rb
|
|
61
|
+
- lib/mta_sts/version.rb
|
|
62
|
+
- mta_sts.gemspec
|
|
63
|
+
homepage: https://github.com/mailpiece/mta_sts
|
|
64
|
+
licenses:
|
|
65
|
+
- MIT
|
|
66
|
+
metadata:
|
|
67
|
+
source_code_uri: https://github.com/mailpiece/mta_sts
|
|
68
|
+
changelog_uri: https://github.com/mailpiece/mta_sts/blob/main/CHANGELOG.md
|
|
69
|
+
bug_tracker_uri: https://github.com/mailpiece/mta_sts/issues
|
|
70
|
+
rubygems_mfa_required: 'true'
|
|
71
|
+
rdoc_options: []
|
|
72
|
+
require_paths:
|
|
73
|
+
- lib
|
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: 3.3.4
|
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
requirements: []
|
|
85
|
+
rubygems_version: 4.0.3
|
|
86
|
+
specification_version: 4
|
|
87
|
+
summary: MTA-STS policy discovery and enforcement for outbound SMTP in Ruby.
|
|
88
|
+
test_files: []
|