Skip to content

Commit 5b89cf5

Browse files
committed
🐛(dns_check) make DNS checking more resilient
Instead of exact matching, we now parse DKIM and SPF records
1 parent ae40938 commit 5b89cf5

File tree

2 files changed

+442
-58
lines changed

2 files changed

+442
-58
lines changed

src/backend/core/services/dns/check.py

Lines changed: 153 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44

55
import re
6-
from typing import Dict, List
6+
from typing import Dict, List, Optional, Tuple
77

88
import dns.resolver
99

@@ -17,6 +17,137 @@ def normalize_txt_value(value: str) -> str:
1717
return re.sub(r"\;$", "", re.sub(r"\s*\;\s*", ";", value.strip('"')))
1818

1919

20+
def parse_dkim_tags(value: str) -> Optional[Dict[str, str]]:
21+
"""Parse a DKIM record into a dict of tag=value pairs.
22+
23+
Per RFC 6376, tags are separated by semicolons, with tag=value format.
24+
The v= tag MUST be first and equal to DKIM1.
25+
Returns None if the record is not a valid DKIM record.
26+
"""
27+
parts = [p.strip() for p in value.split(";") if p.strip()]
28+
if not parts:
29+
return None
30+
# v= must be first
31+
first = parts[0]
32+
if not first.startswith("v=") or first.split("=", 1)[1].strip() != "DKIM1":
33+
return None
34+
tags = {}
35+
for part in parts:
36+
if "=" not in part:
37+
continue
38+
key, val = part.split("=", 1)
39+
tags[key.strip()] = val.strip()
40+
return tags
41+
42+
43+
def parse_spf_terms(value: str) -> Optional[Tuple[str, set]]:
44+
"""Parse an SPF record into its qualifier-all and set of other terms.
45+
46+
Per RFC 7208, v=spf1 must be first. Returns (all_mechanism, other_terms)
47+
where all_mechanism is e.g. "-all", "~all", "+all", "?all" or None,
48+
and other_terms is the set of remaining mechanisms/modifiers.
49+
Returns None if not a valid SPF record.
50+
"""
51+
if not value.startswith("v=spf1"):
52+
return None
53+
rest = value[len("v=spf1") :].strip()
54+
terms = rest.split()
55+
all_mechanism = None
56+
other_terms = set()
57+
for term in terms:
58+
if term in ("-all", "~all", "+all", "?all", "all"):
59+
all_mechanism = term
60+
else:
61+
other_terms.add(term)
62+
return (all_mechanism, other_terms)
63+
64+
65+
def _check_dkim_semantic(
66+
expected_value: str, found_values: List[str]
67+
) -> Optional[Dict[str, any]]:
68+
"""Semantic comparison for DKIM records (tag order doesn't matter per RFC 6376)."""
69+
expected_tags = parse_dkim_tags(expected_value)
70+
if not expected_tags:
71+
return None
72+
for found_value in found_values:
73+
found_tags = parse_dkim_tags(found_value)
74+
if not found_tags:
75+
continue
76+
if not all(found_tags.get(k) == v for k, v in expected_tags.items()):
77+
continue
78+
# Check for t=y (testing mode) → insecure
79+
if found_tags.get("t") and "y" in found_tags["t"].split(":"):
80+
return {"status": "insecure", "found": found_values}
81+
return {"status": "correct", "found": found_values}
82+
return None
83+
84+
85+
def _check_spf_semantic(
86+
expected_value: str, found_values: List[str]
87+
) -> Optional[Dict[str, any]]:
88+
"""Semantic comparison for SPF records (term order doesn't matter)."""
89+
expected_spf = parse_spf_terms(expected_value)
90+
if not expected_spf:
91+
return None
92+
expected_all, expected_terms = expected_spf
93+
for found_value in found_values:
94+
found_spf = parse_spf_terms(found_value)
95+
if not found_spf:
96+
continue
97+
found_all, found_terms = found_spf
98+
if not expected_terms <= found_terms:
99+
continue
100+
if expected_all == found_all:
101+
return {"status": "correct", "found": found_values}
102+
if expected_all == "-all" and found_all == "~all":
103+
return {"status": "correct", "found": found_values}
104+
return None
105+
106+
107+
def _resolve_dns_values(record_type, target, query_name):
108+
"""Resolve DNS and return found values and normalized expected value flag."""
109+
if record_type.upper() == "MX":
110+
answers = dns.resolver.resolve(query_name, "MX")
111+
return [f"{answer.preference} {answer.exchange}" for answer in answers]
112+
113+
if record_type.upper() == "TXT":
114+
answers = dns.resolver.resolve(query_name, "TXT")
115+
if target.endswith("._domainkey"):
116+
return [
117+
normalize_txt_value(answer.to_text().strip('"').replace('" "', ""))
118+
for answer in answers
119+
]
120+
return [normalize_txt_value(answer.to_text()) for answer in answers]
121+
122+
answers = dns.resolver.resolve(query_name, record_type)
123+
return [answer.to_text() for answer in answers]
124+
125+
126+
def _check_txt_security(expected_value, found_values):
127+
"""Check for duplicate/insecure SPF and DMARC records. Returns result or None."""
128+
# SPF duplicate and insecure checks
129+
if expected_value.startswith("v=spf1"):
130+
spf_records = [v for v in found_values if v.startswith("v=spf1")]
131+
if len(spf_records) > 1:
132+
return {"status": "duplicate", "found": found_values}
133+
if expected_value.endswith("-all"):
134+
for spf in spf_records:
135+
if spf.endswith("+all") or spf.endswith("?all"):
136+
return {"status": "insecure", "found": found_values}
137+
138+
# DMARC duplicate and insecure checks
139+
if expected_value.startswith("v=DMARC1"):
140+
dmarc_records = [v for v in found_values if v.startswith("v=DMARC1")]
141+
if len(dmarc_records) > 1:
142+
return {"status": "duplicate", "found": found_values}
143+
if "p=none" not in expected_value:
144+
for dmarc in dmarc_records:
145+
if "p=none" in dmarc:
146+
return {"status": "insecure", "found": found_values}
147+
148+
return None
149+
150+
20151
def check_single_record(
21152
maildomain: MailDomain, expected_record: Dict[str, any]
22153
) -> Dict[str, any]:
@@ -35,60 +166,20 @@ def check_single_record(
35166
expected_value = expected_record["value"]
36167

37168
# Build the query name
38-
if target:
39-
query_name = f"{target}.{maildomain.name}"
40-
else:
41-
query_name = maildomain.name
169+
query_name = f"{target}.{maildomain.name}" if target else maildomain.name
42170

43171
try:
44-
# Query DNS records
45-
if record_type.upper() == "MX":
46-
answers = dns.resolver.resolve(query_name, "MX")
47-
found_values = [
48-
f"{answer.preference} {answer.exchange}" for answer in answers
49-
]
50-
elif record_type.upper() == "TXT":
51-
answers = dns.resolver.resolve(query_name, "TXT")
52-
if target.endswith("._domainkey"):
53-
found_values = [
54-
normalize_txt_value(answer.to_text().strip('"').replace('" "', ""))
55-
for answer in answers
56-
]
57-
else:
58-
found_values = [
59-
normalize_txt_value(answer.to_text()) for answer in answers
60-
]
172+
found_values = _resolve_dns_values(record_type, target, query_name)
173+
if record_type.upper() == "TXT":
61174
expected_value = normalize_txt_value(expected_value)
62-
else:
63-
# For other record types, try to resolve them as-is
64-
answers = dns.resolver.resolve(query_name, record_type)
65-
found_values = [answer.to_text() for answer in answers]
66175

67-
# For SPF records, check for duplicates (multiple "v=spf1" TXT records
68-
# is invalid per RFC 7208 and causes delivery issues)
69-
if record_type.upper() == "TXT" and expected_value.startswith("v=spf1"):
70-
spf_records = [v for v in found_values if v.startswith("v=spf1")]
71-
if len(spf_records) > 1:
72-
return {"status": "duplicate", "found": found_values}
73-
# Check for insecure SPF: if we expect -all but found +all or ?all
74-
if expected_value.endswith("-all"):
75-
for spf in spf_records:
76-
if spf.endswith("+all") or spf.endswith("?all"):
77-
return {"status": "insecure", "found": found_values}
78-
79-
# For DMARC records, check for duplicates and insecure policy
80-
if record_type.upper() == "TXT" and expected_value.startswith("v=DMARC1"):
81-
dmarc_records = [v for v in found_values if v.startswith("v=DMARC1")]
82-
if len(dmarc_records) > 1:
83-
return {"status": "duplicate", "found": found_values}
84-
# Check for insecure DMARC: if we expect p=reject or p=quarantine
85-
# but found p=none
86-
if "p=none" not in expected_value:
87-
for dmarc in dmarc_records:
88-
if "p=none" in dmarc:
89-
return {"status": "insecure", "found": found_values}
90-
91-
# Check if expected value is in found values
176+
# Check for duplicate/insecure SPF and DMARC
177+
if record_type.upper() == "TXT":
178+
security_result = _check_txt_security(expected_value, found_values)
179+
if security_result:
180+
return security_result
181+
182+
# Exact match
92183
if expected_value in found_values:
93184
return {"status": "correct", "found": found_values}
94185

@@ -98,25 +189,30 @@ def check_single_record(
98189
if softfail_variant in found_values:
99190
return {"status": "correct", "found": found_values}
100191

192+
# Semantic fallback comparisons for DKIM and SPF
193+
if record_type.upper() == "TXT" and target.endswith("._domainkey"):
194+
result = _check_dkim_semantic(expected_value, found_values)
195+
if result:
196+
return result
197+
198+
if record_type.upper() == "TXT" and expected_value.startswith("v=spf1"):
199+
result = _check_spf_semantic(expected_value, found_values)
200+
if result:
201+
return result
202+
101203
return {"status": "incorrect", "found": found_values}
102204

103205
except dns.resolver.NXDOMAIN:
104-
# Domain doesn't exist
105206
return {"status": "missing", "error": "Domain not found"}
106207
except dns.resolver.NoAnswer:
107-
# No records found for this query
108208
return {"status": "missing", "error": "No records found"}
109209
except dns.resolver.NoNameservers:
110-
# No nameservers found
111210
return {"status": "missing", "error": "No nameservers found"}
112211
except dns.resolver.Timeout:
113-
# DNS query timed out
114212
return {"status": "error", "error": "DNS query timeout"}
115213
except dns.resolver.YXDOMAIN:
116-
# Domain name is too long
117214
return {"status": "error", "error": "Domain name too long"}
118215
except Exception as e: # pylint: disable=broad-exception-caught
119-
# Other DNS errors
120216
return {"status": "error", "error": f"DNS query failed: {str(e)}"}
121217

122218

0 commit comments

Comments
 (0)