Skip to content

Commit 8ccdae7

Browse files
authored
Merge pull request #813 from thisisjaymehta/copilot/improve-dnsbl-response-handling
feat(dnsbl): Add per-response-code scoring with custom messages
2 parents 15b3989 + 0d68e63 commit 8ccdae7

5 files changed

Lines changed: 667 additions & 42 deletions

File tree

docs/reference/checks/dnsbl.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,30 @@ check.dnsbl {
2929
mailfrom yes
3030
score 1
3131
}
32+
33+
# Example with per-response-code scoring (new in 0.8)
34+
zen.spamhaus.org {
35+
client_ipv4 yes
36+
client_ipv6 yes
37+
38+
# SBL - Spamhaus Block List (known spam sources)
39+
response 127.0.0.2 127.0.0.3 {
40+
score 10
41+
message "Listed in Spamhaus SBL. See https://check.spamhaus.org/"
42+
}
43+
44+
# XBL - Exploits Block List (compromised hosts)
45+
response 127.0.0.4 127.0.0.5 127.0.0.6 127.0.0.7 {
46+
score 10
47+
message "Listed in Spamhaus XBL. See https://check.spamhaus.org/"
48+
}
49+
50+
# PBL - Policy Block List (dynamic IPs)
51+
response 127.0.0.10 127.0.0.11 {
52+
score 5
53+
message "Listed in Spamhaus PBL. See https://check.spamhaus.org/"
54+
}
55+
}
3256
}
3357
```
3458

@@ -171,3 +195,64 @@ will be rejected.
171195

172196
It is possible to specify a negative value to make list act like a whitelist
173197
and override results of other blocklists.
198+
199+
**Note:** When using `response` blocks (see below), the score from matching response
200+
rules is used instead of this flat score value.
201+
202+
---
203+
204+
### response _ip..._
205+
206+
Defines per-response-code rules for scoring and custom messages. This is useful
207+
for combined DNSBLs like Spamhaus ZEN that return different codes for different
208+
listing types.
209+
210+
This works for both IP-based lookups (client_ipv4, client_ipv6) and domain-based
211+
lookups (ehlo, mailfrom).
212+
213+
Each `response` block takes one or more IP addresses or CIDR ranges as arguments
214+
and contains the following directives:
215+
216+
#### score _integer_
217+
**Required**
218+
219+
Score to add when this response code is returned. If multiple response codes
220+
are returned by the DNSBL, and they match different rules, the scores from
221+
all matched rules are summed together. Each rule is counted only once, even
222+
if multiple returned IPs match networks within that rule.
223+
224+
#### message _string_
225+
**Optional**
226+
227+
Custom rejection or quarantine message to include when this response code
228+
matches. This message is shown to the client or logged when the threshold
229+
is reached.
230+
231+
**Example:**
232+
233+
```
234+
zen.spamhaus.org {
235+
client_ipv4 yes
236+
237+
# High severity - known spam sources
238+
response 127.0.0.2 127.0.0.3 {
239+
score 10
240+
message "Listed in Spamhaus SBL"
241+
}
242+
243+
# Lower severity - dynamic IPs
244+
response 127.0.0.10 127.0.0.11 {
245+
score 5
246+
message "Listed in Spamhaus PBL"
247+
}
248+
}
249+
```
250+
251+
**Scoring behavior:**
252+
- If DNSBL returns `127.0.0.2` only → Score: 10 (matches first rule)
253+
- If DNSBL returns `127.0.0.11` only → Score: 5 (matches second rule)
254+
- If DNSBL returns both `127.0.0.2` and `127.0.0.11` → Score: 15 (both rules match, scores sum)
255+
- If DNSBL returns both `127.0.0.2` and `127.0.0.3` → Score: 10 (same rule matches, counted once)
256+
257+
**Backwards compatibility:** When `response` blocks are not used, the legacy
258+
`responses` and `score` directives work as before.

internal/check/dnsbl/common.go

Lines changed: 122 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,23 @@ type ListedErr struct {
3232
Identity string
3333
List string
3434
Reason string
35+
Score int
36+
Message string
3537
}
3638

3739
func (le ListedErr) Fields() map[string]interface{} {
40+
msg := "Client identity listed in the used DNSBL"
41+
if le.Message != "" {
42+
msg = le.Message
43+
}
3844
return map[string]interface{}{
3945
"check": "dnsbl",
4046
"list": le.List,
4147
"listed_identity": le.Identity,
4248
"reason": le.Reason,
4349
"smtp_code": 554,
4450
"smtp_enchcode": exterrors.EnhancedCode{5, 7, 0},
45-
"smtp_msg": "Client identity listed in the used DNSBL",
51+
"smtp_msg": msg,
4652
}
4753
}
4854

@@ -66,28 +72,85 @@ func checkDomain(ctx context.Context, resolver dns.Resolver, cfg List, domain st
6672
return nil
6773
}
6874

69-
// Attempt to extract explanation string.
70-
txts, err := resolver.LookupTXT(context.Background(), query)
71-
if err != nil || len(txts) == 0 {
72-
// Not significant, include addresses as reason. Usually they are
73-
// mapped to some predefined 'reasons' by BL.
74-
return ListedErr{
75-
Identity: domain,
76-
List: cfg.Zone,
77-
Reason: strings.Join(addrs, "; "),
75+
var score int
76+
var customMessage string
77+
var filteredAddrs []string
78+
79+
// If ResponseRules is configured, use new behavior
80+
if len(cfg.ResponseRules) > 0 {
81+
// Convert string addresses to IPAddr for matching
82+
ipAddrs := make([]net.IPAddr, 0, len(addrs))
83+
for _, addr := range addrs {
84+
if ip := net.ParseIP(addr); ip != nil {
85+
ipAddrs = append(ipAddrs, net.IPAddr{IP: ip})
86+
}
7887
}
88+
89+
matchedScore, matchedMessages, matchedReasons, matched := matchResponseRules(ipAddrs, cfg.ResponseRules)
90+
if !matched {
91+
return nil
92+
}
93+
score = matchedScore
94+
95+
// Use first matched message if available
96+
if len(matchedMessages) > 0 {
97+
customMessage = matchedMessages[0]
98+
}
99+
100+
filteredAddrs = matchedReasons
101+
} else {
102+
// Legacy behavior: accept all addresses
103+
filteredAddrs = addrs
79104
}
80105

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

84117
return ListedErr{
85118
Identity: domain,
86119
List: cfg.Zone,
87-
Reason: strings.Join(txts, "; "),
120+
Reason: reason,
121+
Score: score,
122+
Message: customMessage,
88123
}
89124
}
90125

126+
func matchResponseRules(addrs []net.IPAddr, rules []ResponseRule) (score int, messages []string, reasons []string, matched bool) {
127+
// Track which rules have been matched to avoid counting the same rule multiple times
128+
matchedRules := make(map[int]bool)
129+
130+
for _, addr := range addrs {
131+
for ruleIdx, rule := range rules {
132+
// Skip if this rule has already been matched
133+
if matchedRules[ruleIdx] {
134+
continue
135+
}
136+
137+
for _, respNet := range rule.Networks {
138+
if respNet.Contains(addr.IP) {
139+
score += rule.Score
140+
if rule.Message != "" {
141+
messages = append(messages, rule.Message)
142+
}
143+
reasons = append(reasons, addr.IP.String())
144+
matchedRules[ruleIdx] = true
145+
matched = true
146+
break // Move to next rule
147+
}
148+
}
149+
}
150+
}
151+
return
152+
}
153+
91154
func checkIP(ctx context.Context, resolver dns.Resolver, cfg List, ip net.IP) error {
92155
ipv6 := true
93156
if ipv4 := ip.To4(); ipv4 != nil {
@@ -113,52 +176,72 @@ func checkIP(ctx context.Context, resolver dns.Resolver, cfg List, ip net.IP) er
113176
return err
114177
}
115178

116-
filteredAddrs := make([]net.IPAddr, 0, len(addrs))
117-
addrsLoop:
118-
for _, addr := range addrs {
119-
// No responses whitelist configured - permit all.
120-
if len(cfg.Responses) == 0 {
121-
filteredAddrs = append(filteredAddrs, addr)
122-
continue
179+
var filteredAddrs []net.IPAddr
180+
var score int
181+
var customMessage string
182+
183+
// If ResponseRules is configured, use new behavior
184+
if len(cfg.ResponseRules) > 0 {
185+
matchedScore, matchedMessages, matchedReasons, matched := matchResponseRules(addrs, cfg.ResponseRules)
186+
if !matched {
187+
return nil
123188
}
189+
score = matchedScore
124190

125-
for _, respNet := range cfg.Responses {
126-
if respNet.Contains(addr.IP) {
191+
// Use first matched message if available
192+
if len(matchedMessages) > 0 {
193+
customMessage = matchedMessages[0]
194+
}
195+
196+
// Build filteredAddrs from matched reasons for TXT lookup fallback
197+
for _, reason := range matchedReasons {
198+
filteredAddrs = append(filteredAddrs, net.IPAddr{IP: net.ParseIP(reason)})
199+
}
200+
} else {
201+
// Legacy behavior: use flat Responses filter
202+
filteredAddrs = make([]net.IPAddr, 0, len(addrs))
203+
addrsLoop:
204+
for _, addr := range addrs {
205+
// No responses whitelist configured - permit all.
206+
if len(cfg.Responses) == 0 {
127207
filteredAddrs = append(filteredAddrs, addr)
128-
continue addrsLoop
208+
continue
209+
}
210+
211+
for _, respNet := range cfg.Responses {
212+
if respNet.Contains(addr.IP) {
213+
filteredAddrs = append(filteredAddrs, addr)
214+
continue addrsLoop
215+
}
129216
}
130217
}
131-
}
132218

133-
if len(filteredAddrs) == 0 {
134-
return nil
219+
if len(filteredAddrs) == 0 {
220+
return nil
221+
}
135222
}
136223

137-
// Attempt to extract explanation string.
224+
// Attempt to extract explanation string from TXT records (shared by both paths)
138225
txts, err := resolver.LookupTXT(ctx, query)
139-
if err != nil || len(txts) == 0 {
226+
var reason string
227+
if err == nil && len(txts) > 0 {
228+
reason = strings.Join(txts, "; ")
229+
} else {
140230
// Not significant, include addresses as reason. Usually they are
141231
// mapped to some predefined 'reasons' by BL.
142-
143232
reasonParts := make([]string, 0, len(filteredAddrs))
144233
for _, addr := range filteredAddrs {
145234
reasonParts = append(reasonParts, addr.IP.String())
146235
}
147-
148-
return ListedErr{
149-
Identity: ip.String(),
150-
List: cfg.Zone,
151-
Reason: strings.Join(reasonParts, "; "),
152-
}
236+
reason = strings.Join(reasonParts, "; ")
153237
}
154238

155-
// Some BLs provide multiple reasons (meta-BLs such as Spamhaus Zen) so
156-
// don't mangle them by joining with "", instead join with "; ".
157-
158239
return ListedErr{
159240
Identity: ip.String(),
160241
List: cfg.Zone,
161-
Reason: strings.Join(txts, "; "),
242+
Reason: reason,
243+
Score: score,
244+
Message: customMessage,
162245
}
163246
}
164247

0 commit comments

Comments
 (0)