Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs/reference/checks/dnsbl.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@ check.dnsbl {
mailfrom yes
score 1
}

# Example with per-response-code scoring (new in 0.8)
zen.spamhaus.org {
client_ipv4 yes
client_ipv6 yes

# SBL - Spamhaus Block List (known spam sources)
response 127.0.0.2 127.0.0.3 {
score 10
message "Listed in Spamhaus SBL. See https://check.spamhaus.org/"
}

# XBL - Exploits Block List (compromised hosts)
response 127.0.0.4 127.0.0.5 127.0.0.6 127.0.0.7 {
score 10
message "Listed in Spamhaus XBL. See https://check.spamhaus.org/"
}

# PBL - Policy Block List (dynamic IPs)
response 127.0.0.10 127.0.0.11 {
score 5
message "Listed in Spamhaus PBL. See https://check.spamhaus.org/"
}
}
}
```

Expand Down Expand Up @@ -171,3 +195,64 @@ will be rejected.

It is possible to specify a negative value to make list act like a whitelist
and override results of other blocklists.

**Note:** When using `response` blocks (see below), the score from matching response
rules is used instead of this flat score value.

---

### response _ip..._

Defines per-response-code rules for scoring and custom messages. This is useful
for combined DNSBLs like Spamhaus ZEN that return different codes for different
listing types.

This works for both IP-based lookups (client_ipv4, client_ipv6) and domain-based
lookups (ehlo, mailfrom).

Each `response` block takes one or more IP addresses or CIDR ranges as arguments
and contains the following directives:

#### score _integer_
**Required**

Score to add when this response code is returned. If multiple response codes
are returned by the DNSBL, and they match different rules, the scores from
all matched rules are summed together. Each rule is counted only once, even
if multiple returned IPs match networks within that rule.

#### message _string_
**Optional**

Custom rejection or quarantine message to include when this response code
matches. This message is shown to the client or logged when the threshold
is reached.

**Example:**

```
zen.spamhaus.org {
client_ipv4 yes

# High severity - known spam sources
response 127.0.0.2 127.0.0.3 {
score 10
message "Listed in Spamhaus SBL"
}

# Lower severity - dynamic IPs
response 127.0.0.10 127.0.0.11 {
score 5
message "Listed in Spamhaus PBL"
}
}
```

**Scoring behavior:**
- If DNSBL returns `127.0.0.2` only → Score: 10 (matches first rule)
- If DNSBL returns `127.0.0.11` only → Score: 5 (matches second rule)
- If DNSBL returns both `127.0.0.2` and `127.0.0.11` → Score: 15 (both rules match, scores sum)
- If DNSBL returns both `127.0.0.2` and `127.0.0.3` → Score: 10 (same rule matches, counted once)

**Backwards compatibility:** When `response` blocks are not used, the legacy
`responses` and `score` directives work as before.
163 changes: 123 additions & 40 deletions internal/check/dnsbl/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,23 @@ type ListedErr struct {
Identity string
List string
Reason string
Score int
Message string
}

func (le ListedErr) Fields() map[string]interface{} {
msg := "Client identity listed in the used DNSBL"
if le.Message != "" {
msg = le.Message
}
return map[string]interface{}{
"check": "dnsbl",
"list": le.List,
"listed_identity": le.Identity,
"reason": le.Reason,
"smtp_code": 554,
"smtp_enchcode": exterrors.EnhancedCode{5, 7, 0},
"smtp_msg": "Client identity listed in the used DNSBL",
"smtp_msg": msg,
}
}

Expand All @@ -66,26 +72,83 @@ func checkDomain(ctx context.Context, resolver dns.Resolver, cfg List, domain st
return nil
}

// Attempt to extract explanation string.
txts, err := resolver.LookupTXT(context.Background(), query)
if err != nil || len(txts) == 0 {
// Not significant, include addresses as reason. Usually they are
// mapped to some predefined 'reasons' by BL.
return ListedErr{
Identity: domain,
List: cfg.Zone,
Reason: strings.Join(addrs, "; "),
var score int
var customMessage string
var filteredAddrs []string

// If ResponseRules is configured, use new behavior
if len(cfg.ResponseRules) > 0 {
// Convert string addresses to IPAddr for matching
ipAddrs := make([]net.IPAddr, 0, len(addrs))
for _, addr := range addrs {
if ip := net.ParseIP(addr); ip != nil {
ipAddrs = append(ipAddrs, net.IPAddr{IP: ip})
}
}

matchedScore, matchedMessages, matchedReasons, matched := matchResponseRules(ipAddrs, cfg.ResponseRules)
if !matched {
return nil
}
score = matchedScore

// Use first matched message if available
if len(matchedMessages) > 0 {
customMessage = matchedMessages[0]
}

filteredAddrs = matchedReasons
} else {
// Legacy behavior: accept all addresses
filteredAddrs = addrs
}

// Some BLs provide multiple reasons (meta-BLs such as Spamhaus Zen) so
// don't mangle them by joining with "", instead join with "; ".
// Attempt to extract explanation string from TXT records (shared by both paths)
txts, err := resolver.LookupTXT(ctx, query)
var reason string
if err == nil && len(txts) > 0 {
reason = strings.Join(txts, "; ")
} else {
// Not significant, include addresses as reason. Usually they are
// mapped to some predefined 'reasons' by BL.
reason = strings.Join(filteredAddrs, "; ")
}

return ListedErr{
Identity: domain,
List: cfg.Zone,
Reason: strings.Join(txts, "; "),
Reason: reason,
Score: score,
Message: customMessage,
}
}

func matchResponseRules(addrs []net.IPAddr, rules []ResponseRule) (score int, messages []string, reasons []string, matched bool) {
// Track which rules have been matched to avoid counting the same rule multiple times
matchedRules := make(map[int]bool)

for _, addr := range addrs {
for ruleIdx, rule := range rules {
// Skip if this rule has already been matched
if matchedRules[ruleIdx] {
continue
}

for _, respNet := range rule.Networks {
if respNet.Contains(addr.IP) {
score += rule.Score
if rule.Message != "" {
messages = append(messages, rule.Message)
}
reasons = append(reasons, addr.IP.String())
matchedRules[ruleIdx] = true
matched = true
break // Move to next rule
}
}
}
}
return
}

func checkIP(ctx context.Context, resolver dns.Resolver, cfg List, ip net.IP) error {
Expand Down Expand Up @@ -113,52 +176,72 @@ func checkIP(ctx context.Context, resolver dns.Resolver, cfg List, ip net.IP) er
return err
}

filteredAddrs := make([]net.IPAddr, 0, len(addrs))
addrsLoop:
for _, addr := range addrs {
// No responses whitelist configured - permit all.
if len(cfg.Responses) == 0 {
filteredAddrs = append(filteredAddrs, addr)
continue
}
var filteredAddrs []net.IPAddr
var score int
var customMessage string

for _, respNet := range cfg.Responses {
if respNet.Contains(addr.IP) {
// If ResponseRules is configured, use new behavior
if len(cfg.ResponseRules) > 0 {
matchedScore, matchedMessages, matchedReasons, matched := matchResponseRules(addrs, cfg.ResponseRules)
if !matched {
return nil
}
score = matchedScore

// Use first matched message if available
if len(matchedMessages) > 0 {
customMessage = matchedMessages[0]
}

// Build filteredAddrs from matched reasons for TXT lookup fallback
for _, reason := range matchedReasons {
filteredAddrs = append(filteredAddrs, net.IPAddr{IP: net.ParseIP(reason)})
}
} else {
// Legacy behavior: use flat Responses filter
filteredAddrs = make([]net.IPAddr, 0, len(addrs))
addrsLoop:
for _, addr := range addrs {
// No responses whitelist configured - permit all.
if len(cfg.Responses) == 0 {
filteredAddrs = append(filteredAddrs, addr)
continue addrsLoop
continue
}

for _, respNet := range cfg.Responses {
if respNet.Contains(addr.IP) {
filteredAddrs = append(filteredAddrs, addr)
continue addrsLoop
}
}
}
}

if len(filteredAddrs) == 0 {
return nil
if len(filteredAddrs) == 0 {
return nil
}
}

// Attempt to extract explanation string.
// Attempt to extract explanation string from TXT records (shared by both paths)
txts, err := resolver.LookupTXT(ctx, query)
if err != nil || len(txts) == 0 {
var reason string
if err == nil && len(txts) > 0 {
reason = strings.Join(txts, "; ")
} else {
// Not significant, include addresses as reason. Usually they are
// mapped to some predefined 'reasons' by BL.

reasonParts := make([]string, 0, len(filteredAddrs))
for _, addr := range filteredAddrs {
reasonParts = append(reasonParts, addr.IP.String())
}

return ListedErr{
Identity: ip.String(),
List: cfg.Zone,
Reason: strings.Join(reasonParts, "; "),
}
reason = strings.Join(reasonParts, "; ")
}

// Some BLs provide multiple reasons (meta-BLs such as Spamhaus Zen) so
// don't mangle them by joining with "", instead join with "; ".

return ListedErr{
Identity: ip.String(),
List: cfg.Zone,
Reason: strings.Join(txts, "; "),
Reason: reason,
Score: score,
Message: customMessage,
}
}

Expand Down
Loading