Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ The `defender` directive is used to configure the Caddy Defender plugin. It has
defender <responder> {
message <custom message>
ranges <ip_ranges...>
access_log <logger_name...>
url <url>
}
```
Expand All @@ -72,6 +73,7 @@ defender <responder> {
- `ratelimit`: Marks requests for rate limiting (requires [Caddy-Ratelimit](https://github.com/mholt/caddy-ratelimit) to be installed as well ).
- `tarpit`: Stream data at a slow, but configurable rate to stall bots and pollute AI training.
- `<ip_ranges...>`: An optional list of CIDR ranges or predefined range keys to match against the client's IP. Defaults to [`aws azurepubliccloud deepseek gcloud githubcopilot openai`](./plugin.go).
- `<logger_name...>`: Optional Caddy access logger name(s) for requests blocked by Defender.
- `<custom message>`: A custom message to return when using the `custom` responder.
- `<url>`: The URI that the `redirect` responder would redirect to.

Expand Down
117 changes: 117 additions & 0 deletions access_log_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package caddydefender

import (
"bufio"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"testing"
"time"

"github.com/caddyserver/caddy/v2/caddytest"
"github.com/stretchr/testify/require"
)

func TestDefenderBlockedRequestsAccessLogE2E(t *testing.T) {
logAddr, logLines, logErrs := startAccessLogServer(t)
tester := caddytest.NewTester(t)
defer tester.InitServer(`{
admin localhost:2999
}`, "caddyfile")

tester.InitServer(fmt.Sprintf(`{
admin localhost:2999
order defender after header
servers {
trusted_proxies static 127.0.0.1/32 ::1/128
client_ip_headers X-Forwarded-For
}
}

http://localhost:9080 {
log defender_blocked {
output net %q {
Comment on lines +20 to +35
dial_timeout 1s
}
no_hostname
format json
}

defender block {
ranges 203.0.113.0/24
access_log defender_blocked
}

