Skip to content

Commit 72e9f99

Browse files
Preston12321mkhandker19
authored andcommitted
bdns: error when CAA query response is truncated (#8839)
This change ensures Boulder never trusts a CAA DNS query response when the TC (truncated) bit is set. Because the truncated portion may contain a valid CAA record that forbids issuance, it is not safe to trust a partial response. A truncated record like `CAA 0 issue ";"` could be safely ignored given a non-truncated `CAA 0 issue "letsencrypt.org"`; but if a record with an unknown/unsupported Property tag and the critical bit set, e.g. `CAA 128 unknown "foo"`, is ever truncated, that's a definitive misissuance per RFC 8659, [Section 4.1](https://datatracker.ietf.org/doc/html/rfc8659#section-4.1-6.2.2.2). > __Bit 0, Issuer Critical Flag:__ If the value is set to "1", the Property is critical. A CA __MUST NOT__ issue certificates for any FQDN if the Relevant RRset for that FQDN contains a CAA critical Property for an unknown or unsupported Property Tag. In practice, it would require a freak accident DNS misconfiguration or a malicious construction of records (an exercise left to the reader) to trigger this bug. And in any case, doing so doesn't grant an attacker any issuance capabilities they don't already have. If you have access to a domain's DNS zone to exploit this bug, you can already edit CAA records and validate DNS-01 challenges sufficient to issue a legitimate certificate. Nevertheless, this bug represents an attacker-controlled compliance risk and we should patch it.
1 parent dbdcd7c commit 72e9f99

4 files changed

Lines changed: 85 additions & 17 deletions

File tree

bdns/dns.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,9 @@ func (c *impl) LookupCAA(ctx context.Context, hostname string) (*Result[*dns.CAA
304304
// for DNS-01 challenge) and then removed after validation but before CAA
305305
// rechecking. But allow NXDOMAIN for TLDs to fall through to the error code
306306
// below, so we don't issue for gTLDs that have been removed by ICANN.
307-
if err == nil && resp.Rcode == dns.RcodeNameError && strings.Contains(hostname, ".") {
307+
// Truncated responses also fall through, since we can't definitively trust
308+
// an incomplete response to accurately reflect an NXDOMAIN.
309+
if err == nil && !resp.Truncated && resp.Rcode == dns.RcodeNameError && strings.Contains(hostname, ".") {
308310
return resultFromMsg[*dns.CAA](resp), resolver, nil
309311
}
310312

bdns/dns_test.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ func TestDNSNXDOMAIN(t *testing.T) {
543543
test.AssertContains(t, err.Error(), "NXDOMAIN looking up AAAA for")
544544

545545
_, _, err = obj.LookupTXT(context.Background(), hostname)
546-
expected := Error{dns.TypeTXT, hostname, nil, dns.RcodeNameError, nil}
546+
expected := Error{dns.TypeTXT, hostname, nil, dns.RcodeNameError, nil, false}
547547
test.AssertDeepEquals(t, err, expected)
548548
}
549549

@@ -961,3 +961,39 @@ func TestDOHMetric(t *testing.T) {
961961
// Now, we should count 1 "out of retries" errors.
962962
test.AssertMetricWithLabelsEquals(t, resolver.timeoutCounter, prometheus.Labels{"qtype": "None", "type": "out of retries", "resolver": "127.0.0.1", "isTLD": "false"}, 1)
963963
}
964+
965+
// truncatedExchanger returns a truncated (TC bit set) response with the given
966+
// Rcode. If a caller failed to check for truncation on a CAA query, it would
967+
// otherwise be fooled into trusting an incomplete set of records, potentially
968+
// missing an issue record that would forbid issuance.
969+
type truncatedExchanger struct {
970+
rcode int
971+
}
972+
973+
func (te truncatedExchanger) ExchangeContext(_ context.Context, m *dns.Msg, _ string) (*dns.Msg, time.Duration, error) {
974+
resp := new(dns.Msg)
975+
resp.SetReply(m)
976+
resp.Rcode = te.rcode
977+
resp.Truncated = true
978+
return resp, time.Millisecond, nil
979+
}
980+
981+
func TestDNSCAATruncatedResponse(t *testing.T) {
982+
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
983+
test.AssertNotError(t, err, "Got error creating StaticProvider")
984+
985+
client := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.NewMock(), tlsConfig)
986+
client.(*impl).exchanger = truncatedExchanger{rcode: dns.RcodeSuccess}
987+
988+
_, _, err = client.LookupCAA(context.Background(), "example.com")
989+
test.AssertError(t, err, "expected error for truncated CAA response")
990+
test.AssertContains(t, err.Error(), "response was truncated")
991+
992+
// A truncated NXDOMAIN response must not be treated as the usual
993+
// NXDOMAIN-as-empty-CAA-set special case for non-TLD names: we can't
994+
// trust an incomplete response to accurately reflect an NXDOMAIN.
995+
client.(*impl).exchanger = truncatedExchanger{rcode: dns.RcodeNameError}
996+
_, _, err = client.LookupCAA(context.Background(), "nonexistent.letsencrypt.org")
997+
test.AssertError(t, err, "expected error for truncated CAA response, even when NXDOMAIN-shaped")
998+
test.AssertContains(t, err.Error(), "response was truncated")
999+
}

bdns/problem.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,18 @@ import (
1414
type Error struct {
1515
recordType uint16
1616
hostname string
17-
// Exactly one of rCode or underlying should be set.
17+
// Exactly one of underlying, rCode, or truncated should be set.
1818
underlying error
1919
rCode int
2020

2121
// Optional: If the resolver returned extended error information, it will be stored here.
2222
// https://www.rfc-editor.org/rfc/rfc8914
2323
extended *dns.EDNS0_EDE
24+
25+
// truncated is set when the response to a CAA query had the TC bit set. We
26+
// don't implement fallback to TCP, so we treat a truncated response as an
27+
// error rather than risk silently acting on an incomplete set of records.
28+
truncated bool
2429
}
2530

2631
// extendedDNSError returns non-nil if the input message contained an OPT RR
@@ -39,8 +44,10 @@ func extendedDNSError(msg *dns.Msg) *dns.EDNS0_EDE {
3944
return nil
4045
}
4146

42-
// wrapErr returns a non-nil error if err is non-nil or if resp.Rcode is not dns.RcodeSuccess.
43-
// The error includes appropriate details about the DNS query that failed.
47+
// wrapErr returns a non-nil error if err is non-nil, if resp.Rcode is not
48+
// dns.RcodeSuccess, or if resp was truncated (the TC bit was set) for a CAA
49+
// query. The error includes appropriate details about the DNS query that
50+
// failed.
4451
func wrapErr(queryType uint16, hostname string, resp *dns.Msg, err error) error {
4552
if err != nil {
4653
return Error{
@@ -50,6 +57,13 @@ func wrapErr(queryType uint16, hostname string, resp *dns.Msg, err error) error
5057
extended: nil,
5158
}
5259
}
60+
if queryType == dns.TypeCAA && resp.Truncated {
61+
return Error{
62+
recordType: queryType,
63+
hostname: hostname,
64+
truncated: true,
65+
}
66+
}
5367
if resp.Rcode != dns.RcodeSuccess {
5468
return Error{
5569
recordType: queryType,
@@ -120,6 +134,8 @@ func (d Error) Error() string {
120134
} else {
121135
detail = detailServerFailure
122136
}
137+
} else if d.truncated {
138+
detail = detailDNSTruncated
123139
} else if d.rCode != dns.RcodeSuccess {
124140
detail = dns.RcodeToString[d.rCode]
125141
if explanation, ok := rcodeExplanations[d.rCode]; ok {
@@ -150,6 +166,7 @@ const detailDNSTimeout = "query timed out"
150166
const detailCanceled = "query timed out (and was canceled)"
151167
const detailDNSNetFailure = "networking error"
152168
const detailServerFailure = "server failure at resolver"
169+
const detailDNSTruncated = "response was truncated"
153170

154171
// rcodeExplanations provide additional friendly explanatory text to be included in DNS
155172
// error messages, for select inscrutable RCODEs.

bdns/problem_test.go

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,41 +18,44 @@ func TestError(t *testing.T) {
1818
expected string
1919
}{
2020
{
21-
&Error{dns.TypeMX, "hostname", &net.OpError{Err: errors.New("some net error")}, -1, nil},
21+
&Error{dns.TypeMX, "hostname", &net.OpError{Err: errors.New("some net error")}, -1, nil, false},
2222
"DNS problem: networking error looking up MX for hostname",
2323
}, {
24-
&Error{dns.TypeTXT, "hostname", nil, dns.RcodeNameError, nil},
24+
&Error{dns.TypeTXT, "hostname", nil, dns.RcodeNameError, nil, false},
2525
"DNS problem: NXDOMAIN looking up TXT for hostname - check that a DNS record exists for this domain",
2626
}, {
27-
&Error{dns.TypeTXT, "hostname", context.DeadlineExceeded, -1, nil},
27+
&Error{dns.TypeTXT, "hostname", context.DeadlineExceeded, -1, nil, false},
2828
"DNS problem: query timed out looking up TXT for hostname",
2929
}, {
30-
&Error{dns.TypeTXT, "hostname", context.Canceled, -1, nil},
30+
&Error{dns.TypeTXT, "hostname", context.Canceled, -1, nil, false},
3131
"DNS problem: query timed out (and was canceled) looking up TXT for hostname",
3232
}, {
33-
&Error{dns.TypeCAA, "hostname", nil, dns.RcodeServerFailure, nil},
33+
&Error{dns.TypeCAA, "hostname", nil, dns.RcodeServerFailure, nil, false},
3434
"DNS problem: SERVFAIL looking up CAA for hostname - the domain's nameservers may be malfunctioning",
3535
}, {
36-
&Error{dns.TypeA, "hostname", nil, dns.RcodeServerFailure, &dns.EDNS0_EDE{InfoCode: 1, ExtraText: "oh no"}},
36+
&Error{dns.TypeA, "hostname", nil, dns.RcodeServerFailure, &dns.EDNS0_EDE{InfoCode: 1, ExtraText: "oh no"}, false},
3737
"DNS problem: looking up A for hostname: DNSSEC: Unsupported DNSKEY Algorithm: oh no",
3838
}, {
39-
&Error{dns.TypeA, "hostname", nil, dns.RcodeServerFailure, &dns.EDNS0_EDE{InfoCode: 6, ExtraText: ""}},
39+
&Error{dns.TypeA, "hostname", nil, dns.RcodeServerFailure, &dns.EDNS0_EDE{InfoCode: 6, ExtraText: ""}, false},
4040
"DNS problem: looking up A for hostname: DNSSEC: Bogus",
4141
}, {
42-
&Error{dns.TypeA, "hostname", nil, dns.RcodeServerFailure, &dns.EDNS0_EDE{InfoCode: 1337, ExtraText: "mysterious"}},
42+
&Error{dns.TypeA, "hostname", nil, dns.RcodeServerFailure, &dns.EDNS0_EDE{InfoCode: 1337, ExtraText: "mysterious"}, false},
4343
"DNS problem: looking up A for hostname: Unknown Extended DNS Error code 1337: mysterious",
4444
}, {
45-
&Error{dns.TypeCAA, "hostname", nil, dns.RcodeServerFailure, nil},
45+
&Error{dns.TypeCAA, "hostname", nil, dns.RcodeServerFailure, nil, false},
4646
"DNS problem: SERVFAIL looking up CAA for hostname - the domain's nameservers may be malfunctioning",
4747
}, {
48-
&Error{dns.TypeCAA, "hostname", nil, dns.RcodeServerFailure, nil},
48+
&Error{dns.TypeCAA, "hostname", nil, dns.RcodeServerFailure, nil, false},
4949
"DNS problem: SERVFAIL looking up CAA for hostname - the domain's nameservers may be malfunctioning",
5050
}, {
51-
&Error{dns.TypeA, "hostname", nil, dns.RcodeFormatError, nil},
51+
&Error{dns.TypeA, "hostname", nil, dns.RcodeFormatError, nil, false},
5252
"DNS problem: FORMERR looking up A for hostname",
5353
}, {
54-
&Error{dns.TypeA, "hostname", &url.Error{Op: "GET", URL: "https://example.com/", Err: dohTimeoutError{}}, -1, nil},
54+
&Error{dns.TypeA, "hostname", &url.Error{Op: "GET", URL: "https://example.com/", Err: dohTimeoutError{}}, -1, nil, false},
5555
"DNS problem: query timed out looking up A for hostname",
56+
}, {
57+
&Error{dns.TypeCAA, "hostname", nil, dns.RcodeSuccess, nil, true},
58+
"DNS problem: response was truncated looking up CAA for hostname",
5659
},
5760
}
5861
for _, tc := range testCases {
@@ -87,4 +90,14 @@ func TestWrapErr(t *testing.T) {
8790
MsgHdr: dns.MsgHdr{Rcode: dns.RcodeSuccess},
8891
}, errors.New("oh no"))
8992
test.AssertError(t, err, "expected error")
93+
94+
// A truncated response should be treated as an error, even though its
95+
// Rcode is RcodeSuccess: a truncated CAA response could be silently
96+
// missing the issue/issuewild records that would otherwise forbid
97+
// issuance.
98+
err = wrapErr(dns.TypeCAA, "hostname", &dns.Msg{
99+
MsgHdr: dns.MsgHdr{Rcode: dns.RcodeSuccess, Truncated: true},
100+
}, nil)
101+
test.AssertError(t, err, "expected error for truncated response")
102+
test.AssertContains(t, err.Error(), "response was truncated")
90103
}

0 commit comments

Comments
 (0)