1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2026-10-29 15:02:30 +00:00
Files
go-chasquid-smtp/internal/dkim/header.go
T
Alberto Bertogli 10aa1a55d0 dkim: Validate domain in s= and d= tags
We currently use the s= and d= tags as-is without any validation.

They are used to construct the DNS name to look up the key, which is
generally fine, but also the d= tag is used as-is in the
Authentication-Results header.

The latter can be used to inject malicious content, for example:

  d=example.com dkim=pass header.d=example.net

can result in the header including:

  header.d=example.com dkim=pass header.d=example.net

regardless of the validation status.

To fix that problem, this patch now validates the domain (using the same
normalization we use in other areas, like RCPT TO and MAIL FROM) on both
tags.

If any of the tags have invalid domains, we consider the signature
invalid as per RFC 6376 section 3.5.
2026-10-12 19:53:47 +01:00

382 lines
9.5 KiB
Go

package dkim
import (
"crypto"
"encoding/base64"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"blitiri.com.ar/go/chasquid/internal/normalize"
)
// https://datatracker.ietf.org/doc/html/rfc6376#section-6
type dkimSignature struct {
// Version. Must be "1".
v string
// Algorithm. Like "rsa-sha256".
a string
// Key type, extracted from a=.
KeyType keyType
// Hash, extracted from a=.
Hash crypto.Hash
// Signature data.
// Decoded from base64, ignoring whitespace.
b []byte
// Hash of canonicalized body.
// Decoded from base64, ignoring whitespace.
bh []byte
// Canonicalization modes.
cH canonicalization
cB canonicalization
// Domain ("SDID"), in plain text.
// IDNs MUST be encoded as A-labels.
d string
// Signed header fields.
// Colon-separated list of header fields.
h []string
// AUID, in plain text.
i string
// Body octet count of the canonicalized body.
// nil if not present. Note that 0 is a valid value, and means that none
// of the body is signed.
l *uint64
// Query methods used for DNS lookup.
// Colon-separated list of methods. Only "dns/txt" is valid.
q []string
// Selector.
s string
// Timestamp. In Seconds since the UNIX epoch.
t time.Time
// Signature expiration. In Seconds since the UNIX epoch.
x time.Time
// Copied header fields.
// Has a specific encoding but whitespace is ignored.
z string
}
func (sig *dkimSignature) canonicalizationFromString(s string) error {
if s == "" {
sig.cH = simpleCanonicalization
sig.cB = simpleCanonicalization
return nil
}
// Either "header/body" or "header". In the latter case, "simple" is used
// for the body canonicalization.
// No whitespace around the '/' is allowed.
hs, bs, _ := strings.Cut(s, "/")
if bs == "" {
bs = "simple"
}
var err error
sig.cH, err = stringToCanonicalization(hs)
if err != nil {
return fmt.Errorf("header: %w", err)
}
sig.cB, err = stringToCanonicalization(bs)
if err != nil {
return fmt.Errorf("body: %w", err)
}
return nil
}
// Maximum number of entries in the h= tag that we accept.
//
// The RFC does not set a limit, but h= is attacker-controlled, so we cap it
// to bound the work done when verifying. Signatures over the limit are
// considered invalid (instead of truncating h=, which would also make them
// fail, but in a more obscure way).
//
// 512 is chosen to be well above what legitimate signers use:
// - Signers typically use a few dozen entries at most (ours uses ~25).
// - OpenDKIM truncates h= to 4096 bytes, so signatures with a longer h=
// already fail verification there. With realistic header names (~10
// bytes each), 512 entries is above that.
// - Exim limits the number of headers it considers when verifying to 512.
const maxHTagEntries = 512
func (sig *dkimSignature) checkRequiredTags() error {
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1
if sig.a == "" {
return fmt.Errorf("%w: a=", errMissingRequiredTag)
}
if len(sig.b) == 0 {
return fmt.Errorf("%w: b=", errMissingRequiredTag)
}
if len(sig.bh) == 0 {
return fmt.Errorf("%w: bh=", errMissingRequiredTag)
}
if sig.d == "" {
return fmt.Errorf("%w: d=", errMissingRequiredTag)
}
if len(sig.h) == 0 {
return fmt.Errorf("%w: h=", errMissingRequiredTag)
}
if sig.s == "" {
return fmt.Errorf("%w: s=", errMissingRequiredTag)
}
// d= and s= must be valid domain names (a selector has the same syntax).
// They are attacker-controlled, and used to build the DNS name of the
// key; d= is also included in the Authentication-Results header, so
// otherwise it could be used to inject content into it.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.5
if _, err := normalize.Domain(sig.d); err != nil {
return fmt.Errorf("%w: d= is not a valid domain: %w",
errInvalidTag, err)
}
if _, err := normalize.Domain(sig.s); err != nil {
return fmt.Errorf("%w: s= is not a valid selector: %w",
errInvalidTag, err)
}
if len(sig.h) > maxHTagEntries {
return fmt.Errorf("%w: h= has too many entries (%d > %d)",
errInvalidTag, len(sig.h), maxHTagEntries)
}
// h= must contain From.
var isFrom = func(s string) bool { return strings.EqualFold(s, "from") }
if !slices.ContainsFunc(sig.h, isFrom) {
return fmt.Errorf("%w: h= does not contain 'from'", errInvalidTag)
}
// If i= is present, its domain must be equal to, or a subdomain of, d=.
if sig.i != "" {
_, domain, _ := strings.Cut(sig.i, "@")
if domain != sig.d && !strings.HasSuffix(domain, "."+sig.d) {
return fmt.Errorf("%w: i= is not a subdomain of d=",
errInvalidTag)
}
}
return nil
}
var (
errInvalidSignature = errors.New("invalid signature")
errInvalidVersion = errors.New("invalid version")
errBadATag = errors.New("invalid a= tag")
errUnsupportedHash = errors.New("unsupported hash")
errUnsupportedKeyType = errors.New("unsupported key type")
errMissingRequiredTag = errors.New("missing required tag")
errNegativeTimestamp = errors.New("negative timestamp")
)
// String replacer that removes whitespace.
var eatWhitespace = strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "")
func dkimSignatureFromHeader(header string) (*dkimSignature, error) {
tags, err := parseTags(header)
if err != nil {
return nil, err
}
sig := &dkimSignature{
v: tags["v"],
a: tags["a"],
}
// v= tag is mandatory and must be 1.
if sig.v != "1" {
return nil, errInvalidVersion
}
// a= tag is mandatory; check that we can parse it and that we support the
// algorithms.
ktS, hS, found := strings.Cut(sig.a, "-")
if !found {
return nil, errBadATag
}
sig.KeyType, err = keyTypeFromString(ktS)
if err != nil {
return nil, fmt.Errorf("%w: %s", err, sig.a)
}
sig.Hash, err = hashFromString(hS)
if err != nil {
return nil, fmt.Errorf("%w: %s", err, sig.a)
}
// b is base64-encoded, and whitespace in it must be ignored.
sig.b, err = base64.StdEncoding.DecodeString(
eatWhitespace.Replace(tags["b"]))
if err != nil {
return nil, fmt.Errorf("%w: failed to decode b: %w",
errInvalidSignature, err)
}
// bh - same as b.
sig.bh, err = base64.StdEncoding.DecodeString(
eatWhitespace.Replace(tags["bh"]))
if err != nil {
return nil, fmt.Errorf("%w: failed to decode bh: %w",
errInvalidSignature, err)
}
err = sig.canonicalizationFromString(tags["c"])
if err != nil {
return nil, fmt.Errorf("%w: failed to parse c: %w",
errInvalidSignature, err)
}
sig.d = tags["d"]
// h is a colon-separated list of header fields.
if tags["h"] != "" {
sig.h = strings.Split(eatWhitespace.Replace(tags["h"]), ":")
}
sig.i = tags["i"]
if tags["l"] != "" {
var l uint64
l, err = strconv.ParseUint(tags["l"], 10, 64)
if err != nil {
return nil, fmt.Errorf("%w: failed to parse l: %w",
errInvalidSignature, err)
}
sig.l = &l
}
// q is a colon-separated list of query methods.
if tags["q"] != "" {
sig.q = strings.Split(eatWhitespace.Replace(tags["q"]), ":")
}
if len(sig.q) > 0 && !slices.Contains(sig.q, "dns/txt") {
return nil, fmt.Errorf("%w: no dns/txt query method in q",
errInvalidSignature)
}
sig.s = tags["s"]
if tags["t"] != "" {
sig.t, err = unixStrToTime(tags["t"])
if err != nil {
return nil, fmt.Errorf("%w: failed to parse t: %w",
errInvalidSignature, err)
}
}
if tags["x"] != "" {
sig.x, err = unixStrToTime(tags["x"])
if err != nil {
return nil, fmt.Errorf("%w: failed to parse x: %w",
errInvalidSignature, err)
}
}
sig.z = eatWhitespace.Replace(tags["z"])
// Check required tags are present.
if err := sig.checkRequiredTags(); err != nil {
return nil, err
}
return sig, nil
}
func unixStrToTime(s string) (time.Time, error) {
// Technically the timestamp is an "unsigned decimal integer", but since
// time.Unix takes an int64, we use that and check it's positive.
ti, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return time.Time{}, err
}
if ti < 0 {
return time.Time{}, errNegativeTimestamp
}
return time.Unix(ti, 0), nil
}
type keyType string
const (
keyTypeRSA keyType = "rsa"
keyTypeEd25519 keyType = "ed25519"
)
func keyTypeFromString(s string) (keyType, error) {
switch s {
case "rsa":
return keyTypeRSA, nil
case "ed25519":
return keyTypeEd25519, nil
default:
return "", errUnsupportedKeyType
}
}
func hashFromString(s string) (crypto.Hash, error) {
switch s {
// Note SHA1 is not supported: as per RFC 8301, it must not be used
// for signing or verifying.
// https://datatracker.ietf.org/doc/html/rfc8301#section-3.1
case "sha256":
return crypto.SHA256, nil
default:
return 0, errUnsupportedHash
}
}
// DKIM Tag=Value lists, as defined in RFC 6376, Section 3.2.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.2
type tags map[string]string
var errInvalidTag = errors.New("invalid tag")
func parseTags(s string) (tags, error) {
// First trim space, and trailing semicolon, to simplify parsing below.
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, ";")
tags := make(tags)
for tv := range strings.SplitSeq(s, ";") {
t, v, found := strings.Cut(tv, "=")
if !found {
return nil, fmt.Errorf("%w: missing '='", errInvalidTag)
}
// Trim leading and trailing whitespace from tag and value, as per
// RFC.
t = strings.TrimSpace(t)
v = strings.TrimSpace(v)
if t == "" {
return nil, fmt.Errorf("%w: missing tag name", errInvalidTag)
}
// RFC 6376, Section 3.2: Tags with duplicate names MUST NOT occur
// within a single tag-list; if a tag name does occur more than once,
// the entire tag-list is invalid.
if _, exists := tags[t]; exists {
return nil, fmt.Errorf("%w: duplicate tag", errInvalidTag)
}
tags[t] = v
}
return tags, nil
}