Skip to content

Commit 476abe6

Browse files
committed
feat: add tarpit using http headers
1 parent aa8d46c commit 476abe6

5 files changed

Lines changed: 182 additions & 8 deletions

File tree

config.go

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ import (
1717
"pkg.jsn.cam/caddy-defender/matchers/whitelist"
1818
"pkg.jsn.cam/caddy-defender/ranges/data"
1919
"pkg.jsn.cam/caddy-defender/responders"
20+
"pkg.jsn.cam/caddy-defender/responders/headertarpit"
2021
"pkg.jsn.cam/caddy-defender/responders/tarpit"
2122
)
2223

23-
var responderTypes = []string{"block", "custom", "drop", "garbage", "ratelimit", "redirect", "tarpit"}
24+
var responderTypes = []string{"block", "custom", "drop", "garbage", "ratelimit", "redirect", "tarpit", "header_tarpit"}
2425

2526
// UnmarshalCaddyfile sets up the handler from Caddyfile tokens. Syntax:
2627
//
@@ -150,6 +151,57 @@ func (m *Defender) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
150151
return d.Errf("unknown nested config key: %s", d.Val())
151152
}
152153
}
154+
case "header_tarpit_config":
155+
for nesting := d.Nesting(); d.NextBlock(nesting); {
156+
switch d.Val() {
157+
case "headers":
158+
headers := map[string]string{}
159+
for nesting := d.Nesting(); d.NextBlock(nesting); {
160+
k := d.Val()
161+
if !d.NextArg() {
162+
return d.ArgErr()
163+
}
164+
headers[k] = d.Val()
165+
}
166+
m.HeaderTarpitConfig.Headers = headers
167+
case "timeout":
168+
if !d.NextArg() {
169+
return d.ArgErr()
170+
}
171+
172+
timeout, err := time.ParseDuration(d.Val())
173+
if err != nil {
174+
return fmt.Errorf("invalid timeout value: '%s'", d.Val())
175+
}
176+
177+
m.HeaderTarpitConfig.Timeout = timeout
178+
case "header_per_second":
179+
if !d.NextArg() {
180+
return d.ArgErr()
181+
}
182+
183+
hps, err := strconv.Atoi(d.Val())
184+
if err != nil {
185+
return fmt.Errorf("invalid header_per_second value: '%s'", d.Val())
186+
}
187+
188+
m.HeaderTarpitConfig.DelaySecond = hps
189+
case "response_code":
190+
if !d.NextArg() {
191+
return d.ArgErr()
192+
}
193+
194+
responseCode, err := strconv.Atoi(d.Val())
195+
if err != nil {
196+
return fmt.Errorf("invalid response_code value: '%s'", d.Val())
197+
}
198+
199+
m.HeaderTarpitConfig.ResponseCode = responseCode
200+
default:
201+
return d.Errf("unknown nested config key: %s", d.Val())
202+
}
203+
}
204+
153205
default:
154206
return d.Errf("unknown subdirective '%s'", d.Val())
155207
}
@@ -162,7 +214,7 @@ func (m *Defender) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
162214
func (m *Defender) UnmarshalJSON(b []byte) error {
163215
type rawDefender Defender
164216
var rawConfig rawDefender
165-
var excludedKeys = []string{"responder"}
217+
excludedKeys := []string{"responder"}
166218

167219
if err := json.Unmarshal(b, &rawConfig); err != nil {
168220
return err
@@ -194,6 +246,10 @@ func (m *Defender) UnmarshalJSON(b []byte) error {
194246
m.responder = &tarpit.Responder{
195247
Config: &m.TarpitConfig,
196248
}
249+
case "header_tarpit":
250+
m.responder = &headertarpit.Responder{
251+
Config: &m.HeaderTarpitConfig,
252+
}
197253

198254
default:
199255
return fmt.Errorf("unknown responder type: %s", rawConfig.RawResponder)

plugin.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"go.uber.org/zap"
1313
"pkg.jsn.cam/caddy-defender/matchers/ip"
1414
"pkg.jsn.cam/caddy-defender/responders"
15+
"pkg.jsn.cam/caddy-defender/responders/headertarpit"
1516
"pkg.jsn.cam/caddy-defender/responders/tarpit"
1617
)
1718

@@ -25,13 +26,20 @@ func init() {
2526
var (
2627
// DefaultRanges is the default ranges to block if none are specified.
2728
DefaultRanges = []string{"aws", "gcloud", "azurepubliccloud", "openai", "deepseek", "githubcopilot"}
29+
2830
// Tarpit Defaults
2931
// defaultTarpitTimeout is the default duration for a request to be closed after.
3032
defaultTarpitTimeout = time.Second * 30
3133
// defaultTarpitBytesPerSecond is the default amount of bytes to stream per second.
3234
defaultTarpitBytesPerSecond = 24
3335
// defaultTarpitResponseCode is the default HTTP respond code for the tarpit responder.
3436
defaultTarpitResponseCode = http.StatusOK
37+
38+
// Header Tarpit Defaults
39+
// defaultHeaderTarpitTimeout is the default duration for a request to be closed after.
40+
defaultHeaderTarpitTimeout = time.Second * 30
41+
// defaultTarpitHeaderDelaySecond is the default delay between each successive header responses
42+
defaultHeaderTarpitDelaySecond = 4
3543
)
3644

3745
// Defender implements an HTTP middleware that enforces IP-based rules to protect your site from AIs/Scrapers.
@@ -85,7 +93,7 @@ type Defender struct {
8593
URL string `json:"url,omitempty"`
8694

8795
// RawResponder defines the response strategy for blocked requests.
88-
// Required. Must be one of: "block", "custom", "drop", "garbage", "redirect", "tarpit"
96+
// Required. Must be one of: "block", "custom", "drop", "garbage", "redirect", "tarpit", "headertarpit"
8997
RawResponder string `json:"raw_responder,omitempty"`
9098

9199
// Ranges specifies IP ranges to block, which can be either:
@@ -103,6 +111,10 @@ type Defender struct {
103111
// Default: {Headers: {}, timeout: 30s, ResponseCode: 200}
104112
TarpitConfig tarpit.Config `json:"tarpit_config,omitempty"`
105113

114+
// An optional configuration for the 'tarpit' responder
115+
// Default: {Headers: {}, timeout: 30s, ResponseCode: 200}
116+
HeaderTarpitConfig headertarpit.Config `json:"header_tarpit_config,omitempty"`
117+
106118
// StatusCode specifies the HTTP status code for 'custom' responder type.
107119
// Optional. Default: 200
108120
StatusCode int `json:"status_code,omitempty"`
@@ -150,6 +162,16 @@ func (m *Defender) Provision(ctx caddy.Context) error {
150162
}
151163
}
152164

165+
if m.RawResponder == "header_tarpit" {
166+
if m.HeaderTarpitConfig.Timeout == 0 {
167+
m.HeaderTarpitConfig.Timeout = defaultHeaderTarpitTimeout
168+
}
169+
170+
if m.HeaderTarpitConfig.DelaySecond == 0 {
171+
m.HeaderTarpitConfig.DelaySecond = defaultHeaderTarpitDelaySecond
172+
}
173+
}
174+
153175
return nil
154176
}
155177

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package headertarpit
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"math/rand/v2"
7+
"net/http"
8+
"time"
9+
10+
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
11+
)
12+
13+
// Config holds the tarpit responder's configuration.
14+
type Config struct {
15+
Headers map[string]string `json:"headers"`
16+
Timeout time.Duration `json:"timeout"`
17+
DelaySecond int `json:"header_per_second"`
18+
ResponseCode int `json:"code"`
19+
}
20+
21+
// Responder returns a custom response.
22+
type Responder struct {
23+
Config *Config
24+
}
25+
26+
func (r *Responder) ServeHTTP(w http.ResponseWriter, req *http.Request, _ caddyhttp.Handler) error {
27+
hj, ok := w.(http.Hijacker)
28+
if !ok {
29+
http.Error(w, "webserver doesn't support hijacking", http.StatusInternalServerError)
30+
return errors.New("webserver doesn't support hijacking")
31+
}
32+
33+
conn, bufrw, err := hj.Hijack()
34+
if err != nil {
35+
http.Error(w, err.Error(), http.StatusInternalServerError)
36+
return err
37+
}
38+
39+
defer conn.Close()
40+
41+
h := fmt.Sprintf("HTTP/1.1 %d %s\n", r.Config.ResponseCode, http.StatusText(r.Config.ResponseCode))
42+
bufrw.WriteString(h)
43+
bufrw.Flush()
44+
45+
// Write any prelude headers
46+
for key, value := range r.Config.Headers {
47+
bufrw.WriteString(fmt.Sprintf("%s: %s\n", key, value))
48+
}
49+
50+
bufrw.Flush()
51+
52+
// Write successive headers per delay
53+
ticker := time.NewTicker(time.Duration(r.Config.DelaySecond) * time.Second)
54+
defer ticker.Stop()
55+
56+
timeout := time.After(r.Config.Timeout)
57+
58+
for {
59+
select {
60+
case <-ticker.C:
61+
s := fmt.Sprintf("X-%016x: %016x\n", rand.Uint64(), rand.Uint64())
62+
bufrw.WriteString(s)
63+
bufrw.Flush()
64+
case <-timeout:
65+
return nil
66+
}
67+
}
68+
}
69+
70+
func (r *Responder) Validate() error {
71+
if r.Config.Timeout <= 0 {
72+
return errors.New("header_tarpit timeout must be greater than 0")
73+
}
74+
if r.Config.DelaySecond <= 0 {
75+
return errors.New("header_tarpit header_per_second must be greater than 0")
76+
}
77+
return nil
78+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package headertarpit
2+
3+
import (
4+
"time"
5+
)
6+
7+
// Helper function to create a new responder
8+
func newTestResponder(content Content, timeout time.Duration) *Responder {
9+
return &Responder{
10+
Config: &Config{
11+
Content: content,
12+
Timeout: timeout,
13+
DelayPerSecond: 4,
14+
ResponseCode: 200,
15+
},
16+
}
17+
}

responders/tarpit/tarpit.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ type Content struct {
2525

2626
// Config holds the tarpit responder's configuration.
2727
type Config struct {
28-
Headers map[string]string `json:"headers"`
29-
Content Content
30-
Timeout time.Duration `json:"timeout"`
31-
BytesPerSecond int `json:"bytes_per_second"`
32-
ResponseCode int `json:"code"`
28+
Headers map[string]string `json:"headers"`
29+
Content Content
30+
Timeout time.Duration `json:"timeout"`
31+
HeaderPerSecond int `json:"header_per_second"`
32+
BytesPerSecond int `json:"bytes_per_second"`
33+
ResponseCode int `json:"code"`
3334
}
3435

3536
// ConfigureContentReader checks the content protocol configuration

0 commit comments

Comments
 (0)