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

dkim: Make b= tag stripping more precise

When verifying a signature we need to strip the value of the b= tag.

Today, we do this with a regexp that can accidentally match other tag
values. For example, "z=Subject:club=20".

This can cause valid emails to fail verification. But it can't cause an
invalid email to pass verification, so it's not a significant security
concern.

To fix this, this patch updates the regular expression to match the tag
more precisely. It also adds a few tests to validate the behaviour is
correct, which were cross-checked with other implementations.

Thanks to John JEAN <john@wargan.com> for reporting this bug.
This commit is contained in:
Alberto Bertogli
2026-10-14 20:08:35 +01:00
parent 9a59bd77d1
commit 0b0e520692
6 changed files with 157 additions and 4 deletions
+3
View File
@@ -0,0 +1,3 @@
brisbane._domainkey.football.example.com: \
v=DKIM1; k=ed25519; \
p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
+18
View File
@@ -0,0 +1,18 @@
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; s=brisbane; t=1528637909;
h=from:to:subject:date:message-id;
b=xjoLeGfcxnO99aQ8Tlc/unAH+heqD8dXVjKLdvzbUr7sctU9pf/V3FRluuVyLtxiZieesO7xRWJAcThkMDIqAA==;
bh=W3XNOuZiySVQVWa4kPgRp9uVKwEhAmhwKC5NysNOjhg=;
xb=1;
z=Subject:Is=20the=20club=20open?
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is the club open?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
Is the club open tonight? We could watch the game there.
Joe.
+9
View File
@@ -0,0 +1,9 @@
This tests a DKIM signature with tags that contain "b=" but are not the b=
tag: an unknown "xb=" tag, and a z= tag with a quoted-printable "club=20".
The b= tag is also not the last one.
When verifying, only the value of the real b= tag must be removed before
hashing the DKIM-Signature header.
It was signed with Mail::DKIM (Perl), using the RFC 8463 ed25519 key, and
verified with both Mail::DKIM and dkimpy.
+14
View File
@@ -0,0 +1,14 @@
{
"Found": 1,
"Valid": 1,
"Results": [
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; s=brisbane; t=1528637909;\r\n h=from:to:subject:date:message-id;\r\n b=xjoLeGfcxnO99aQ8Tlc/unAH+heqD8dXVjKLdvzbUr7sctU9pf/V3FRluuVyLtxiZieesO7xRWJAcThkMDIqAA==;\r\n bh=W3XNOuZiySVQVWa4kPgRp9uVKwEhAmhwKC5NysNOjhg=;\r\n xb=1;\r\n z=Subject:Is=20the=20club=20open?",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "xjoLeGfcxnO99aQ8Tlc/unAH+heqD8dXVjKLdvzbUr7sctU9pf/V3FRluuVyLtxiZieesO7xRWJAcThkMDIqAA==",
"State": "SUCCESS"
}
]
}
+9 -4
View File
@@ -141,10 +141,15 @@ func VerifyMessage(ctx context.Context, message string) (*VerifyResult, error) {
return results, nil return results, nil
} }
// Regular expression that matches the "b=" tag. // Regular expression that matches the "b=" tag, in a (canonicalized)
// First capture group is the "b=" part (including any whitespace up to the // DKIM-Signature header, including the header name.
// It must be anchored at the beginning of a tag, so it doesn't match other
// tags or values that contain "b=" (e.g. "xb=", or "z=...club=20...").
// First capture group is what precedes the tag: the header name and ':' if
// it is the first tag, or ';' otherwise.
// Second capture group is the "b=" part (including any whitespace up to the
// '='). // '=').
var bTag = regexp.MustCompile(`(b[ \t\r\n]*=)[^;]+`) var bTag = regexp.MustCompile(`(^[^:]*:|;)([ \t\r\n]*b[ \t\r\n]*=)[^;]*`)
func verifySignature(ctx context.Context, sigH header, func verifySignature(ctx context.Context, sigH header,
headers headers, body string) *OneResult { headers headers, body string) *OneResult {
@@ -233,7 +238,7 @@ func verifySignature(ctx context.Context, sigH header,
// with an empty b= tag, and without a trailing \r\n. // with an empty b= tag, and without a trailing \r\n.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7 // https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
sigC := sig.cH.header(sigH) sigC := sig.cH.header(sigH)
sigCStr := bTag.ReplaceAllString(sigC.Source, "$1") sigCStr := bTag.ReplaceAllString(sigC.Source, "$1$2")
trace(ctx, "Hashing header: %q", sigCStr) trace(ctx, "Hashing header: %q", sigCStr)
b.Write([]byte(sigCStr)) b.Write([]byte(sigCStr))
bSum := b.Sum(nil) bSum := b.Sum(nil)
+104
View File
@@ -297,6 +297,110 @@ func TestVerifyInvalidDomain(t *testing.T) {
} }
} }
func TestBTag(t *testing.T) {
// Test that we remove the value of the b= tag (and only that) from the
// canonicalized DKIM-Signature header.
// The expected values match what Mail::DKIM does (except for the cases
// without a b= tag, where it appends one).
cases := []struct {
in, want string
}{
// Relaxed canonicalization, b= in different positions.
{"dkim-signature:v=1; bh=AAAA; b=SIG",
"dkim-signature:v=1; bh=AAAA; b="},
{"dkim-signature:v=1; bh=AAAA; b=SIG;",
"dkim-signature:v=1; bh=AAAA; b=;"},
{"dkim-signature:b=SIG; v=1",
"dkim-signature:b=; v=1"},
{"dkim-signature:v=1; b=SIG; bh=AAAA; a=ed25519-sha256",
"dkim-signature:v=1; b=; bh=AAAA; a=ed25519-sha256"},
// Simple canonicalization: the header name is unchanged, and
// whitespace (including folding) is preserved.
{"DKIM-Signature: b=SIG; v=1",
"DKIM-Signature: b=; v=1"},
{"DKIM-Signature: v=1;\r\n\tb=SIG1\r\n\t SIG2;\r\n\tbh=AAAA",
"DKIM-Signature: v=1;\r\n\tb=;\r\n\tbh=AAAA"},
{"DKIM-Signature: v=1;\r\n\t b=SIG",
"DKIM-Signature: v=1;\r\n\t b="},
{"DKIM-Signature: v=1;\r\n b\r\n =\r\n SIG\r\n ;",
"DKIM-Signature: v=1;\r\n b\r\n =;"},
// Whitespace around and inside the value is removed.
{"dkim-signature:v=1; b = SI G ;",
"dkim-signature:v=1; b =;"},
// Empty b= tag.
{"dkim-signature:v=1; b=; bh=AAAA",
"dkim-signature:v=1; b=; bh=AAAA"},
{"dkim-signature:v=1; b=",
"dkim-signature:v=1; b="},
// The whole value is removed, even if it's not valid base64.
{"dkim-signature:v=1; b=SI!G; x=1",
"dkim-signature:v=1; b=; x=1"},
// Degenerate folding, with a ':' at the beginning of a continuation
// line.
{"DKIM-Signature: v=1; h=from:to\r\n\t:subject;\r\n\tb=SIG",
"DKIM-Signature: v=1; h=from:to\r\n\t:subject;\r\n\tb="},
// Tags and values that contain "b=" but are not the b= tag must be
// left alone.
{"dkim-signature:v=1; bh=AAAA",
"dkim-signature:v=1; bh=AAAA"},
{"dkim-signature:v=1; bh=AAb=; b=SIG",
"dkim-signature:v=1; bh=AAb=; b="},
{"dkim-signature:v=1; xb=1; b=SIG",
"dkim-signature:v=1; xb=1; b="},
{"dkim-signature:v=1; x=b=1; b=SIG",
"dkim-signature:v=1; x=b=1; b="},
{"dkim-signature:v=1; x=y b=1; b=SIG",
"dkim-signature:v=1; x=y b=1; b="},
{"dkim-signature:v=1; z=Subject:Is=20the=20club=20open?; b=SIG",
"dkim-signature:v=1; z=Subject:Is=20the=20club=20open?; b="},
{"dkim-signature:z=Subject:b=20x; b=SIG",
"dkim-signature:z=Subject:b=20x; b="},
{"dkim-signature:v=1; i=bob=2B@example.com; b=SIG",
"dkim-signature:v=1; i=bob=2B@example.com; b="},
{"dkim-signature:v=1; xb=1; z=a:b=20",
"dkim-signature:v=1; xb=1; z=a:b=20"},
// Tag names are case-sensitive.
{"dkim-signature:v=1; B=SIG; b=SIG",
"dkim-signature:v=1; B=SIG; b="},
}
for _, c := range cases {
got := bTag.ReplaceAllString(c.in, "$1$2")
if got != c.want {
t.Errorf("bTag replace on %q:", c.in)
t.Errorf(" want %q", c.want)
t.Errorf(" got %q", got)
}
// Cross-check against the tag parser: the result must have the
// same tags as the input, with only b= (if present) emptied.
_, inV, _ := strings.Cut(c.in, ":")
_, gotV, _ := strings.Cut(got, ":")
inTags, err := parseTags(inV)
if err != nil {
t.Fatalf("parseTags(%q) error: %v", inV, err)
}
gotTags, err := parseTags(gotV)
if err != nil {
t.Fatalf("parseTags(%q) error: %v", gotV, err)
}
if _, ok := inTags["b"]; ok {
inTags["b"] = ""
}
if diff := cmp.Diff(inTags, gotTags); diff != "" {
t.Errorf("bTag replace on %q: tags diff (-want +got):\n%s",
c.in, diff)
}
}
}
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 {