Skip to content
Merged
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: 1 addition & 1 deletion .github/workflows/lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
traefik: [v2.11, v3.0, v3.1, v3.2, v3.3]
traefik: [v2.11, v3.0, v3.1, v3.2, v3.3, v3.4]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ services:
| `exemptUserAgents` | `[]string` | `""` | Comma-separated list of case-insensitive user agent **prefixes** to never challenge. e.g. `exemptUserAgents: edge` would never challenge useragents like "Edge/12.4 ..." |
| `challengeURL` | `string` | `"/challenge"` | URL where challenges are served. This will override existing routes if there is a conflict. Setting to blank will have the challenge presented on the same page that tripped the rate limit. |
| `challengeTmpl` | `string` | `"./challenge.tmpl.html"`| Path to the Go HTML template for the captcha challenge page. |
| `challengeStatusCode` | `int` | `200` | HTTP Response status code to return when serving a challenge |
| `enableStatsPage` | `string` | `"false"` | Allows `exemptIps` to access `/captcha-protect/stats` to monitor the rate limiter. |
| `logLevel` | `string` | `"INFO"` | Log level for the middleware. Options: `ERROR`, `WARNING`, `INFO`, or `DEBUG`. |
| `persistentStateFile` | `string` | `""` | File path to persist rate limiter state across Traefik restarts. In Docker, mount this file from the host. |
Expand Down
20 changes: 15 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type Config struct {
ExemptUserAgents []string `json:"exemptUserAgents"`
ChallengeURL string `json:"challengeURL"`
ChallengeTmpl string `json:"challengeTmpl"`
ChallengeStatusCode int `json:"challengeStatusCode"`
CaptchaProvider string `json:"captchaProvider"`
SiteKey string `json:"siteKey"`
SecretKey string `json:"secretKey"`
Expand Down Expand Up @@ -96,6 +97,7 @@ func CreateConfig() *Config {
ExemptUserAgents: []string{},
ChallengeURL: "/challenge",
ChallengeTmpl: "challenge.tmpl.html",
ChallengeStatusCode: 0,
EnableStatsPage: "false",
LogLevel: "INFO",
IPDepth: 0,
Expand Down Expand Up @@ -210,6 +212,15 @@ func NewCaptchaProtect(ctx context.Context, next http.Handler, config *Config, n
excludeRoutesRegex: excludeRoutesRegex,
}

// if a status code was not configured
// retain the default set before this config option was added
if config.ChallengeStatusCode == 0 {
bc.config.ChallengeStatusCode = http.StatusOK
if bc.ChallengeOnPage() {
bc.config.ChallengeStatusCode = http.StatusTooManyRequests
}
}

err := bc.SetIpv4Mask(config.IPv4SubnetMask)
if err != nil {
return nil, err
Expand Down Expand Up @@ -325,11 +336,10 @@ func (bc *CaptchaProtect) serveChallengePage(rw http.ResponseWriter, destination
"ChallengeURL": bc.config.ChallengeURL,
"Destination": destination,
}
status := http.StatusOK
if bc.ChallengeOnPage() {
status = http.StatusTooManyRequests
}
rw.WriteHeader(status)

// have to write http status before executing the template
// otherwise a 200 will get served by the template execution
rw.WriteHeader(bc.config.ChallengeStatusCode)

err := bc.tmpl.Execute(rw, d)
if err != nil {
Expand Down
37 changes: 36 additions & 1 deletion main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,20 +481,39 @@ func TestServeHTTP(t *testing.T) {
expectedStatus uint
challengePage string
expectedBody string
challengeCode int
}{
{
name: "Redirect to 302",
rateLimit: 0,
challengePage: "/challenge",
expectedStatus: http.StatusFound,
expectedBody: "/challenge?destination=%2Fsomepath",
challengeCode: http.StatusOK,
},
{
name: "429 on same page",
name: "403 when changing default challenge code",
rateLimit: 0,
challengePage: "/challenge",
expectedStatus: http.StatusFound,
challengeCode: http.StatusForbidden,
expectedBody: "/challenge?destination=%2Fsomepath",
},
{
name: "429 when challenging on same page",
rateLimit: 0,
challengePage: "",
expectedStatus: http.StatusTooManyRequests,
expectedBody: "One moment while we verify your network connection",
challengeCode: http.StatusTooManyRequests,
},
{
name: "403 when challenging on same page",
rateLimit: 0,
challengePage: "",
expectedStatus: http.StatusForbidden,
challengeCode: http.StatusForbidden,
expectedBody: "One moment while we verify your network connection",
},
}
for _, tc := range tests {
Expand All @@ -503,6 +522,7 @@ func TestServeHTTP(t *testing.T) {
config.CaptchaProvider = "turnstile"
config.ProtectRoutes = []string{"/"}
config.ChallengeURL = tc.challengePage
config.ChallengeStatusCode = tc.challengeCode
config.ExemptIPs = []string{}
cp, err := NewCaptchaProtect(context.Background(), next, config, "captcha-protect")
if err != nil {
Expand All @@ -519,6 +539,21 @@ func TestServeHTTP(t *testing.T) {
if !strings.Contains(body, tc.expectedBody) {
t.Errorf("expected %s got %s", tc.expectedBody, body)
}

// we're done testing if challenging on same page
if tc.challengePage == "" {
return
}

// if redirecting, test the /challenge page
// and ensure it returns the status we set
req = httptest.NewRequest(http.MethodGet, "http://example.com"+tc.challengePage, nil)
req.RequestURI = tc.challengePage
rr = httptest.NewRecorder()
cp.ServeHTTP(rr, req)
if rr.Code != int(tc.challengeCode) {
t.Errorf("expected %d got %d", tc.challengeCode, rr.Code)
}
})
}
}
Expand Down