1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2025-12-18 14:47:03 +00:00

sts: Update to draft-ietf-uta-mta-sts-18

This patch updates the STS implementation from draft version 02 to 18.

The main changes are:

 - Policy is now in an ad-hoc format instead of JSON (😒).
 - Minor policy well-known URL change (now ends in ".txt").
 - Enforce HTTP media type == text/plain, as with the ad-hoc format this
   becomes much more important.
 - Simplify wildcard mx matching (same algorithm), extend test cases.
 - Valid modes are "enforce" (as before), "testing" (replaces "report"),
   and "none" (new).
This commit is contained in:
Alberto Bertogli
2018-05-20 14:45:38 +01:00
parent 23deaf1f88
commit 252ab5d3e3
3 changed files with 113 additions and 62 deletions

View File

@@ -192,7 +192,7 @@ retry:
if a.stsPolicy != nil && a.stsPolicy.Mode == sts.Enforce { if a.stsPolicy != nil && a.stsPolicy.Mode == sts.Enforce {
// The connection MUST be validated TLS. // The connection MUST be validated TLS.
// https://tools.ietf.org/html/draft-ietf-uta-mta-sts-03#section-4.2 // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-4.2
if secLevel != domaininfo.SecLevel_TLS_SECURE { if secLevel != domaininfo.SecLevel_TLS_SECURE {
stsSecurityResults.Add("fail", 1) stsSecurityResults.Add("fail", 1)
return a.tr.Errorf("invalid security level (%v) for STS policy", return a.tr.Errorf("invalid security level (%v) for STS policy",
@@ -317,7 +317,7 @@ func filterMXs(tr *trace.Trace, p *sts.Policy, mxs []string) []string {
// We don't want to return an empty set if the mode is not enforce. // We don't want to return an empty set if the mode is not enforce.
// This prevents failures for policies in reporting mode. // This prevents failures for policies in reporting mode.
// https://tools.ietf.org/html/draft-ietf-uta-mta-sts-03#section-5.2 // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-5.1
if len(filtered) == 0 && p.Mode != sts.Enforce { if len(filtered) == 0 && p.Mode != sts.Enforce {
filtered = mxs filtered = mxs
} }

View File

@@ -1,5 +1,5 @@
// Package sts implements the MTA-STS (Strict Transport Security), based on // Package sts implements the MTA-STS (Strict Transport Security), based on
// the current draft, https://tools.ietf.org/html/draft-ietf-uta-mta-sts-02. // the current draft, https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18.
// //
// This is an EXPERIMENTAL implementation for now. // This is an EXPERIMENTAL implementation for now.
// //
@@ -10,6 +10,8 @@
package sts package sts
import ( import (
"bufio"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -17,8 +19,10 @@ import (
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"mime"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -49,7 +53,8 @@ var (
) )
// Policy represents a parsed policy. // Policy represents a parsed policy.
// https://tools.ietf.org/html/draft-ietf-uta-mta-sts-02#section-3.2 // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-3.2
// The json annotations are used for serializing for caching purposes.
type Policy struct { type Policy struct {
Version string `json:"version"` Version string `json:"version"`
Mode Mode `json:"mode"` Mode Mode `json:"mode"`
@@ -62,28 +67,55 @@ type Mode string
// Valid modes. // Valid modes.
const ( const (
Enforce = Mode("enforce") Enforce = Mode("enforce")
Report = Mode("report") Testing = Mode("testing")
None = Mode("none")
) )
// parsePolicy parses a JSON representation of the policy, and returns the // parsePolicy parses a text representation of the policy (as specified in the
// corresponding Policy structure. // RFC), and returns the corresponding Policy structure.
func parsePolicy(raw []byte) (*Policy, error) { func parsePolicy(raw []byte) (*Policy, error) {
p := &Policy{} p := &Policy{}
if err := json.Unmarshal(raw, p); err != nil {
return nil, err scanner := bufio.NewScanner(bytes.NewReader(raw))
for scanner.Scan() {
sp := strings.SplitN(scanner.Text(), ":", 2)
if len(sp) != 2 {
continue
} }
// MaxAge is in seconds. key := strings.TrimSpace(sp[0])
p.MaxAge = p.MaxAge * time.Second value := strings.TrimSpace(sp[1])
// Only care for the keys we recognize.
switch key {
case "version":
p.Version = value
case "mode":
p.Mode = Mode(value)
case "max_age":
// On error, p.MaxAge will be 0 which is invalid.
max_age, _ := strconv.Atoi(value)
p.MaxAge = time.Duration(max_age) * time.Second
case "mx":
p.MXs = append(p.MXs, value)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return p, nil return p, nil
} }
var ( var (
// Check errors.
ErrUnknownVersion = errors.New("unknown policy version") ErrUnknownVersion = errors.New("unknown policy version")
ErrInvalidMaxAge = errors.New("invalid max_age") ErrInvalidMaxAge = errors.New("invalid max_age")
ErrInvalidMode = errors.New("invalid mode") ErrInvalidMode = errors.New("invalid mode")
ErrInvalidMX = errors.New("invalid mx") ErrInvalidMX = errors.New("invalid mx")
// Fetch errors.
ErrInvalidMediaType = errors.New("invalid HTTP media type")
) )
// Check that the policy contents are valid. // Check that the policy contents are valid.
@@ -95,7 +127,7 @@ func (p *Policy) Check() error {
return ErrInvalidMaxAge return ErrInvalidMaxAge
} }
if p.Mode != Enforce && p.Mode != Report { if p.Mode != Enforce && p.Mode != Testing && p.Mode != None {
return ErrInvalidMode return ErrInvalidMode
} }
@@ -109,7 +141,7 @@ func (p *Policy) Check() error {
} }
// MXMatches checks if the given MX is allowed, according to the policy. // MXMatches checks if the given MX is allowed, according to the policy.
// https://tools.ietf.org/html/draft-ietf-uta-mta-sts-02#section-4.1 // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-4.1
func (p *Policy) MXIsAllowed(mx string) bool { func (p *Policy) MXIsAllowed(mx string) bool {
for _, pattern := range p.MXs { for _, pattern := range p.MXs {
if matchDomain(mx, pattern) { if matchDomain(mx, pattern) {
@@ -150,9 +182,9 @@ func urlForDomain(domain string) string {
} }
// URL composed from the domain, as explained in: // URL composed from the domain, as explained in:
// https://tools.ietf.org/html/draft-ietf-uta-mta-sts-02#section-3.3 // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-3.3
// https://tools.ietf.org/html/draft-ietf-uta-mta-sts-02#section-3.2 // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-3.2
return "https://mta-sts." + domain + "/.well-known/mta-sts.json" return "https://mta-sts." + domain + "/.well-known/mta-sts.txt"
} }
// Fetch a policy for the given domain. Note this results in various network // Fetch a policy for the given domain. Note this results in various network
@@ -178,7 +210,7 @@ func Fetch(ctx context.Context, domain string) (*Policy, error) {
func httpGet(ctx context.Context, url string) ([]byte, error) { func httpGet(ctx context.Context, url string) ([]byte, error) {
client := &http.Client{ client := &http.Client{
// We MUST NOT follow redirects, see // We MUST NOT follow redirects, see
// https://tools.ietf.org/html/draft-ietf-uta-mta-sts-02#section-3.3 // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-3.3
CheckRedirect: rejectRedirect, CheckRedirect: rejectRedirect,
} }
@@ -194,13 +226,26 @@ func httpGet(ctx context.Context, url string) ([]byte, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP response status code: %v", resp.StatusCode)
}
// Media type must be "text/plain" to guard against cases where webservers
// allow untrusted users to host non-text content (like HTML or images) at
// a user-defined path.
// https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-3.2
mt, _, err := mime.ParseMediaType(resp.Header.Get("Content-type"))
if err != nil {
return nil, fmt.Errorf("HTTP media type error: %v", err)
}
if mt != "text/plain" {
return nil, ErrInvalidMediaType
}
// Read but up to 10k; policies should be way smaller than that, and // Read but up to 10k; policies should be way smaller than that, and
// having a limit prevents abuse/accidents with very large replies. // having a limit prevents abuse/accidents with very large replies.
return ioutil.ReadAll(&io.LimitedReader{resp.Body, 10 * 1024}) return ioutil.ReadAll(&io.LimitedReader{resp.Body, 10 * 1024})
} }
return nil, fmt.Errorf("HTTP response status code: %v", resp.StatusCode)
}
var errRejectRedirect = errors.New("redirects not allowed in MTA-STS") var errRejectRedirect = errors.New("redirects not allowed in MTA-STS")
@@ -209,8 +254,8 @@ func rejectRedirect(req *http.Request, via []*http.Request) error {
} }
// matchDomain checks if the domain matches the given pattern, according to // matchDomain checks if the domain matches the given pattern, according to
// https://tools.ietf.org/html/rfc6125#section-6.4 // from https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-4.1
// (from https://tools.ietf.org/html/draft-ietf-uta-mta-sts-02#section-4.1). // (based on https://tools.ietf.org/html/rfc6125#section-6.4).
func matchDomain(domain, pattern string) bool { func matchDomain(domain, pattern string) bool {
domain, dErr := domainToASCII(domain) domain, dErr := domainToASCII(domain)
pattern, pErr := domainToASCII(pattern) pattern, pErr := domainToASCII(pattern)
@@ -220,29 +265,24 @@ func matchDomain(domain, pattern string) bool {
return false return false
} }
domainLabels := strings.Split(domain, ".") // Simplify the case of a literal match.
patternLabels := strings.Split(pattern, ".") if domain == pattern {
if len(domainLabels) != len(patternLabels) {
return false
}
for i, p := range patternLabels {
// Wildcards only apply to the first part, see
// https://tools.ietf.org/html/rfc6125#section-6.4.3 #1 and #2.
// This also allows us to do the lenght comparison above.
if p == "*" && i == 0 {
continue
}
if p != domainLabels[i] {
return false
}
}
return true return true
} }
// For wildcards, skip the first part of the domain and match the rest.
// Note that if the pattern is malformed this might fail, but we are ok
// with that.
if strings.HasPrefix(pattern, "*.") {
parts := strings.SplitN(domain, ".", 2)
if len(parts) > 1 && parts[1] == pattern[2:] {
return true
}
}
return false
}
// domainToASCII converts the domain to ASCII form, similar to idna.ToASCII // domainToASCII converts the domain to ASCII form, similar to idna.ToASCII
// but with some preprocessing convenient for our use cases. // but with some preprocessing convenient for our use cases.
func domainToASCII(domain string) (string, error) { func domainToASCII(domain string) (string, error) {

View File

@@ -18,21 +18,19 @@ import (
var policyForDomain = map[string]string{ var policyForDomain = map[string]string{
// domain.com -> valid, with reasonable policy. // domain.com -> valid, with reasonable policy.
"domain.com": ` "domain.com": `
{ version: STSv1
"version": "STSv1", mode: enforce
"mode": "enforce", mx: *.mail.domain.com
"mx": ["*.mail.domain.com"], max_age: 3600
"max_age": 3600 `,
}`,
// version99 -> invalid policy (unknown version). // version99 -> invalid policy (unknown version).
"version99": ` "version99": `
{ version: STSv99
"version": "STSv99", mode: enforce
"mode": "enforce", mx: *.mail.version99
"mx": ["*.mail.version99"], max_age: 999
"max_age": 999 `,
}`,
} }
func testHTTPHandler(w http.ResponseWriter, r *http.Request) { func testHTTPHandler(w http.ResponseWriter, r *http.Request) {
@@ -55,12 +53,11 @@ func TestMain(m *testing.M) {
} }
func TestParsePolicy(t *testing.T) { func TestParsePolicy(t *testing.T) {
const pol1 = `{ const pol1 = `
"version": "STSv1", version: STSv1
"mode": "enforce", mode: enforce
"mx": ["*.mail.example.com"], mx: *.mail.example.com
"max_age": 123456 max_age: 123456
}
` `
p, err := parsePolicy([]byte(pol1)) p, err := parsePolicy([]byte(pol1))
if err != nil { if err != nil {
@@ -74,7 +71,9 @@ func TestCheckPolicy(t *testing.T) {
validPs := []Policy{ validPs := []Policy{
{Version: "STSv1", Mode: "enforce", MaxAge: 1 * time.Hour, {Version: "STSv1", Mode: "enforce", MaxAge: 1 * time.Hour,
MXs: []string{"mx1", "mx2"}}, MXs: []string{"mx1", "mx2"}},
{Version: "STSv1", Mode: "report", MaxAge: 1 * time.Hour, {Version: "STSv1", Mode: "testing", MaxAge: 1 * time.Hour,
MXs: []string{"mx1"}},
{Version: "STSv1", Mode: "none", MaxAge: 1 * time.Hour,
MXs: []string{"mx1"}}, MXs: []string{"mx1"}},
} }
for i, p := range validPs { for i, p := range validPs {
@@ -115,12 +114,18 @@ func TestMatchDomain(t *testing.T) {
{"abc.com", "abc.*.com", false}, {"abc.com", "abc.*.com", false},
{"abc.com", "x.abc.com", false}, {"abc.com", "x.abc.com", false},
{"x.abc.com", "*.*.com", false}, {"x.abc.com", "*.*.com", false},
{"abc.def.com", "abc.*.com", false},
{"ñaca.com", "ñaca.com", true}, {"ñaca.com", "ñaca.com", true},
{"Ñaca.com", "ñaca.com", true}, {"Ñaca.com", "ñaca.com", true},
{"ñaca.com", "Ñaca.com", true}, {"ñaca.com", "Ñaca.com", true},
{"x.ñaca.com", "x.xn--aca-6ma.com", true}, {"x.ñaca.com", "x.xn--aca-6ma.com", true},
{"x.naca.com", "x.xn--aca-6ma.com", false}, {"x.naca.com", "x.xn--aca-6ma.com", false},
// Examples from the RFC.
{"mail.example.com", "*.example.com", true},
{"example.com", "*.example.com", false},
{"foo.bar.example.com", "*.example.com", false},
} }
for _, c := range cases { for _, c := range cases {
@@ -341,7 +346,10 @@ func TestCacheRefresh(t *testing.T) {
ctx := context.Background() ctx := context.Background()
policyForDomain["refresh-test"] = ` policyForDomain["refresh-test"] = `
{"version": "STSv1", "mode": "enforce", "mx": ["mx"], "max_age": 100}` version: STSv1
mode: enforce
mx: mx
max_age: 100`
p := mustFetch(t, c, ctx, "refresh-test") p := mustFetch(t, c, ctx, "refresh-test")
if p.MaxAge != 100*time.Second { if p.MaxAge != 100*time.Second {
t.Fatalf("policy.MaxAge is %v, expected 100s", p.MaxAge) t.Fatalf("policy.MaxAge is %v, expected 100s", p.MaxAge)
@@ -350,7 +358,10 @@ func TestCacheRefresh(t *testing.T) {
// Change the "published" policy, check that we see the old version at // Change the "published" policy, check that we see the old version at
// fetch (should be cached), and a new version after a refresh. // fetch (should be cached), and a new version after a refresh.
policyForDomain["refresh-test"] = ` policyForDomain["refresh-test"] = `
{"version": "STSv1", "mode": "enforce", "mx": ["mx"], "max_age": 200}` version: STSv1
mode: enforce
mx: mx
max_age: 200`
p = mustFetch(t, c, ctx, "refresh-test") p = mustFetch(t, c, ctx, "refresh-test")
if p.MaxAge != 100*time.Second { if p.MaxAge != 100*time.Second {
@@ -377,7 +388,7 @@ func TestURLForDomain(t *testing.T) {
defer func() { fakeURLForTesting = oldURL }() defer func() { fakeURLForTesting = oldURL }()
got := urlForDomain("a-test-domain") got := urlForDomain("a-test-domain")
expected := "https://mta-sts.a-test-domain/.well-known/mta-sts.json" expected := "https://mta-sts.a-test-domain/.well-known/mta-sts.txt"
if got != expected { if got != expected {
t.Errorf("got %q, expected %q", got, expected) t.Errorf("got %q, expected %q", got, expected)
} }