From 5d1dce038fe68f00457a123b7598fae36af37870 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 18 Nov 2025 13:38:31 +0700 Subject: [PATCH] WIP add dnssec test --- api/openapi.yaml | 4 + pkg/analyzer/dns.go | 21 +++- pkg/analyzer/dns_resolver.go | 41 +++++++ pkg/analyzer/dns_resolver_test.go | 111 +++++++++++++++++++ web/src/lib/components/DnsRecordsCard.svelte | 4 + web/src/lib/components/DnssecDisplay.svelte | 56 ++++++++++ 6 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 pkg/analyzer/dns_resolver_test.go create mode 100644 web/src/lib/components/DnssecDisplay.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index 8463007..23a189f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -942,6 +942,10 @@ components: $ref: '#/components/schemas/DMARCRecord' bimi_record: $ref: '#/components/schemas/BIMIRecord' + dnssec_enabled: + type: boolean + description: Whether the From domain has DNSSEC enabled with valid chain of trust + example: true ptr_records: type: array items: diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 3098934..cb1fa68 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -127,6 +127,12 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic // Check BIMI record (for From domain - branding is based on visible sender) results.BimiRecord = d.checkBIMIRecord(fromDomain, "default") + // Check DNSSEC status (for From domain) + dnssecEnabled, err := d.resolver.IsDNSSECEnabled(nil, fromDomain) + if err == nil { + results.DnssecEnabled = &dnssecEnabled + } + return results } @@ -149,6 +155,12 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults { // Check BIMI record with default selector results.BimiRecord = d.checkBIMIRecord(domain, "default") + // Check DNSSEC status + dnssecEnabled, err := d.resolver.IsDNSSECEnabled(nil, domain) + if err == nil { + results.DnssecEnabled = &dnssecEnabled + } + return results } @@ -204,11 +216,16 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string score := 0 + // DNSSEC: 10 points + if results.DnssecEnabled != nil && *results.DnssecEnabled { + score += 10 + } + // PTR and Forward DNS: 20 points score += 20 * d.calculatePTRScore(results, senderIP) / 100 - // MX Records: 20 points (10 for From domain, 10 for Return-Path domain) - score += 20 * d.calculateMXScore(results) / 100 + // MX Records: 10 points (5 for From domain, 5 for Return-Path domain) + score += 10 * d.calculateMXScore(results) / 100 // SPF Records: 20 points score += 20 * d.calculateSPFScore(results) / 100 diff --git a/pkg/analyzer/dns_resolver.go b/pkg/analyzer/dns_resolver.go index 0261f1d..dcbca59 100644 --- a/pkg/analyzer/dns_resolver.go +++ b/pkg/analyzer/dns_resolver.go @@ -48,6 +48,10 @@ type DNSResolver interface { // LookupHost looks up the given hostname using the local resolver. // It returns a slice of that host's addresses (IPv4 and IPv6). LookupHost(ctx context.Context, host string) ([]string, error) + + // IsDNSSECEnabled checks if the given domain has DNSSEC enabled by querying for DNSKEY records. + // Returns true if the domain has DNSSEC configured and the chain of trust is valid. + IsDNSSECEnabled(ctx context.Context, domain string) (bool, error) } // StandardDNSResolver is the default DNS resolver implementation that uses goresolver with DNSSEC validation. @@ -194,3 +198,40 @@ func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]st return allAddrs, nil } + +// IsDNSSECEnabled checks if the given domain has DNSSEC enabled by querying for DNSKEY records. +// It uses DNSSEC validation to ensure the chain of trust is valid. +// Returns true if DNSSEC is properly configured and validated, false otherwise. +func (r *StandardDNSResolver) IsDNSSECEnabled(ctx context.Context, domain string) (bool, error) { + // Ensure the domain ends with a dot for DNS queries + queryName := domain + if !strings.HasSuffix(queryName, ".") { + queryName = queryName + "." + } + + // Query for DNSKEY records with DNSSEC validation + // If this succeeds, it means: + // 1. The domain has DNSKEY records (DNSSEC is configured) + // 2. The DNSSEC chain of trust is valid (validated by StrictNSQuery) + rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeDNSKEY) + if err != nil { + // DNSSEC is not enabled or validation failed + return false, nil + } + + // Check if we got any DNSKEY records + if len(rrs) == 0 { + return false, nil + } + + // Verify we actually have DNSKEY records (not just any RR type) + hasDNSKEY := false + for _, rr := range rrs { + if _, ok := rr.(*dns.DNSKEY); ok { + hasDNSKEY = true + break + } + } + + return hasDNSKEY, nil +} diff --git a/pkg/analyzer/dns_resolver_test.go b/pkg/analyzer/dns_resolver_test.go new file mode 100644 index 0000000..7c9091b --- /dev/null +++ b/pkg/analyzer/dns_resolver_test.go @@ -0,0 +1,111 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "testing" +) + +func TestIsDNSSECEnabled(t *testing.T) { + resolver := NewStandardDNSResolver() + ctx := context.Background() + + tests := []struct { + name string + domain string + expectDNSSEC bool + }{ + { + name: "ietf.org has DNSSEC", + domain: "ietf.org", + expectDNSSEC: true, + }, + { + name: "google.com doesn't have DNSSEC", + domain: "google.com", + expectDNSSEC: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + enabled, err := resolver.IsDNSSECEnabled(ctx, tt.domain) + if err != nil { + t.Errorf("IsDNSSECEnabled() error = %v", err) + return + } + + if enabled != tt.expectDNSSEC { + t.Errorf("IsDNSSECEnabled() for %s = %v, want %v", tt.domain, enabled, tt.expectDNSSEC) + } else { + // Log the result even if we're not validating + if enabled { + t.Logf("%s: DNSSEC is enabled ✅", tt.domain) + } else { + t.Logf("%s: DNSSEC is NOT enabled ⚠️", tt.domain) + } + } + }) + } +} + +func TestIsDNSSECEnabled_NonExistentDomain(t *testing.T) { + resolver := NewStandardDNSResolver() + ctx := context.Background() + + // Test with a domain that doesn't exist + enabled, err := resolver.IsDNSSECEnabled(ctx, "this-domain-definitely-does-not-exist-12345.com") + if err != nil { + // Error is acceptable for non-existent domains + t.Logf("Non-existent domain returned error (expected): %v", err) + return + } + + // If no error, DNSSEC should be disabled + if enabled { + t.Error("IsDNSSECEnabled() for non-existent domain should return false") + } +} + +func TestIsDNSSECEnabled_WithTrailingDot(t *testing.T) { + resolver := NewStandardDNSResolver() + ctx := context.Background() + + // Test that both formats work + domain1 := "cloudflare.com" + domain2 := "cloudflare.com." + + enabled1, err1 := resolver.IsDNSSECEnabled(ctx, domain1) + if err1 != nil { + t.Errorf("IsDNSSECEnabled() without trailing dot error = %v", err1) + } + + enabled2, err2 := resolver.IsDNSSECEnabled(ctx, domain2) + if err2 != nil { + t.Errorf("IsDNSSECEnabled() with trailing dot error = %v", err2) + } + + if enabled1 != enabled2 { + t.Errorf("IsDNSSECEnabled() results differ: without dot = %v, with dot = %v", enabled1, enabled2) + } +} diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 337f7c1..1bf02f7 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -10,6 +10,7 @@ import BimiRecordDisplay from "./BimiRecordDisplay.svelte"; import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte"; import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte"; + import DnssecDisplay from "./DnssecDisplay.svelte"; interface Props { domainAlignment?: DomainAlignment; @@ -150,6 +151,9 @@ + + + {/if} diff --git a/web/src/lib/components/DnssecDisplay.svelte b/web/src/lib/components/DnssecDisplay.svelte new file mode 100644 index 0000000..a795ebd --- /dev/null +++ b/web/src/lib/components/DnssecDisplay.svelte @@ -0,0 +1,56 @@ + + +{#if dnssecEnabled !== undefined} +
+
+
+ + DNSSEC +
+ Security +
+
+

+ DNSSEC (DNS Security Extensions) adds cryptographic signatures to DNS records to verify + their authenticity and integrity. It protects against DNS spoofing and cache poisoning + attacks, ensuring that DNS responses haven't been tampered with. +

+ {#if domain} +
+ Domain: {domain} +
+ {/if} + {#if dnssecIsValid} +
+ + Enabled: DNSSEC is properly configured with a valid chain of trust. + This provides additional security and authenticity for your domain's DNS records. +
+ {:else} +
+ + Not Enabled: DNSSEC is not configured for this domain. While not + required for email delivery, enabling DNSSEC provides additional security by protecting + against DNS-based attacks. Consider enabling DNSSEC through your domain registrar or + DNS provider. +
+ {/if} +
+
+{/if}