Skip to content
Merged
76 changes: 76 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,55 @@ 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..._

**New in 0.8**
Comment thread
thisisjaymehta marked this conversation as resolved.
Outdated

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.

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, scores are summed together.

#### 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"
}
}
```

**Backwards compatibility:** When `response` blocks are not used, the legacy
`responses` and `score` directives work as before.
58 changes: 57 additions & 1 deletion 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 Down Expand Up @@ -113,6 +119,56 @@ func checkIP(ctx context.Context, resolver dns.Resolver, cfg List, ip net.IP) er
return err
}

// If ResponseRules is configured, use new behavior
if len(cfg.ResponseRules) > 0 {
Comment thread
thisisjaymehta marked this conversation as resolved.
totalScore := 0
var matchedMessages []string
var matchedReasons []string

for _, addr := range addrs {
for _, rule := range cfg.ResponseRules {
for _, respNet := range rule.Networks {
if respNet.Contains(addr.IP) {
totalScore += rule.Score
if rule.Message != "" {
matchedMessages = append(matchedMessages, rule.Message)
}
matchedReasons = append(matchedReasons, addr.IP.String())
break // Only match once per rule
}
}
}
}

if totalScore == 0 {
return nil
}

// Attempt to extract explanation string from TXT records
Comment thread
thisisjaymehta marked this conversation as resolved.
Outdated
txts, err := resolver.LookupTXT(ctx, query)
var reason string
if err == nil && len(txts) > 0 {
reason = strings.Join(txts, "; ")
} else {
reason = strings.Join(matchedReasons, "; ")
}

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

return ListedErr{
Identity: ip.String(),
List: cfg.Zone,
Reason: reason,
Score: totalScore,
Message: message,
}
}

// Legacy behavior: use flat Responses filter
filteredAddrs := make([]net.IPAddr, 0, len(addrs))
addrsLoop:
for _, addr := range addrs {
Expand Down
87 changes: 83 additions & 4 deletions internal/check/dnsbl/dnsbl.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ import (
"golang.org/x/sync/errgroup"
)

type ResponseRule struct {
Networks []net.IPNet
Score int
Message string
}

type List struct {
Zone string

Expand All @@ -49,6 +55,8 @@ type List struct {

ScoreAdj int
Responses []net.IPNet

ResponseRules []ResponseRule
}

var defaultBL = List{
Expand Down Expand Up @@ -126,7 +134,9 @@ func (bl *DNSBL) readListCfg(node config.Node) error {
cfg.Bool("mailfrom", false, defaultBL.EHLO, &listCfg.MAILFROM)
cfg.Int("score", false, false, 1, &listCfg.ScoreAdj)
cfg.StringList("responses", false, false, []string{"127.0.0.1/24"}, &responseNets)
if _, err := cfg.Process(); err != nil {
cfg.AllowUnknown()
Comment thread
thisisjaymehta marked this conversation as resolved.
Outdated
unknown, err := cfg.Process()
if err != nil {
return err
}

Expand All @@ -144,6 +154,19 @@ func (bl *DNSBL) readListCfg(node config.Node) error {
listCfg.Responses = append(listCfg.Responses, *ipNet)
}

// Parse response blocks
for _, child := range unknown {
Comment thread
thisisjaymehta marked this conversation as resolved.
Outdated
if child.Name == "response" {
rule, err := parseResponseRule(child)
if err != nil {
return err
}
listCfg.ResponseRules = append(listCfg.ResponseRules, rule)
} else {
return config.NodeErr(child, "unknown directive: %s", child.Name)
}
}

for _, zone := range append([]string{node.Name}, node.Args...) {
zoneCfg := listCfg
zoneCfg.Zone = zone
Expand Down Expand Up @@ -173,6 +196,44 @@ func (bl *DNSBL) readListCfg(node config.Node) error {
return nil
}

func parseResponseRule(node config.Node) (ResponseRule, error) {
var rule ResponseRule

if len(node.Args) == 0 {
return rule, config.NodeErr(node, "response block requires at least one IP address or CIDR as argument")
}

// Parse IP addresses/CIDRs from arguments
for _, arg := range node.Args {
// If there is no / - it is a plain IP address, append '/32' or '/128'
resp := arg
if !strings.Contains(resp, "/") {
// Check if it's IPv6 to determine the mask
if strings.Contains(resp, ":") {
resp += "/128"
} else {
resp += "/32"
}
}

_, ipNet, err := net.ParseCIDR(resp)
if err != nil {
return rule, config.NodeErr(node, "invalid IP address or CIDR: %s: %v", arg, err)
}
rule.Networks = append(rule.Networks, *ipNet)
}

// Parse directives within the response block
cfg := config.NewMap(nil, node)
cfg.Int("score", false, true, 0, &rule.Score)
cfg.String("message", false, false, "", &rule.Message)
if _, err := cfg.Process(); err != nil {
return rule, err
}

return rule, nil
}

func (bl *DNSBL) testList(listCfg List) {
// Check RFC 5782 Section 5 requirements.

Expand Down Expand Up @@ -298,6 +359,7 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin
score int
listedOn []string
reasons []string
messages []string
)

for _, list := range bl.bls {
Expand All @@ -313,7 +375,18 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin
defer lck.Unlock()
listedOn = append(listedOn, listErr.List)
reasons = append(reasons, listErr.Reason)
score += list.ScoreAdj

// Use score from ListedErr if set (new behavior), otherwise use legacy ScoreAdj
if listErr.Score != 0 {
score += listErr.Score
} else {
score += list.ScoreAdj
}

// Collect custom messages if available
if listErr.Message != "" {
messages = append(messages, listErr.Message)
}
}
return nil
})
Expand All @@ -334,13 +407,19 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin
}
}

// Use custom message if available, otherwise use default
message := "Client identity is listed in the used DNSBL"
if len(messages) > 0 {
message = strings.Join(messages, "; ")
}

if score >= bl.rejectThres {
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 554,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Client identity is listed in the used DNSBL",
Message: message,
Err: err,
CheckName: "dnsbl",
},
Expand All @@ -352,7 +431,7 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin
Reason: &exterrors.SMTPError{
Code: 554,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Client identity is listed in the used DNSBL",
Message: message,
Err: err,
CheckName: "dnsbl",
},
Expand Down
Loading