DNSBL: only the first code of a multi-code answer is evaluated, and not-listed results are cached for 24 hours regardless of the zone's negative TTL

Issue Description

is_dnsbl() has two behaviours that lose DNSBL listings.

  1. Only the first A record of a positive answer is used. A DNSBL can return several codes for one address: zen.spamhaus.org returns 127.0.0.2, 127.0.0.4 and 127.0.0.10 for its test entry 2.0.0.127. Which code is selected depends on the record order when Stalwart performs an uncached lookup, and Stalwart then caches that single code until the answer expires. The same address can therefore produce different tags across fresh lookups, while the other returned codes are never evaluated. Spamhaus documents that “Querying code is expected to go through all the A records provided by a positive reply”: Blocklists via DNS Query Requests — Spamhaus Technology Documentation 2.0 documentation

  2. A not-listed answer is cached for a fixed 24 hours, regardless of the zone’s negative TTL (RFC 2308 §5). The negative TTL of zen.spamhaus.org is 10 seconds. An address listed after its first lookup is treated as not listed for up to a day.

Code (v0.16.24):

  • crates/spam-filter/src/modules/dnsbl.rs L139-147: result.entry.iter().copied().next() keeps the first record only.
  • crates/spam-filter/src/modules/dnsbl.rs L157-171: RecordNotFound is cached with Duration::from_secs(86400).

Expected Behavior

Every code returned for a listed address is evaluated, and a not-listed result is cached no longer than the zone’s negative TTL.

Actual Behavior

Only the first code in the answer is evaluated, so an address listed in several lists is scored for one of them, chosen by record order. A not-listed result is cached for 24 hours.

Reproduction Steps

  1. Through a resolver that rotates record order (Unbound with its default rrset-roundrobin yes): dig 2.0.0.127.zen.spamhaus.org A returns three records, and dig 1.0.0.127.zen.spamhaus.org A returns NXDOMAIN with an SOA minimum of 10.
  2. On a test instance whose DNS server answers each listed address with three codes, mapped to different tags, in rotating order: 12 lookups of distinct addresses each produced a single tag, each code 4 times.
  3. The not-listed result is cached for 24 hours (dnsbl.rs L169), so an address listed after its first lookup is reported not listed until that entry expires.

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

One option could be to cache and evaluate the complete positive answer, and to cache a not-listed result for the remaining negative TTL supplied by the DNS answer.

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This matches a real bug confirmed directly in the current source (crates/spam-filter/src/modules/dnsbl.rs). The code indeed only keeps the first returned A record (result.entry.iter().copied().next()) when building the cached IpResolver, and a RecordNotFound response is always cached with a fixed Duration::from_secs(86400) rather than using the negative TTL supplied by the DNS answer (result.expires is only used for the positive-answer cache, not the negative one).

This is a legitimate behavioral issue in the current codebase, not something documented as intended behavior anywhere in the docs. I don’t have a way to confirm this report has already been triaged or fixed beyond what’s in the mirrored source, so I can’t give you a resolution timeline, but the report accurately reflects what the code does today.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v0.16.25 (will be released today).