diff --git a/README.md b/README.md index 7b103730b0..873fc4ea10 100644 --- a/README.md +++ b/README.md @@ -880,12 +880,15 @@ Whether captcha middleware is enabled `SECURITY_CAPTCHA_PROVIDER` - `string` -for now the only options supported are: hCaptcha and Turnstile +Supported options are hCaptcha, Turnstile, and FCaptcha. - `SECURITY_CAPTCHA_SECRET` - `string` +- `SECURITY_CAPTCHA_PROVIDER_URL` - `string` - `SECURITY_CAPTCHA_TIMEOUT` - `string` -Retrieve from hcaptcha or turnstile account +`SECURITY_CAPTCHA_PROVIDER_URL` is required only for FCaptcha and points to the +self-hosted FCaptcha base URL. `SECURITY_CAPTCHA_SECRET` must match that +instance's `FCAPTCHA_VERIFY_SECRET`. ### Reauthentication diff --git a/example.env b/example.env index f7bcdb6c4c..fbd132eaea 100644 --- a/example.env +++ b/example.env @@ -228,6 +228,7 @@ GOTRUE_SMS_VONAGE_FROM="" GOTRUE_SECURITY_CAPTCHA_ENABLED="false" GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha" GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000" +GOTRUE_SECURITY_CAPTCHA_PROVIDER_URL="" GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s" GOTRUE_SESSION_KEY="" diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 00e0367029..a39aa461e3 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -792,10 +792,11 @@ type VonageProviderConfiguration struct { } type CaptchaConfiguration struct { - Enabled bool `json:"enabled" default:"false"` - Provider string `json:"provider" default:"hcaptcha"` - Secret string `json:"provider_secret"` - Timeout time.Duration `json:"timeout" split_words:"true" default:"10s"` + Enabled bool `json:"enabled" default:"false"` + Provider string `json:"provider" default:"hcaptcha"` + Secret string `json:"provider_secret"` + ProviderURL string `json:"provider_url" split_words:"true"` + Timeout time.Duration `json:"timeout" split_words:"true" default:"10s"` } func (c *CaptchaConfiguration) Validate() error { @@ -803,7 +804,7 @@ func (c *CaptchaConfiguration) Validate() error { return nil } - if c.Provider != "hcaptcha" && c.Provider != "turnstile" { + if c.Provider != "hcaptcha" && c.Provider != "turnstile" && c.Provider != "fcaptcha" { return fmt.Errorf("unsupported captcha provider: %s", c.Provider) } @@ -813,6 +814,15 @@ func (c *CaptchaConfiguration) Validate() error { return errors.New("captcha provider secret is empty") } + if c.Provider == "fcaptcha" { + u, err := url.Parse(c.ProviderURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || + u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return errors.New("fcaptcha provider URL must be an HTTP(S) base URL") + } + c.ProviderURL = strings.TrimRight(c.ProviderURL, "/") + } + return nil } diff --git a/internal/conf/configuration_test.go b/internal/conf/configuration_test.go index 0941b326e7..9d541fe099 100644 --- a/internal/conf/configuration_test.go +++ b/internal/conf/configuration_test.go @@ -404,6 +404,26 @@ func TestValidate(t *testing.T) { Secret: "abc", }, }, + { + val: &CaptchaConfiguration{ + Enabled: true, + Provider: "fcaptcha", + Secret: "abc", + }, + err: "fcaptcha provider URL must be an HTTP(S) base URL", + }, + { + val: &CaptchaConfiguration{ + Enabled: true, + Provider: "fcaptcha", + Secret: "abc", + ProviderURL: "https://captcha.example.com/", + }, + check: func(t *testing.T, v any) { + cfg := v.(*CaptchaConfiguration) + require.Equal(t, "https://captcha.example.com", cfg.ProviderURL) + }, + }, { val: &DatabaseEncryptionConfiguration{Encrypt: false}, diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env index 2e123751a7..c15bf2ce92 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -214,6 +214,7 @@ GOTRUE_SMS_VONAGE_FROM="" GOTRUE_SECURITY_CAPTCHA_ENABLED="false" GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha" GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000" +GOTRUE_SECURITY_CAPTCHA_PROVIDER_URL="" GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s" GOTRUE_SESSION_KEY="" diff --git a/internal/reloader/testdata/60_example_newline.env b/internal/reloader/testdata/60_example_newline.env index 3ab3aebb02..2ff63d54e0 100644 --- a/internal/reloader/testdata/60_example_newline.env +++ b/internal/reloader/testdata/60_example_newline.env @@ -209,6 +209,7 @@ GOTRUE_SMS_VONAGE_FROM="" GOTRUE_SECURITY_CAPTCHA_ENABLED="false" GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha" GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000" +GOTRUE_SECURITY_CAPTCHA_PROVIDER_URL="" GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s" GOTRUE_SESSION_KEY="" diff --git a/internal/security/captcha.go b/internal/security/captcha.go index 90d27b5006..81775257e2 100644 --- a/internal/security/captcha.go +++ b/internal/security/captcha.go @@ -29,9 +29,10 @@ type CaptchaVerifier interface { // HTTPCaptchaVerifier is the default implementation that calls out to hCaptcha / Turnstile. type HTTPCaptchaVerifier struct { - client *http.Client - secret string - provider string + client *http.Client + secret string + provider string + providerURL string } func NewCaptchaVerifier(cfg *conf.CaptchaConfiguration) *HTTPCaptchaVerifier { @@ -41,14 +42,15 @@ func NewCaptchaVerifier(cfg *conf.CaptchaConfiguration) *HTTPCaptchaVerifier { } return &HTTPCaptchaVerifier{ - client: &http.Client{Timeout: timeout}, - secret: strings.TrimSpace(cfg.Secret), - provider: cfg.Provider, + client: &http.Client{Timeout: timeout}, + secret: strings.TrimSpace(cfg.Secret), + provider: cfg.Provider, + providerURL: cfg.ProviderURL, } } func (v *HTTPCaptchaVerifier) Verify(ctx context.Context, token, clientIP string) (*VerificationResponse, error) { - captchaURL, err := getCaptchaURL(v.provider) + captchaURL, err := getCaptchaURL(v.provider, v.providerURL) if err != nil { return nil, err } @@ -84,12 +86,17 @@ func (v *HTTPCaptchaVerifier) verifyCaptchaCode(ctx context.Context, token, clie return &verificationResponse, nil } -func getCaptchaURL(captchaProvider string) (string, error) { +func getCaptchaURL(captchaProvider, providerURL string) (string, error) { switch captchaProvider { case "hcaptcha": return "https://hcaptcha.com/siteverify", nil case "turnstile": return "https://challenges.cloudflare.com/turnstile/v0/siteverify", nil + case "fcaptcha": + if providerURL == "" { + return "", errors.New("fcaptcha provider URL is empty") + } + return strings.TrimRight(providerURL, "/") + "/siteverify", nil default: return "", fmt.Errorf("captcha Provider %q could not be found", captchaProvider) } diff --git a/internal/security/captcha_test.go b/internal/security/captcha_test.go index 44cc1ba7cb..273eb147cc 100644 --- a/internal/security/captcha_test.go +++ b/internal/security/captcha_test.go @@ -1,6 +1,9 @@ package security import ( + "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -59,3 +62,30 @@ func TestUnsupportedProvider(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "recaptcha") } + +func TestFCaptchaSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + assert.Equal(t, "verify-secret", r.Form.Get("secret")) + assert.Equal(t, "token", r.Form.Get("response")) + assert.Equal(t, "127.0.0.1", r.Form.Get("remoteip")) + _ = json.NewEncoder(w).Encode(VerificationResponse{Success: true, Hostname: "example.com"}) + })) + defer server.Close() + + v := NewCaptchaVerifier(&conf.CaptchaConfiguration{ + Provider: "fcaptcha", + Secret: "verify-secret", + ProviderURL: server.URL, + }) + resp, err := v.Verify(t.Context(), "token", "127.0.0.1") + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, "example.com", resp.Hostname) +} + +func TestFCaptchaRequiresProviderURL(t *testing.T) { + v := newTestVerifier("fcaptcha", "verify-secret") + _, err := v.Verify(t.Context(), "token", "127.0.0.1") + require.ErrorContains(t, err, "provider URL is empty") +}