respond "allowed"
}`, logAddr), "caddyfile")

allowedReq, err := http.NewRequest(http.MethodGet, "http://localhost:9080/allowed", nil)
require.NoError(t, err)
allowedReq.Header.Set("X-Forwarded-For", "198.51.100.10")
allowedResp, _ := tester.AssertResponse(allowedReq, http.StatusOK, "allowed")
require.NoError(t, allowedResp.Body.Close())

blockedReq, err := http.NewRequest(http.MethodGet, "http://localhost:9080/blocked", nil)
require.NoError(t, err)
blockedReq.Header.Set("X-Forwarded-For", "203.0.113.10")
blockedResp, _ := tester.AssertResponse(blockedReq, http.StatusForbidden, "Access denied")
require.NoError(t, blockedResp.Body.Close())

line := readLogLine(t, logLines, logErrs)

var entry map[string]any
require.NoError(t, json.Unmarshal([]byte(line), &entry))
require.Equal(t, "http.log.access.defender_blocked", entry["logger"])
require.Equal(t, float64(http.StatusForbidden), entry["status"])
require.Equal(t, true, entry["defender.blocked"])
require.Equal(t, "block", entry["defender.action"])
require.Equal(t, "203.0.113.10", entry["defender.client_ip"])
require.Equal(t, "ip_range", entry["defender.reason"])
}

func startAccessLogServer(t *testing.T) (string, <-chan string, <-chan error) {
t.Helper()

listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, listener.Close())
})

lines := make(chan string, 1)
errs := make(chan error, 1)

go func() {
conn, err := listener.Accept()
if err != nil {
errs <- err
return
}
defer conn.Close()

line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
errs <- err
return
}
lines <- line
}()

return "tcp/" + listener.Addr().String(), lines, errs
}

func readLogLine(t *testing.T, lines <-chan string, errs <-chan error) string {
t.Helper()

select {
case line := <-lines:
return strings.TrimSpace(line)
case err := <-errs:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for access log entry")
}
return ""
}
8 changes: 8 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ func (m *Defender) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return fmt.Errorf("invalid status_code value: '%s'", d.Val())
}
m.StatusCode = statusCode
case "access_log":
if !d.NextArg() {
return d.ArgErr()
}
m.AccessLogNames = append(m.AccessLogNames, d.Val())
for d.NextArg() {
m.AccessLogNames = append(m.AccessLogNames, d.Val())
Comment on lines +99 to +101
}
case "url":
if !d.NextArg() {
return d.ArgErr()
Expand Down
17 changes: 11 additions & 6 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ func TestUnmarshalCaddyfile(t *testing.T) {
name: "valid block responder with CIDR ranges",
input: `defender block {
ranges 192.168.1.0/24 10.0.0.0/8
access_log defender_blocked defender_audit
}`,
expected: Defender{
RawResponder: "block",
Ranges: []string{"192.168.1.0/24", "10.0.0.0/8"},
RawResponder: "block",
Ranges: []string{"192.168.1.0/24", "10.0.0.0/8"},
AccessLogNames: []string{"defender_blocked", "defender_audit"},
},
},
{
Expand Down Expand Up @@ -194,6 +196,7 @@ func TestUnmarshalCaddyfile(t *testing.T) {
require.Equal(t, tt.expected.RawResponder, def.RawResponder)
require.Equal(t, tt.expected.Ranges, def.Ranges)
require.Equal(t, tt.expected.Message, def.Message)
require.Equal(t, tt.expected.AccessLogNames, def.AccessLogNames)
})
}
}
Expand All @@ -207,11 +210,12 @@ func TestUnmarshalJSON(t *testing.T) {
}{
{
name: "valid block responder with ranges",
input: `{"raw_responder":"block","ranges":["10.0.0.0/8","aws"]}`,
input: `{"raw_responder":"block","ranges":["10.0.0.0/8","aws"],"access_log":["defender_blocked"]}`,
expected: Defender{
RawResponder: "block",
Ranges: []string{"10.0.0.0/8", "aws"},
responder: &responders.BlockResponder{},
RawResponder: "block",
Ranges: []string{"10.0.0.0/8", "aws"},
AccessLogNames: []string{"defender_blocked"},
responder: &responders.BlockResponder{},
},
},
{
Expand Down Expand Up @@ -318,6 +322,7 @@ func TestUnmarshalJSON(t *testing.T) {
require.Equal(t, tt.expected.RawResponder, def.RawResponder)
require.Equal(t, tt.expected.Ranges, def.Ranges)
require.Equal(t, tt.expected.Message, def.Message)
require.Equal(t, tt.expected.AccessLogNames, def.AccessLogNames)
require.IsType(t, tt.expected.responder, def.responder)
})
}
Expand Down
10 changes: 10 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ defender <responder> {
message <custom_message>
status_code <http_status_code>
ranges <cidr_or_predefined...>
access_log <logger_name...>
url <url>
}
```

