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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""

Expand Down
20 changes: 15 additions & 5 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -792,18 +792,19 @@ 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 {
if !c.Enabled {
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)
}

Expand All @@ -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
}

Expand Down
20 changes: 20 additions & 0 deletions internal/conf/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
1 change: 1 addition & 0 deletions internal/reloader/testdata/50_example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""

Expand Down
1 change: 1 addition & 0 deletions internal/reloader/testdata/60_example_newline.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""

Expand Down
23 changes: 15 additions & 8 deletions internal/security/captcha.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
30 changes: 30 additions & 0 deletions internal/security/captcha_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package security

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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")
}