Check TLSA lookups for false bogus verdicts too
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
ci / github (pull_request) Successful in 6m46s
github/ci (branch) GitHub Actions

Mail to chuckmckinnon.com sat in the queue for days with "Error fetching
TLSA record: DNSSEC validation failed". Its MX, mail.usefulinsight.com,
is on Cloudflare, and behind Hetzner's resolvers
_25._tcp.mail.usefulinsight.com answers TLSA with a signed CNAME to the
zone apex, which has no TLSA record. That is the second hickory 0.26.3
bug #72 works around: it checks the denial against the name first asked
for, not the CNAME's target, and calls a valid answer bogus.

#72 put MX and address lookups through validated_lookup but left the
TLSA lookup calling hickory directly. It goes through validated_lookup
now: a signed CNAME is followed, the denial at the target validates, and
the result is "no TLSA record", so delivery goes ahead without DANE as
it should. A TLSA record that rechecks as insecure is treated as no
policy, since DANE needs a signed one.

Cloudflare's own resolver answers that name with a compact denial at the
name itself, which hickory already accepts, so the new ignored test
takes a resolver from INBUXA_TEST_DNS_TCP. Run against 185.12.64.2 over
an SSH bridge from host1, hickory alone fails with "DNSSEC validation
failed", as in production, and validated_lookup returns a non-bogus
denial. smtp lib tests pass; check --all-targets is clean.
This commit is contained in:
jcoffey-dev committed 2026-10-04 22:11:39 -07:00
1 parent f59a9de4dc
commit c4a6e4d117
1 file changed
+69 -9
+69 -9
View File
@@ -176,16 +176,23 @@ impl TlsaLookup for Server {
return mail_auth::common::resolver::mock_resolve(key.as_ref()); return mail_auth::common::resolver::mock_resolve(key.as_ref());
} }
let tlsa_lookup = match self // Through `validated_lookup`, like the MX and address lookups: a TLSA
.core // name that is a signed CNAME to a name with no TLSA record (seen at
.smtp // `_25._tcp.mail.usefulinsight.com`, behind Hetzner's resolvers) is
.resolvers // otherwise called bogus, and the message waits on it until it
.dnssec // expires.
.resolver let tlsa_lookup = match validated_lookup(
.tlsa_lookup(Name::from_str_relaxed(key.as_ref())?) &self.core.smtp.resolvers.dnssec.resolver,
self.core.smtp.resolvers.dns.resolver(),
Name::from_str_relaxed(key.as_ref())?,
RecordType::TLSA,
)
.await .await
{ {
Ok(tlsa_lookup) => tlsa_lookup, // A TLSA record proved to sit in an unsigned zone is no DANE
// policy at all.
Ok(validated) if validated.insecure => return Ok(TlsaResult::Missing),
Ok(validated) => validated.lookup,
Err(err) => { Err(err) => {
if let Some(denial) = NegativeAnswer::from_error(&err) { if let Some(denial) = NegativeAnswer::from_error(&err) {
return Ok(if denial.dnssec_status == DnssecStatus::Bogus { return Ok(if denial.dnssec_status == DnssecStatus::Bogus {
@@ -436,7 +443,8 @@ impl TlsaLookup for Server {
// record in the DS reply, and public resolvers often send none. // record in the DS reply, and public resolvers often send none.
// - A signed CNAME to a signed name without the record type queried. Hickory // - A signed CNAME to a signed name without the record type queried. Hickory
// checks the denial of existence against the name first asked for, not the // checks the denial of existence against the name first asked for, not the
// target's, and rejects it. // target's, and rejects it. TLSA lookups hit this too: a TLSA name that is
// a CNAME to the zone apex, with no TLSA there, held mail to it for a week.
// //
// When hickory says bogus, check the answer again with lookups it gets right. // When hickory says bogus, check the answer again with lookups it gets right.
// A signed CNAME is followed and the lookup repeated at its target. Otherwise // A signed CNAME is followed and the lookup repeated at its target. Otherwise
@@ -869,4 +877,56 @@ mod tests {
assert!(validated.insecure); assert!(validated.insecure);
assert!(!validated.lookup.answers().is_empty()); assert!(!validated.lookup.answers().is_empty());
} }
// Needs the network: a TLSA name that is a signed CNAME to the zone apex,
// which has no TLSA record. Cloudflare's resolver answers with a compact
// denial at the name itself; Hetzner's (and others) follow the CNAME, and
// hickory then calls the answer bogus. Point the lookup at a resolver that
// follows it with INBUXA_TEST_DNS_TCP=<ip:port> (TCP), for instance over
// an SSH tunnel to 185.12.64.2:53 from a Hetzner host.
#[tokio::test]
#[ignore]
async fn validated_lookup_follows_signed_cname_for_tlsa() {
use mail_auth::hickory_resolver::{
config::{CLOUDFLARE, ConnectionConfig, NameServerConfig, ResolverConfig, ResolverOpts},
net::runtime::TokioRuntimeProvider,
};
let config = match std::env::var("INBUXA_TEST_DNS_TCP") {
Ok(addr) => {
let addr: std::net::SocketAddr = addr.parse().unwrap();
let mut ns = NameServerConfig::new(addr.ip(), true, vec![ConnectionConfig::tcp()]);
if let Some(c) = ns.connections.first_mut() {
c.port = addr.port();
} }
ResolverConfig::from_parts(None, vec![], vec![ns])
}
Err(_) => ResolverConfig::udp_and_tcp(&CLOUDFLARE),
};
let build = |validate: bool| {
let mut opts = ResolverOpts::default();
opts.validate = validate;
opts.num_concurrent_reqs = 1;
opts.cache_size = 0;
TokioResolver::builder_with_config(config.clone(), TokioRuntimeProvider::default())
.with_options(opts)
.build()
.unwrap()
};
let (dnssec, plain) = (build(true), build(false));
let query = name("_25._tcp.mail.usefulinsight.com.");
let direct = dnssec.lookup(query.clone(), RecordType::TLSA).await;
eprintln!("hickory alone: {:?}", direct.as_ref().err().map(|e| e.to_string()));
let err = match validated_lookup(&dnssec, &plain, query, RecordType::TLSA).await {
Ok(validated) => panic!("expected no TLSA record, got {:?}", validated.lookup.answers()),
Err(err) => err,
};
let denial = NegativeAnswer::from_error(&err).expect("a denial of existence");
assert_eq!(denial.response_code, ResponseCode::NoError);
assert_ne!(denial.dnssec_status, DnssecStatus::Bogus);
}
}