Skip to content

Commit 7f29fe7

Browse files
committed
Implement FCrDNS and other DNS features
1 parent b11d813 commit 7f29fe7

16 files changed

Lines changed: 1497 additions & 398 deletions

File tree

.github/actions/spelling/expect.txt

Lines changed: 401 additions & 392 deletions
Large diffs are not rendered by default.

data/clients/telegram-preview.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- name: telegrambot
2+
action: ALLOW
3+
expression:
4+
all:
5+
- userAgent.matches("TelegramBot")
6+
- verifyFCrDNS(remoteAddress, "ptr\\.telegram\\.org$")

data/clients/vk-preview.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- name: vkbot
2+
action: ALLOW
3+
expression:
4+
all:
5+
- userAgent.matches("vkShare[^+]+\\+http\\://vk\\.com/dev/Share")
6+
- verifyFCrDNS(remoteAddress, "^snipster\\d+\\.go\\.mail\\.ru$")

data/crawlers/_allow-good.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
- import: (data)/crawlers/marginalia.yaml
99
- import: (data)/crawlers/mojeekbot.yaml
1010
- import: (data)/crawlers/commoncrawl.yaml
11+
- import: (data)/crawlers/yandexbot.yaml

data/crawlers/yandexbot.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- name: yandexbot
2+
action: ALLOW
3+
expression:
4+
all:
5+
- userAgent.matches("\\+http\\://yandex\\.com/bots")
6+
- verifyFCrDNS(remoteAddress, "^.*\\.yandex\\.(ru|com|net)$")

data/meta/messengers-preview.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- import: (data)/clients/telegram-preview.yaml
2+
- import: (data)/clients/vk-preview.yaml

