1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2026-10-28 15:02:28 +00:00

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.
This commit is contained in:
Alberto Bertogli
2026-10-12 19:48:22 +01:00
parent 5e827fe36f
commit 10aa1a55d0
3 changed files with 86 additions and 0 deletions
+16
View File
@@ -9,6 +9,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"blitiri.com.ar/go/chasquid/internal/normalize"
) )
// https://datatracker.ietf.org/doc/html/rfc6376#section-6 // https://datatracker.ietf.org/doc/html/rfc6376#section-6
@@ -136,6 +138,20 @@ func (sig *dkimSignature) checkRequiredTags() error {
return fmt.Errorf("%w: s=", errMissingRequiredTag) 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 { if len(sig.h) > maxHTagEntries {
return fmt.Errorf("%w: h= has too many entries (%d > %d)", return fmt.Errorf("%w: h= has too many entries (%d > %d)",
errInvalidTag, len(sig.h), maxHTagEntries) errInvalidTag, len(sig.h), maxHTagEntries)
+35
View File
@@ -333,6 +333,30 @@ func TestCheckRequiredTags(t *testing.T) {
}, },
err: "invalid tag: h= does not contain 'from'", err: "invalid tag: h= does not contain 'from'",
}, },
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "example.com dkim=pass header.d=example.net",
h: []string{"from"},
s: "selector",
},
err: "invalid tag: d= is not a valid domain: " +
"invalid domain: invalid character ' '",
},
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "example.com",
h: []string{"from"},
s: "sel/ector",
},
err: "invalid tag: s= is not a valid selector: " +
"invalid domain: invalid character '/'",
},
{ {
sig: &dkimSignature{ sig: &dkimSignature{
a: "rsa-sha256", a: "rsa-sha256",
@@ -381,6 +405,17 @@ func TestCheckRequiredTags(t *testing.T) {
}, },
err: "<nil>", err: "<nil>",
}, },
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "ñandú.example", // U-labels are allowed (RFC 8616).
h: []string{"from"},
s: "sel_2026.ector", // "_" and "." are allowed.
},
err: "<nil>",
},
{ {
sig: &dkimSignature{ sig: &dkimSignature{
a: "rsa-sha256", a: "rsa-sha256",
+35
View File
@@ -262,6 +262,41 @@ func TestVerifyBodyLength(t *testing.T) {
} }
} }
func TestVerifyInvalidDomain(t *testing.T) {
ctx := t.Context()
ctx = WithTraceFunc(ctx, t.Logf)
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{}))
// A d= tag that is not a valid domain must be rejected, and must not
// make it to the Authentication-Results header, where it could inject
// content.
message := toCRLF(
"DKIM-Signature: v=1; a=ed25519-sha256; c=simple/simple;\n" +
" d=example.com dkim=pass header.d=example.net; s=sel;\n" +
" h=from; bh=AAAA; b=AAAA\n" +
"From: Joe SixPack <joe@example.com>\n" +
"\n" +
"Hi.\n")
res, err := VerifyMessage(ctx, message)
if err != nil {
t.Fatalf("VerifyMessage returned error: %v", err)
}
if res.Found != 1 || res.Valid != 0 {
t.Fatalf("wanted 1 found, 0 valid; got %v", res)
}
r := res.Results[0]
if r.State != PERMFAIL || !errors.Is(r.Error, errInvalidTag) {
t.Errorf("wanted PERMFAIL / %v; got %v / %v",
errInvalidTag, r.State, r.Error)
}
ar := res.AuthenticationResults()
if strings.Contains(ar, "example.net") {
t.Errorf("Authentication-Results contains the invalid d=: %q", ar)
}
}
func TestHeadersToInclude(t *testing.T) { func TestHeadersToInclude(t *testing.T) {
// Test that headersToInclude returns the expected headers. // Test that headersToInclude returns the expected headers.
cases := []struct { cases := []struct {