mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-28 09:55:45 +00:00
fix(turnstile): route CAPTCHA verification through the configured proxy (#38412)
Fixes #38217 ## Problem Turnstile CAPTCHA verification uses `http.DefaultClient`, so the request to `challenges.cloudflare.com` bypasses Gitea's configured HTTP proxy — unlike other outbound HTTP clients such as the update checker (`modules/updatechecker/update_checker.go`) and migrations. In deployments where egress is only permitted through the configured proxy, verification fails. ## Fix Build the client with `proxy.Proxy()` as the transport proxy, mirroring the update checker: ```go func httpClient() *http.Client { return &http.Client{ Transport: &http.Transport{ Proxy: proxy.Proxy(), }, } } ``` The client is built per call (rather than a package-level var) because `proxy.Proxy()` reads `setting.Proxy` when invoked; building it at request time ensures it reflects the loaded settings. When no proxy is configured, behavior is unchanged (`proxy.Proxy()` returns a no-op / `http.ProxyFromEnvironment`). --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -12,9 +12,20 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
|
"gitea.dev/modules/proxy"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// httpClient returns an HTTP client that honors Gitea's proxy configuration.
|
||||||
|
var httpClient = util.OnceValue[*http.Client]{
|
||||||
|
Func: func() *http.Client {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.Proxy = proxy.Proxy()
|
||||||
|
return &http.Client{Transport: transport}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// Response is the structure of JSON returned from API
|
// Response is the structure of JSON returned from API
|
||||||
type Response struct {
|
type Response struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -40,7 +51,7 @@ func Verify(ctx context.Context, response string) (bool, error) {
|
|||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := httpClient.Value().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("Failed to send CAPTCHA response: %w", err)
|
return false, fmt.Errorf("Failed to send CAPTCHA response: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package turnstile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHTTPClientHonorsProxy(t *testing.T) {
|
||||||
|
proxyURL, err := url.Parse("http://proxy.example.com:3128")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer test.MockVariableValue(&setting.Proxy.Enabled, true)()
|
||||||
|
defer test.MockVariableValue(&setting.Proxy.ProxyURL, proxyURL.String())()
|
||||||
|
defer test.MockVariableValue(&setting.Proxy.ProxyURLFixed, proxyURL)()
|
||||||
|
defer test.MockVariableValue(&setting.Proxy.ProxyHosts, []string{"**"})()
|
||||||
|
httpClient.Reset()
|
||||||
|
transport, ok := httpClient.Value().Transport.(*http.Transport)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotNil(t, transport.Proxy)
|
||||||
|
|
||||||
|
// The Turnstile verification request must be routed through the configured proxy.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "https://any.example.com", nil)
|
||||||
|
got, err := transport.Proxy(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, proxyURL.String(), got.String())
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
type onceValueResult[T any] struct {
|
||||||
|
value T
|
||||||
|
panic any
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnceValue is similar to Golang's "sync.OnceValue", but can be reset.
|
||||||
|
type OnceValue[T any] struct {
|
||||||
|
Func func() T
|
||||||
|
mu sync.Mutex
|
||||||
|
res atomic.Pointer[onceValueResult[T]]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OnceValue[T]) Value() T {
|
||||||
|
res := o.res.Load()
|
||||||
|
if res == nil {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
res = o.res.Load()
|
||||||
|
if res == nil {
|
||||||
|
res = &onceValueResult[T]{}
|
||||||
|
defer func() {
|
||||||
|
res.panic = recover()
|
||||||
|
o.res.Store(res)
|
||||||
|
if res.panic != nil {
|
||||||
|
panic(res.panic)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
res.value = o.Func()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if res.panic != nil {
|
||||||
|
panic(res.panic)
|
||||||
|
}
|
||||||
|
return res.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OnceValue[T]) Reset() {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
o.res.Store(nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOnceValue(t *testing.T) {
|
||||||
|
t.Run("RepeatCall", func(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
o := OnceValue[int]{Func: func() int {
|
||||||
|
callCount++
|
||||||
|
return 42
|
||||||
|
}}
|
||||||
|
assert.Equal(t, 42, o.Value())
|
||||||
|
assert.Equal(t, 42, o.Value())
|
||||||
|
assert.Equal(t, 1, callCount)
|
||||||
|
o.Reset()
|
||||||
|
assert.Equal(t, 42, o.Value())
|
||||||
|
assert.Equal(t, 2, callCount)
|
||||||
|
assert.Equal(t, 42, o.Value())
|
||||||
|
assert.Equal(t, 2, callCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Panic", func(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
doPanic := true
|
||||||
|
o := OnceValue[int]{Func: func() int {
|
||||||
|
callCount++
|
||||||
|
if doPanic {
|
||||||
|
panic("some error")
|
||||||
|
}
|
||||||
|
return 42
|
||||||
|
}}
|
||||||
|
assert.PanicsWithValue(t, "some error", func() { o.Value() })
|
||||||
|
assert.PanicsWithValue(t, "some error", func() { o.Value() })
|
||||||
|
assert.Equal(t, 1, callCount)
|
||||||
|
doPanic = false
|
||||||
|
o.Reset()
|
||||||
|
assert.Equal(t, 42, o.Value())
|
||||||
|
assert.Equal(t, 2, callCount)
|
||||||
|
assert.Equal(t, 42, o.Value())
|
||||||
|
assert.Equal(t, 2, callCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user