docs/docs/CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,30 @@ logging:
4343
```
4444
4545
Additionally, information about [how Anubis uses each logging level](./admin/policies.mdx#log-levels) has been added to the documentation.
46+
### DNS Features
47+
48+
- CEL expressions for:
49+
- FCrDNS checks
50+
- Forward DNS queries
51+
- Reverse DNS queries
52+
- `arpaReverseIP` to transform IPv4/6 addresses into ARPA reverse IP notation.
53+
- `regexSafe` to escape regex special characters (useful for including `remoteAddress` or headers in regular expressions).
54+
- DNS cache and other optimizations to minimize unnecessary DNS queries.
55+
56+
The DNS cache TTL can be changed in the bots config like this:
57+
```yaml
58+
dns_ttl:
59+
forward: 600
60+
reverse: 600
61+
```
62+
The default value for both forward and reverse queries is 300 seconds.
63+
64+
The `verifyFCrDNS` CEL function has two overloads:
65+
- `(addr)`
66+
Simply verifies that the remote side has PTR records pointing to the target address.
67+
- `(addr, ptrPattern)`
68+
Verifies that the remote side refers to a specific domain and that this domain points to the target IP.
69+
4670

4771
## v1.23.1: Lyse Hext - Echo 1
4872

internal/dns.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package internal
2+
3+
import (
4+
"encoding/hex"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
"net"
9+
"regexp"
10+
"slices"
11+
"strings"
12+
"time"
13+
)
14+
15+
var (
16+
DNSLookupAddr = net.LookupAddr
17+
DNSLookupHost = net.LookupHost
18+
)
19+
20+
type DnsResult struct {
21+
entries []string
22+
expiration time.Time
23+
}
24+
25+
type Dns struct {
26+
forwardCache map[string]DnsResult
27+
reverseCache map[string]DnsResult
28+
forwardTTL int
29+
reverseTTL int
30+
}
31+
32+
func NewDNS(forwardTTL int, reverseTTL int) *Dns {
33+
return &Dns{
34+
forwardCache: map[string]DnsResult{},
35+
reverseCache: map[string]DnsResult{},
36+
forwardTTL: forwardTTL,
37+
reverseTTL: reverseTTL,
38+
}
39+
}
40+
41+
func (d *Dns) getCachedForward(host string) ([]string, bool) {
42+
if cached, ok := d.forwardCache[host]; ok {
43+
if time.Now().Before(cached.expiration) {
44+
return cached.entries, true
45+
}
46+
delete(d.forwardCache, host)
47+
}
48+
return nil, false
49+
}
50+
51+
func (d *Dns) getCachedReverse(addr string) ([]string, bool) {
52+
if cached, ok := d.reverseCache[addr]; ok {
53+
if time.Now().Before(cached.expiration) {
54+
return cached.entries, true
55+
}
56+
delete(d.forwardCache, addr)
57+
}
58+
return nil, false
59+
}
60+
61+
func (d *Dns) forwardCachePut(host string, entries []string) {
62+
d.forwardCache[host] = DnsResult{
63+
entries: entries,
64+
expiration: time.Now().Add(time.Duration(d.forwardTTL) * time.Second),
65+
}
66+
}
67+
68+
func (d *Dns) reverseCachePut(addr string, entries []string) {
69+
d.reverseCache[addr] = DnsResult{
70+
entries: entries,
71+
expiration: time.Now().Add(time.Duration(d.reverseTTL) * time.Second),
72+
}
73+
}
74+
75+
// lookupAddrAndTrim performs a reverse DNS lookup and trims the trailing dot from the results.
76+
func (d *Dns) lookupAddrAndTrim(addr string) ([]string, error) {
77+
names, err := DNSLookupAddr(addr)
78+
if err != nil {
79+
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
80+
slog.Debug("lookupAddrAndTrim: no PTR record found", "addr", addr)
81+
return []string{}, nil
82+
}
83+
return nil, err
84+
}
85+
86+
for i, name := range names {
87+
names[i] = strings.TrimSuffix(name, ".")
88+
}
89+
90+
return names, nil
91+
}
92+
93+
// verifyFCrDNSInternal performs the second half of the FCrDNS check, using a
94+
// pre-fetched list of names to perform the forward lookups.
95+
func (d *Dns) verifyFCrDNSInternal(addr string, names []string) bool {
96+
for _, name := range names {
97+
if cached, ok := d.getCachedForward(name); ok {
98+
slog.Debug("verifyFCrDNS: forward lookup cache hit", "name", name)
99+
if slices.Contains(cached, addr) {
100+
slog.Info("verifyFCrDNS: forward lookup confirmed original IP", "name", name, "addr", addr)
101+
return true
102+
}
103+
continue
104+
}
105+
106+
slog.Debug("verifyFCrDNS: forward lookup cache miss", "name", name)
107+
ips, err := DNSLookupHost(name)
108+
if err != nil {
109+
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
110+
slog.Debug("verifyFCrDNS: no A/AAAA record found", "name", name)
111+
continue
112+
}
113+
slog.Error("verifyFCrDNS: forward lookup failed", "name", name, "err", err)
114+
continue
115+
}
116+
d.forwardCachePut(name, ips)
117+
slog.Debug("verifyFCrDNS: forward lookup found", "name", name, "ips", ips)
118+
119+
if slices.Contains(ips, addr) {
120+
slog.Info("verifyFCrDNS: forward lookup confirmed original IP", "name", name, "addr", addr)
121+
return true
122+
}
123+
}
124+
125+
slog.Info("verifyFCrDNS: could not confirm original IP in forward lookups", "addr", addr)
126+
return false
127+
}
128+
129+
// ReverseDNS performs a reverse DNS lookup for the given IP address.
130+
func (d *Dns) ReverseDNS(addr string) ([]string, error) {
131+
if cached, ok := d.getCachedReverse(addr); ok {
132+
slog.Debug("reverseDNS lookup found in reverse cache", "addr", addr)
133+
return cached, nil
134+
}
135+
136+
slog.Debug("performing reverseDNS lookup", "addr", addr)
137+
names, err := d.lookupAddrAndTrim(addr)
138+
if err != nil {
139+
slog.Error("reverseDNS lookup failed", "addr", addr, "err", err)
140+
return []string{}, err
141+
}
142+
143+
d.reverseCachePut(addr, names)
144+
slog.Debug("reverseDNS lookup found", "addr", addr, "names", names)
145+
return names, nil
146+
}
147+
148+
// LookupHost performs a forward DNS lookup for the given hostname.
149+
func (d *Dns) LookupHost(host string) ([]string, error) {
150+
if cached, ok := d.getCachedForward(host); ok {
151+
slog.Debug("lookupHost found in forward cache", "host", host)
152+
return cached, nil
153+
}
154+
155+
slog.Debug("performing lookupHost", "host", host)
156+
addrs, err := DNSLookupHost(host)
157+
if err != nil {
158+
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
159+
slog.Debug("lookupHost: no A/AAAA record found", "host", host)
160+
return []string{}, nil
161+
}
162+
slog.Error("lookupHost failed", "host", host, "err", err)
163+
return []string{}, err
164+
}
165+
166+
d.forwardCachePut(host, addrs)
167+
slog.Debug("lookupHost found", "host", host, "addrs", addrs)
168+
return addrs, nil
169+
}
170+
171+
// VerifyFCrDNS performs a forward-confirmed reverse DNS (FCrDNS) lookup for the given IP address,
172+
// optionally matching against a provided pattern.
173+
func (d *Dns) VerifyFCrDNS(addr string, pattern *string) bool {
174+
var names []string
175+
if cached, ok := d.getCachedReverse(addr); ok {
176+
slog.Debug("verifyFCrDNS: reverse lookup cache hit", "addr", addr)
177+
names = cached
178+
} else {
179+
slog.Debug("verifyFCrDNS: reverse lookup cache miss", "addr", addr)
180+
var err error
181+
names, err = d.lookupAddrAndTrim(addr)
182+
if err != nil {
183+
slog.Error("verifyFCrDNS: reverse lookup failed", "addr", addr, "err", err)
184+
return false
185+
}
186+
d.reverseCachePut(addr, names)
187+
}
188+
slog.Debug("verifyFCrDNS: reverse lookup found", "addr", addr, "names", names)
189+
190+
// If a pattern is provided, check for a match.
191+
if pattern != nil {
192+
anyNameMatched := false
193+
for _, name := range names {
194+
matched, err := regexp.MatchString(*pattern, name)
195+
if err != nil {
196+
slog.Error("verifyFCrDNS: invalid regex pattern", "err", err)
197+
return false // Invalid pattern is a failure.
198+
}
199+
if matched {
200+
anyNameMatched = true
201+
break
202+
}
203+
}
204+
205+
if !anyNameMatched {
206+
slog.Debug("verifyFCrDNS: reverse lookup did not match pattern", "addr", addr, "pattern", *pattern)
207+
return false
208+
}
209+
slog.Debug("verifyFCrDNS: reverse lookup matched pattern, proceeding with forward check", "addr", addr, "pattern", *pattern)
210+
}
211+
212+
// If we're here, either there was no pattern, or the pattern matched.
213+
// Proceed with the forward lookup confirmation.
214+
return d.verifyFCrDNSInternal(addr, names)
215+
}
216+
217+
// ArpaReverseIP performs translation from ip v4/v6 to arpa reverse notation
218+
func (d *Dns) ArpaReverseIP(addr string) (string, error) {
219+
ip := net.ParseIP(addr)
220+
if ip == nil {
221+
return addr, errors.New("invalid IP address")
222+
}
223+
224+
if ipv4 := ip.To4(); ipv4 != nil {
225+
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]), nil
226+
}
227+
228+
ipv6 := ip.To16()
229+
if ipv6 == nil {
230+
return addr, errors.New("invalid IPv6 address")
231+
}
232+
233+
hexBytes := make([]byte, hex.EncodedLen(len(ipv6)))
234+
hex.Encode(hexBytes, ipv6)
235+
236+
var sb strings.Builder
237+
sb.Grow(len(hexBytes)*2 - 1)
238+
239+
for i := len(hexBytes) - 1; i >= 0; i-- {
240+
sb.WriteByte(hexBytes[i])
241+
if i > 0 {
242+
sb.WriteByte('.')
243+
}
244+
}
245+
return sb.String(), nil
246+
}

0 commit comments

Comments
 (0)