diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 35e6c8a..1b3e4c7 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -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 diff --git a/README.md b/README.md index 10c6191..72f6f99 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/main.go b/main.go index 9a8b17d..795a971 100644 --- a/main.go +++ b/main.go @@ -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"` @@ -96,6 +97,7 @@ func CreateConfig() *Config { ExemptUserAgents: []string{}, ChallengeURL: "/challenge", ChallengeTmpl: "challenge.tmpl.html", + ChallengeStatusCode: 0, EnableStatsPage: "false", LogLevel: "INFO", IPDepth: 0, @@ -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 @@ -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 { diff --git a/main_test.go b/main_test.go index 2c52aac..ccf1190 100644 --- a/main_test.go +++ b/main_test.go @@ -481,6 +481,7 @@ func TestServeHTTP(t *testing.T) { expectedStatus uint challengePage string expectedBody string + challengeCode int }{ { name: "Redirect to 302", @@ -488,13 +489,31 @@ func TestServeHTTP(t *testing.T) { 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 { @@ -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 { @@ -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) + } }) } }