Skip to content

Commit 4da7966

Browse files
authored
Fix first-request good bot DNS verification (#90)
1 parent 6501d9f commit 4da7966

5 files changed

Lines changed: 105 additions & 29 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ services:
9090
--providers.docker=true
9191
--providers.docker.network=default
9292
--experimental.plugins.captcha-protect.modulename=github.com/libops/captcha-protect
93-
--experimental.plugins.captcha-protect.version=v1.12.3
93+
--experimental.plugins.captcha-protect.version=v1.12.4
9494
volumes:
9595
- /var/run/docker.sock:/var/run/docker.sock:z
9696
- /CHANGEME/TO/A/HOST/PATH/FOR/STATE/FILE:/tmp/state.json:rw

internal/helper/ip.go

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
package helper
22

33
import (
4+
"context"
45
"net"
56
"strings"
67
)
78

89
var (
9-
lookupAddrFunc = net.LookupAddr
10-
lookupIPFunc = net.LookupIP
10+
lookupAddrFunc = net.DefaultResolver.LookupAddr
11+
lookupIPFunc = net.DefaultResolver.LookupIP
1112
)
1213

1314
func IsIpExcluded(clientIP string, exemptIps []*net.IPNet) bool {
@@ -29,39 +30,48 @@ func ParseCIDR(cidr string) (*net.IPNet, error) {
2930
return ipNet, nil
3031
}
3132

32-
func IsIpGoodBot(clientIP string, goodBots []string) bool {
33+
func IsIpGoodBot(ctx context.Context, clientIP string, goodBots []string) bool {
3334
if len(goodBots) == 0 {
3435
return false
3536
}
36-
37-
// lookup the hostname for a given IP
38-
hostname, err := lookupAddrFunc(clientIP)
39-
if err != nil || len(hostname) == 0 {
37+
ip := net.ParseIP(clientIP)
38+
if ip == nil {
4039
return false
4140
}
4241

43-
// then nslookup that hostname to avoid spoofing
44-
resolvedIP, err := lookupIPFunc(hostname[0])
45-
if err != nil || len(resolvedIP) == 0 || resolvedIP[0].String() != clientIP {
42+
// lookup the hostname for a given IP
43+
hostnames, err := lookupAddrFunc(ctx, clientIP)
44+
if err != nil || len(hostnames) == 0 {
4645
return false
4746
}
4847

49-
// get the sld
50-
// will be like 194.114.135.34.bc.googleusercontent.com.
51-
// notice the trailing period
52-
parts := strings.Split(hostname[0], ".")
53-
l := len(parts)
54-
if l < 3 {
55-
return false
48+
for _, hostname := range hostnames {
49+
hostname = strings.ToLower(strings.TrimSuffix(hostname, "."))
50+
if !matchesGoodBotDomain(hostname, goodBots) {
51+
continue
52+
}
53+
54+
// Resolve the PTR hostname forward to prevent forged reverse DNS records.
55+
resolvedIPs, err := lookupIPFunc(ctx, "ip", hostname)
56+
if err != nil {
57+
continue
58+
}
59+
for _, resolvedIP := range resolvedIPs {
60+
if resolvedIP.Equal(ip) {
61+
return true
62+
}
63+
}
5664
}
57-
tld := parts[l-2]
58-
domain := parts[l-3] + "." + tld
5965

66+
return false
67+
}
68+
69+
func matchesGoodBotDomain(hostname string, goodBots []string) bool {
6070
for _, bot := range goodBots {
61-
if domain == bot {
71+
bot = strings.ToLower(strings.Trim(strings.TrimSpace(bot), "."))
72+
if bot != "" && (hostname == bot || strings.HasSuffix(hostname, "."+bot)) {
6273
return true
6374
}
6475
}
65-
6676
return false
6777
}

internal/helper/ip_test.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package helper
22

33
import (
4+
"context"
45
"errors"
56
"net"
7+
"strings"
68
"testing"
79
)
810

@@ -46,7 +48,7 @@ func TestIsIpGoodBot(t *testing.T) {
4648
clientIP: "1.2.3.4",
4749
goodBots: []string{"google.com"},
4850
lookupAddrReturn: []string{
49-
"host.example.com.",
51+
"crawl.google.com.",
5052
},
5153
lookupAddrErr: nil,
5254
lookupIPReturn: []net.IP{
@@ -97,25 +99,42 @@ func TestIsIpGoodBot(t *testing.T) {
9799
lookupIPErr: nil,
98100
expected: true,
99101
},
102+
{
103+
name: "Client IP is not the first forward result",
104+
clientIP: "1.2.3.4",
105+
goodBots: []string{"example.com"},
106+
lookupAddrReturn: []string{
107+
"crawler.example.com.",
108+
},
109+
lookupIPReturn: []net.IP{
110+
net.ParseIP("5.6.7.8"),
111+
net.ParseIP("1.2.3.4"),
112+
},
113+
expected: true,
114+
},
100115
}
101116

102117
for _, tc := range tests {
103-
lookupAddrFunc = func(ip string) ([]string, error) {
118+
lookupAddrFunc = func(_ context.Context, ip string) ([]string, error) {
104119
if ip != tc.clientIP {
105120
t.Errorf("Expected lookupAddr to be called with %q; got %q", tc.clientIP, ip)
106121
}
107122
return tc.lookupAddrReturn, tc.lookupAddrErr
108123
}
109124

110-
lookupIPFunc = func(host string) ([]net.IP, error) {
111-
if len(tc.lookupAddrReturn) == 0 || host != tc.lookupAddrReturn[0] {
112-
t.Errorf("Expected lookupIP to be called with %q; got %q", tc.lookupAddrReturn[0], host)
125+
lookupIPFunc = func(_ context.Context, network, host string) ([]net.IP, error) {
126+
if network != "ip" {
127+
t.Errorf("Expected lookupIP network %q; got %q", "ip", network)
128+
}
129+
expectedHost := strings.TrimSuffix(tc.lookupAddrReturn[0], ".")
130+
if host != expectedHost {
131+
t.Errorf("Expected lookupIP to be called with %q; got %q", expectedHost, host)
113132
}
114133
return tc.lookupIPReturn, tc.lookupIPErr
115134
}
116135

117136
t.Run(tc.name, func(t *testing.T) {
118-
result := IsIpGoodBot(tc.clientIP, tc.goodBots)
137+
result := IsIpGoodBot(context.Background(), tc.clientIP, tc.goodBots)
119138
if result != tc.expected {
120139
t.Errorf("IsIpGoodBot(%q) = %v; expected %v", tc.clientIP, result, tc.expected)
121140
}

main.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const (
3838
// Default health check settings (disabled by default)
3939
DefaultHealthCheckPeriodSeconds = 0 // How often to check captcha provider health
4040
DefaultHealthCheckFailureThreshold = 0 // Number of consecutive health check failures before opening circuit
41+
goodBotLookupTimeout = 2 * time.Second
4142
)
4243

4344
type circuitState int
@@ -106,6 +107,7 @@ type CaptchaProtect struct {
106107
stateDirty uint64
107108
stateSavedDirty uint64
108109
stateFileModTime time.Time
110+
goodBotLookup func(context.Context, string, []string) bool
109111

110112
// Circuit breaker fields
111113
mu sync.RWMutex
@@ -292,6 +294,7 @@ func NewCaptchaProtect(ctx context.Context, next http.Handler, config *Config, n
292294
},
293295
rateCache: lru.New(expiration, 1*time.Minute),
294296
botCache: lru.New(expiration, 1*time.Hour),
297+
goodBotLookup: helper.IsIpGoodBot,
295298
verifiedCache: lru.New(expiration, 1*time.Hour),
296299
exemptIps: ips,
297300
tmpl: tmpl,
@@ -1014,7 +1017,9 @@ func (bc *CaptchaProtect) isGoodBot(req *http.Request, clientIP string) bool {
10141017
}
10151018
}
10161019
if !v {
1017-
v = helper.IsIpGoodBot(clientIP, bc.config.GoodBots)
1020+
ctx, cancel := context.WithTimeout(req.Context(), goodBotLookupTimeout)
1021+
defer cancel()
1022+
v = bc.goodBotLookup(ctx, clientIP, bc.config.GoodBots)
10181023
}
10191024
bc.botCache.Set(clientIP, v, lru.DefaultExpiration)
10201025
return v

main_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,48 @@ func TestServeHTTP(t *testing.T) {
566566
}
567567
}
568568

569+
func TestServeHTTPAllowsGoodBotOnFirstRequest(t *testing.T) {
570+
upstreamCalled := false
571+
next := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
572+
upstreamCalled = true
573+
rw.WriteHeader(http.StatusOK)
574+
})
575+
config := CreateConfig()
576+
config.SiteKey = "test-site-key"
577+
config.SecretKey = "test-secret-key"
578+
config.RateLimit = 0
579+
config.ProtectRoutes = []string{"/"}
580+
config.GoodBots = []string{"yandex.com"}
581+
582+
cp, err := NewCaptchaProtect(context.Background(), next, config, "captcha-protect")
583+
if err != nil {
584+
t.Fatal(err)
585+
}
586+
lookupCalled := false
587+
cp.goodBotLookup = func(ctx context.Context, clientIP string, goodBots []string) bool {
588+
lookupCalled = true
589+
if _, ok := ctx.Deadline(); !ok {
590+
t.Error("expected DNS lookup context to have a deadline")
591+
}
592+
return clientIP == "5.255.231.189" && len(goodBots) == 1 && goodBots[0] == "yandex.com"
593+
}
594+
595+
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
596+
req.RemoteAddr = "5.255.231.189:1234"
597+
rw := httptest.NewRecorder()
598+
cp.ServeHTTP(rw, req)
599+
600+
if !lookupCalled {
601+
t.Fatal("expected DNS verification on the first request")
602+
}
603+
if !upstreamCalled {
604+
t.Fatal("expected verified bot's first request to reach the upstream handler")
605+
}
606+
if rw.Code != http.StatusOK {
607+
t.Fatalf("expected status %d, got %d", http.StatusOK, rw.Code)
608+
}
609+
}
610+
569611
func TestIsGoodUserAgent(t *testing.T) {
570612
tests := []struct {
571613
name string

0 commit comments

Comments
 (0)