- `<responder>`: The responder backend to use.
- `<cidr_or_predefined>`: An optional list of CIDR ranges or predefined range keys to match against the client's IP. Defaults to [`aws azurepubliccloud deepseek gcloud githubcopilot openai`](https://github.com/JasonLovesDoggo/caddy-defender/blob/main/plugin.go).
- `<logger_name>`: Optional Caddy access logger name(s) to use for requests blocked by Defender. Defender also adds `defender.*` fields to blocked access log entries.
- `<custom_message>`: A custom message to return when using the `custom` responder.
- `<http_status_code>`: An optional HTTP status code to return when using the `custom` responder. Defaults to 200.
- `<url>`: The URI that the `redirect` responder would redirect to.
Expand All @@ -38,6 +40,7 @@ defender <responder> {
"url": "",
"raw_responder": "",
"ranges": [""],
"access_log": [""],
"whitelist": [""],
"tarpit_config": {
"headers": {
Expand Down Expand Up @@ -82,6 +85,13 @@ defender <responder> {
- If empty, no IPs are whitelisted.
- Default: `[]`

`access_log`

- Optional Caddy access logger names for blocked requests.
- When set, Defender routes blocked requests to those named Caddy access loggers using Caddy's native logging pipeline.
- Defender adds `defender.blocked`, `defender.action`, `defender.client_ip`, and `defender.reason` fields to blocked access log entries.
- Default: `[]`

`tarpit_config`

- An optional configuration for the `tarpit` responder
Expand Down
24 changes: 24 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ localhost:8080 {

---

## **Log Blocked Requests**

Route Defender-blocked requests to a named Caddy access logger:

```caddyfile
example.com {
log defender_blocked {
output file /var/log/caddy/defender-blocked.log
format json
}

defender block {
ranges openai aws
access_log defender_blocked
}

respond "Human-friendly content"
}
```

Blocked access log entries include `defender.blocked`, `defender.action`, `defender.client_ip`, and `defender.reason` fields. If `access_log` is omitted, Defender still adds those fields to any normal Caddy access log entry for blocked requests.

---

## **Custom Response**

Return tailored messages with custom status codes for blocked requests:
Expand Down
31 changes: 29 additions & 2 deletions middleware.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package caddydefender

import (
"context"
"fmt"
"net"
"net/http"

"go.uber.org/zap"

"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"go.uber.org/zap"
)

// serveIgnore is a helper function to serve a robots.txt file if the ServeIgnore option is enabled.
Expand Down Expand Up @@ -63,10 +63,37 @@ func (m Defender) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
return next.ServeHTTP(w, r)
}
m.log.Debug("Request blocked (IP in blocked ranges and not whitelisted)", zap.String("ip", clientIP.String()))
m.markBlockedRequest(r, clientIP)
// Request should be blocked
return m.responder.ServeHTTP(w, r, next)
}

func (m Defender) markBlockedRequest(r *http.Request, clientIP net.IP) {
if len(m.AccessLogNames) > 0 {
caddyhttp.SetVar(r.Context(), caddyhttp.AccessLoggerNameVarKey, accessLoggerNames(r.Context(), m.AccessLogNames))
}

extra, ok := r.Context().Value(caddyhttp.ExtraLogFieldsCtxKey).(*caddyhttp.ExtraLogFields)
if !ok {
return
}

extra.Set(zap.Bool("defender.blocked", true))
extra.Set(zap.String("defender.action", m.RawResponder))
extra.Set(zap.String("defender.client_ip", clientIP.String()))
extra.Set(zap.String("defender.reason", "ip_range"))
}

func accessLoggerNames(ctx context.Context, defenderLogNames []string) []any {
existing, _ := caddyhttp.GetVar(ctx, caddyhttp.AccessLoggerNameVarKey).([]any)
names := make([]any, 0, len(existing)+len(defenderLogNames))
names = append(names, existing...)
for _, name := range defenderLogNames {
names = append(names, name)
}
Comment on lines +88 to +93
return names
}

func clientIPFromRequest(r *http.Request) (net.IP, error) {
if clientIP, ok := caddyhttp.GetVar(r.Context(), caddyhttp.ClientIPVarKey).(string); ok && clientIP != "" {
return parseClientIP(clientIP)
Expand Down
32 changes: 32 additions & 0 deletions middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,35 @@ func TestDefenderServeHTTP_UsesCaddyClientIP(t *testing.T) {
require.Equal(t, http.StatusForbidden, recorder.Code)
require.Equal(t, "Access denied", recorder.Body.String())
}

func TestDefenderServeHTTP_RoutesBlockedRequestsToAccessLog(t *testing.T) {
defender := &Defender{
RawResponder: "block",
Ranges: []string{"203.0.113.0/24"},
AccessLogNames: []string{"general", "defender_blocked"},
responder: &responders.BlockResponder{},
}

ctx := caddy.Context{Context: context.Background()}
defender.log = zap.NewNop()
err := defender.Provision(ctx)
require.NoError(t, err)

req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.10:12345"
req = req.WithContext(context.WithValue(req.Context(), caddyhttp.VarsCtxKey, map[string]any{
caddyhttp.AccessLoggerNameVarKey: []any{"site_log"},
}))

recorder := httptest.NewRecorder()

err = defender.ServeHTTP(recorder, req, &mockHandler{})

require.NoError(t, err)
require.Equal(t, http.StatusForbidden, recorder.Code)
require.Equal(
t,
[]any{"site_log", "general", "defender_blocked"},
caddyhttp.GetVar(req.Context(), caddyhttp.AccessLoggerNameVarKey),
)
}
4 changes: 4 additions & 0 deletions plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ type Defender struct {
// Default: []
Whitelist []string `json:"whitelist,omitempty"`

// AccessLogNames routes blocked requests to the named Caddy access logger(s).
// Optional. Uses Caddy's native access log configuration.
AccessLogNames []string `json:"access_log,omitempty"`

// An optional configuration for the 'tarpit' responder
// Default: {Headers: {}, timeout: 30s, ResponseCode: 200}
TarpitConfig tarpit.Config `json:"tarpit_config,omitempty"`
Expand Down
Loading