Skip to content

Commit b951ea0

Browse files
committed
fix: correct CAA quoting and NXDOMAIN/NODATA distinction for managed records
probeRR and answerManaged were applying TXT-style double-quote wrapping to CAA records. CAA wire format is "0 issue <value>" — wrapping the entire value in quotes caused dns.NewRR to reject every CAA record. Remove CAA from the quoting logic; only TXT needs it. answerManaged now returns ([]dns.RR, bool) where the bool signals whether the queried name exists at all (in any record type). answer() uses this to set NOERROR when the name exists but no records match the queried type (RFC 2308 NODATA), rather than leaving the rcode as NXDOMAIN. Add TestAnswerManagedNODATA, TestAnswerManagedCAARecord (dns_test.go) and TestAdminCreateCAARecord (api_test.go) to cover both fixes.
1 parent 6440f7a commit b951ea0

4 files changed

Lines changed: 81 additions & 10 deletions

File tree

api.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func toFQDN(name string) string {
2525
return name
2626
}
2727

28-
// stripOuterQuotes removes a single layer of surrounding double-quotes from TXT/CAA values
28+
// stripOuterQuotes removes a single layer of surrounding double-quotes from TXT values
2929
// so the DB always stores the raw string content regardless of how the caller supplied it.
3030
func stripOuterQuotes(value string) string {
3131
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
@@ -36,7 +36,7 @@ func stripOuterQuotes(value string) string {
3636

3737
// probeRR validates that rtype+value form a parseable DNS RR using a placeholder name and TTL.
3838
func probeRR(rtype, value string) bool {
39-
if rtype == "TXT" || rtype == "CAA" {
39+
if rtype == "TXT" {
4040
value = `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
4141
}
4242
_, err := dns.NewRR(fmt.Sprintf("probe.invalid. 300 IN %s %s", rtype, value))

api_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,20 @@ func TestAdminCreateRecordInvalidMXValue(t *testing.T) {
630630
Expect().Status(http.StatusBadRequest)
631631
}
632632

633+
func TestAdminCreateCAARecord(t *testing.T) {
634+
_, e := setupAdminRouter(t, "test-token")
635+
payload := map[string]interface{}{
636+
"name": "caa.example.com",
637+
"type": "CAA",
638+
"value": `0 issue "letsencrypt.org"`,
639+
"ttl": 300,
640+
}
641+
e.POST("/admin/records").
642+
WithHeader("Authorization", "Bearer test-token").
643+
WithJSON(payload).
644+
Expect().Status(http.StatusCreated).JSON().Object().ContainsKey("id")
645+
}
646+
633647
func TestAdminTXTValueQuoteStripping(t *testing.T) {
634648
_, e := setupAdminRouter(t, "test-token")
635649
// Value submitted with surrounding quotes should be stored and returned without them.

dns.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,14 @@ func (d *DNSServer) answer(q dns.Question) ([]dns.RR, int, bool, error) {
211211
}
212212
// Fall through to managed dns_records if no static or ACME records matched
213213
if len(r) == 0 {
214-
managed := d.answerManaged(q)
214+
managed, nameExists := d.answerManaged(q)
215215
if len(managed) > 0 {
216216
r = managed
217217
authoritative = true
218+
} else if nameExists {
219+
// Name exists in dns_records but not for this type: NODATA (NOERROR, empty answer)
220+
rcode = dns.RcodeSuccess
221+
authoritative = true
218222
}
219223
}
220224
if len(r) > 0 {
@@ -252,21 +256,28 @@ func (d *DNSServer) answerOwnChallenge(q dns.Question) ([]dns.RR, error) {
252256
return []dns.RR{r}, nil
253257
}
254258

255-
func (d *DNSServer) answerManaged(q dns.Question) []dns.RR {
259+
func (d *DNSServer) answerManaged(q dns.Question) ([]dns.RR, bool) {
256260
qtype := dns.TypeToString[q.Qtype]
257261
if qtype == "" {
258-
return nil
262+
return nil, false
259263
}
260264
name := strings.ToLower(q.Name)
261-
records, err := d.DB.ListRecords(qtype, name)
265+
// Check if the name exists at all (any type) to distinguish NXDOMAIN from NODATA.
266+
allRecords, err := d.DB.ListRecords("", name)
262267
if err != nil {
263268
log.WithFields(log.Fields{"error": err.Error()}).Debug("Error querying managed records")
264-
return nil
269+
return nil, false
270+
}
271+
if len(allRecords) == 0 {
272+
return nil, false
265273
}
266274
var rrs []dns.RR
267-
for _, rec := range records {
275+
for _, rec := range allRecords {
276+
if rec.Type != qtype {
277+
continue
278+
}
268279
value := rec.Value
269-
if rec.Type == "TXT" || rec.Type == "CAA" {
280+
if rec.Type == "TXT" {
270281
value = `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
271282
}
272283
rrStr := fmt.Sprintf("%s %d IN %s %s", rec.Name, rec.TTL, rec.Type, value)
@@ -277,5 +288,5 @@ func (d *DNSServer) answerManaged(q dns.Question) []dns.RR {
277288
}
278289
rrs = append(rrs, rr)
279290
}
280-
return rrs
291+
return rrs, true
281292
}

dns_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,3 +302,49 @@ func TestAnswerManagedARecord(t *testing.T) {
302302
t.Fatal("expected at least one RR in answer")
303303
}
304304
}
305+
306+
func TestAnswerManagedNODATA(t *testing.T) {
307+
// Name exists in dns_records as A, query for AAAA — must be NODATA (NOERROR, empty answer).
308+
rec := DNSRecord{
309+
ID: "nodata-test-1", Name: "nodata.auth.example.org.", Type: "A", Value: "1.2.3.4", TTL: 300, Created: 0,
310+
}
311+
if err := DB.CreateRecord(rec); err != nil {
312+
t.Fatalf("CreateRecord: %v", err)
313+
}
314+
t.Cleanup(func() { _ = DB.DeleteRecord("nodata-test-1") })
315+
316+
q := dns.Question{Name: "nodata.auth.example.org.", Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}
317+
rrs, rcode, _, err := dnsserver.answer(q)
318+
if err != nil {
319+
t.Fatalf("unexpected error: %v", err)
320+
}
321+
if rcode != dns.RcodeSuccess {
322+
t.Fatalf("expected NOERROR (NODATA) for existing name with wrong type, got %s", dns.RcodeToString[rcode])
323+
}
324+
if len(rrs) != 0 {
325+
t.Fatalf("expected empty answer section for NODATA, got %d records", len(rrs))
326+
}
327+
}
328+
329+
func TestAnswerManagedCAARecord(t *testing.T) {
330+
// CAA value must not be quote-wrapped — it has its own tag-value structure.
331+
rec := DNSRecord{
332+
ID: "caa-test-1", Name: "caa.auth.example.org.", Type: "CAA", Value: `0 issue "letsencrypt.org"`, TTL: 300, Created: 0,
333+
}
334+
if err := DB.CreateRecord(rec); err != nil {
335+
t.Fatalf("CreateRecord: %v", err)
336+
}
337+
t.Cleanup(func() { _ = DB.DeleteRecord("caa-test-1") })
338+
339+
q := dns.Question{Name: "caa.auth.example.org.", Qtype: dns.TypeCAA, Qclass: dns.ClassINET}
340+
rrs, rcode, _, err := dnsserver.answer(q)
341+
if err != nil {
342+
t.Fatalf("unexpected error: %v", err)
343+
}
344+
if rcode != dns.RcodeSuccess {
345+
t.Fatalf("expected NOERROR for CAA query, got %s", dns.RcodeToString[rcode])
346+
}
347+
if len(rrs) == 0 {
348+
t.Fatal("expected CAA record in answer, got none")
349+
}
350+
}

0 commit comments

Comments
 (0)