Skip to content

Commit 884a9aa

Browse files
committed
fix: address Turnstile review feedback
1 parent 2260088 commit 884a9aa

11 files changed

Lines changed: 189 additions & 37 deletions

File tree

backend/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ observability:
109109
- `auth:turnstile_registration_enabled`:是否在邮箱注册时启用 Turnstile。
110110
- `auth:turnstile_site_key`:前端渲染 Turnstile 组件使用的 Site Key,会通过 `/api/v1/auth/login-options` 返回。
111111
- `auth:turnstile_secret_key`:后端调用 Cloudflare siteverify 使用的 Secret Key,属于敏感设置。
112+
- `TURNSTILE_SITEVERIFY_URL` / `security.turnstile_siteverify_url`:可选覆盖 siteverify 端点,默认使用 Cloudflare 官方地址。
112113

113114
启用 Turnstile 需要同时启用 `auth:email_registration_enabled`,并配置 Site Key 与 Secret Key。开启邮箱验证码注册时,前端在 `/api/v1/auth/register/email/start` 提交 `turnstileToken`;关闭邮箱验证码但允许邮箱注册时,前端在 `/api/v1/auth/register/email/complete` 提交 `turnstileToken`。
114115

backend/internal/application/auth/registration.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ func (s *Service) RegisterWithEmail(ctx context.Context, email string, password
161161
if err != nil {
162162
return nil, err
163163
}
164+
// With email verification enabled, Turnstile is checked before issuing the registration code.
165+
// Without that step, completion is the first registration write path and must verify it here.
164166
if !cfg.EmailVerificationEnabled {
165167
if err = s.verifyRegistrationTurnstile(ctx, cfg, turnstileToken, remoteIP); err != nil {
166168
return nil, err

backend/internal/application/auth/registration_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package auth
22

33
import (
44
"context"
5+
"net/http"
6+
"net/http/httptest"
57
"strings"
68
"testing"
79
"time"
@@ -196,6 +198,63 @@ func TestRequestEmailRegistrationRequiresTurnstileWhenEnabled(t *testing.T) {
196198
}
197199
}
198200

201+
func TestVerifyRegistrationTurnstileSkipsWhenSiteKeyEmpty(t *testing.T) {
202+
service := NewService(config.Config{}, nil, nil)
203+
204+
err := service.verifyRegistrationTurnstile(context.Background(), config.Config{
205+
TurnstileRegistrationEnabled: true,
206+
TurnstileSecretKey: "secret-key",
207+
}, "", "")
208+
if err != nil {
209+
t.Fatalf("expected empty site key to skip turnstile, got %v", err)
210+
}
211+
}
212+
213+
func TestVerifyRegistrationTurnstileRequiresSecretWhenSiteKeyPresent(t *testing.T) {
214+
service := NewService(config.Config{}, nil, nil)
215+
216+
err := service.verifyRegistrationTurnstile(context.Background(), config.Config{
217+
TurnstileRegistrationEnabled: true,
218+
TurnstileSiteKey: "site-key",
219+
}, "token", "")
220+
if err == nil || err.Error() != "turnstile is not configured" {
221+
t.Fatalf("expected missing secret error, got %v", err)
222+
}
223+
}
224+
225+
func TestVerifyRegistrationTurnstileUsesConfiguredEndpoint(t *testing.T) {
226+
var gotRemoteIP string
227+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
228+
if r.Method != http.MethodPost {
229+
t.Fatalf("expected POST, got %s", r.Method)
230+
}
231+
if err := r.ParseForm(); err != nil {
232+
t.Fatalf("parse turnstile form: %v", err)
233+
}
234+
if r.Form.Get("secret") != "secret-key" || r.Form.Get("response") != "token" {
235+
t.Fatalf("unexpected form: %#v", r.Form)
236+
}
237+
gotRemoteIP = r.Form.Get("remoteip")
238+
w.Header().Set("Content-Type", "application/json")
239+
_, _ = w.Write([]byte(`{"success":true}`))
240+
}))
241+
defer server.Close()
242+
243+
service := NewService(config.Config{}, nil, nil)
244+
err := service.verifyRegistrationTurnstile(context.Background(), config.Config{
245+
TurnstileRegistrationEnabled: true,
246+
TurnstileSiteKey: "site-key",
247+
TurnstileSecretKey: "secret-key",
248+
TurnstileSiteverifyURL: server.URL,
249+
}, "token", "203.0.113.7")
250+
if err != nil {
251+
t.Fatalf("expected configured endpoint verification to pass, got %v", err)
252+
}
253+
if gotRemoteIP != "203.0.113.7" {
254+
t.Fatalf("expected remote ip to be forwarded, got %q", gotRemoteIP)
255+
}
256+
}
257+
199258
func TestRegisterWithEmailRequiresTurnstileWhenEmailVerificationDisabled(t *testing.T) {
200259
service := NewService(config.Config{
201260
EmailLoginEnabled: true,
@@ -212,6 +271,22 @@ func TestRegisterWithEmailRequiresTurnstileWhenEmailVerificationDisabled(t *test
212271
}
213272
}
214273

274+
func TestRegisterWithEmailDoesNotRequireTurnstileWhenEmailVerificationEnabled(t *testing.T) {
275+
service := NewService(config.Config{
276+
EmailLoginEnabled: true,
277+
EmailRegistrationEnabled: true,
278+
EmailVerificationEnabled: true,
279+
TurnstileRegistrationEnabled: true,
280+
TurnstileSiteKey: "site-key",
281+
TurnstileSecretKey: "secret-key",
282+
}, &emailRegistrationRepo{}, nil)
283+
284+
_, err := service.RegisterWithEmail(context.Background(), "user@example.com", "securepass1", "", "", "127.0.0.1", "", requestmeta.SessionAuditContext{})
285+
if err == nil || err.Error() != "verification code is invalid or expired" {
286+
t.Fatalf("expected verification code error instead of turnstile error, got %v", err)
287+
}
288+
}
289+
215290
func TestResolveSecurityVerificationMethod(t *testing.T) {
216291
now := time.Now()
217292
cases := []struct {

backend/internal/application/auth/service.go

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,11 @@ type Service struct {
4343
geoResolver *geoip.Client
4444
subscriptionResolver subscriptionResolver
4545
providerHTTPClient *http.Client
46-
turnstileHTTPClient httpDoer
4746
logger *zap.Logger
4847
storeProvider appstorage.Provider
4948
auditWriter auditWriter
5049
}
5150

52-
type httpDoer interface {
53-
Do(*http.Request) (*http.Response, error)
54-
}
55-
5651
type subscriptionResolver interface {
5752
GetCurrentSubscriptionSnapshot(
5853
ctx context.Context,
@@ -80,14 +75,12 @@ func NewServiceWithRuntime(cfg *config.Runtime, repo repository.AuthRepository,
8075
ssrfProtectionEnabled = snapshot.SSRFProtectionEnabled
8176
}
8277
providerHTTPClient := newAuthOutboundHTTPClient(env, ssrfProtectionEnabled)
83-
turnstileHTTPClient := newAuthOutboundHTTPClient(env, ssrfProtectionEnabled)
8478
return &Service{
85-
cfg: cfg,
86-
repo: repo,
87-
geoResolver: geoResolver,
88-
providerHTTPClient: providerHTTPClient,
89-
turnstileHTTPClient: turnstileHTTPClient,
90-
storeProvider: appstorage.NewRuntimeProvider(cfg, nil),
79+
cfg: cfg,
80+
repo: repo,
81+
geoResolver: geoResolver,
82+
providerHTTPClient: providerHTTPClient,
83+
storeProvider: appstorage.NewRuntimeProvider(cfg, nil),
9184
}
9285
}
9386

backend/internal/application/auth/turnstile.go

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,25 @@ package auth
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"net/http"
910
"net/url"
1011
"strings"
1112

1213
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
13-
"go.uber.org/zap"
1414
)
1515

1616
const (
17-
turnstileSiteverifyEndpoint = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
18-
turnstileTokenMaxLength = 2048
17+
turnstileTokenMaxLength = 2048
18+
)
19+
20+
var (
21+
errTurnstileNotConfigured = errors.New("turnstile is not configured")
22+
errTurnstileRequired = errors.New("turnstile verification is required")
23+
errTurnstileTokenTooLong = errors.New("turnstile token is too long")
24+
errTurnstileFailed = errors.New("turnstile verification failed")
1925
)
2026

2127
type turnstileSiteverifyResponse struct {
@@ -28,17 +34,20 @@ func (s *Service) verifyRegistrationTurnstile(ctx context.Context, cfg config.Co
2834
return nil
2935
}
3036
siteKey := strings.TrimSpace(cfg.TurnstileSiteKey)
37+
if siteKey == "" {
38+
return nil
39+
}
3140
secretKey := strings.TrimSpace(cfg.TurnstileSecretKey)
32-
if siteKey == "" || secretKey == "" {
33-
return fmt.Errorf("turnstile is not configured")
41+
if secretKey == "" {
42+
return errTurnstileNotConfigured
3443
}
3544

3645
token := strings.TrimSpace(tokenValue)
3746
if token == "" {
38-
return fmt.Errorf("turnstile verification is required")
47+
return errTurnstileRequired
3948
}
4049
if len(token) > turnstileTokenMaxLength {
41-
return fmt.Errorf("turnstile token is too long")
50+
return errTurnstileTokenTooLong
4251
}
4352

4453
form := url.Values{}
@@ -48,33 +57,32 @@ func (s *Service) verifyRegistrationTurnstile(ctx context.Context, cfg config.Co
4857
form.Set("remoteip", ip)
4958
}
5059

51-
request, err := http.NewRequestWithContext(ctx, http.MethodPost, turnstileSiteverifyEndpoint, strings.NewReader(form.Encode()))
60+
endpoint := strings.TrimSpace(cfg.TurnstileSiteverifyURL)
61+
if endpoint == "" {
62+
endpoint = config.DefaultTurnstileSiteverifyURL
63+
}
64+
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
5265
if err != nil {
5366
return err
5467
}
5568
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
5669
request.Header.Set("Accept", "application/json")
5770

58-
client := s.turnstileHTTPClient
59-
if client == nil {
60-
s.warn("turnstile_siteverify_client_missing")
61-
return fmt.Errorf("turnstile verification failed")
62-
}
63-
response, err := client.Do(request)
71+
response, err := s.providerHTTPClient.Do(request)
6472
if err != nil {
65-
s.warn("turnstile_siteverify_request_failed", zap.Error(err))
66-
return fmt.Errorf("turnstile verification failed")
73+
s.warn("turnstile siteverify request failed: " + err.Error())
74+
return errTurnstileFailed
6775
}
6876
defer response.Body.Close()
6977

7078
var result turnstileSiteverifyResponse
7179
if err = json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&result); err != nil {
72-
s.warn("turnstile_siteverify_decode_failed", zap.Int("status", response.StatusCode), zap.Error(err))
73-
return fmt.Errorf("turnstile verification failed")
80+
s.warn(fmt.Sprintf("turnstile siteverify decode failed: status=%d error=%v", response.StatusCode, err))
81+
return errTurnstileFailed
7482
}
7583
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || !result.Success {
76-
s.warn("turnstile_siteverify_rejected", zap.Int("status", response.StatusCode), zap.Strings("error_codes", result.ErrorCodes))
77-
return fmt.Errorf("turnstile verification failed")
84+
s.warn(fmt.Sprintf("turnstile siteverify rejected: status=%d error_codes=%s", response.StatusCode, strings.Join(result.ErrorCodes, ",")))
85+
return errTurnstileFailed
7886
}
7987
return nil
8088
}

backend/internal/infra/config/config.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const (
2626
)
2727

2828
const (
29+
// DefaultTurnstileSiteverifyURL 是 Cloudflare Turnstile 默认校验端点。
30+
DefaultTurnstileSiteverifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
31+
2932
// DefaultMCPMaxSelectedToolsPerMessage 是单次消息可选择 MCP 工具数量的默认值。
3033
DefaultMCPMaxSelectedToolsPerMessage = 32
3134
// MaxMCPSelectedToolsPerMessage 是运行时配置允许的安全上限,防止一次请求暴露过多工具 schema。
@@ -204,9 +207,10 @@ type yamlConfig struct {
204207
MaxHeaderBytes int `yaml:"max_header_bytes"`
205208
} `yaml:"server"`
206209
Security struct {
207-
JWTSecret string `yaml:"jwt_secret"`
208-
DataEncryptionKey string `yaml:"data_encryption_key"`
209-
SSRFProtectionEnabled *bool `yaml:"ssrf_protection_enabled"`
210+
JWTSecret string `yaml:"jwt_secret"`
211+
DataEncryptionKey string `yaml:"data_encryption_key"`
212+
SSRFProtectionEnabled *bool `yaml:"ssrf_protection_enabled"`
213+
TurnstileSiteverifyURL string `yaml:"turnstile_siteverify_url"`
210214
} `yaml:"security"`
211215
Database struct {
212216
Postgres struct {
@@ -309,6 +313,7 @@ type Config struct {
309313
SMTPUsername string
310314
SMTPPassword string
311315
SMTPFrom string
316+
TurnstileSiteverifyURL string
312317
OTelEnabled *bool
313318
OTelExporterOTLPEndpoint string
314319
OTelExporterOTLPHeaders string
@@ -512,6 +517,7 @@ func Load() Config {
512517
SMTPUsername: "",
513518
SMTPPassword: "",
514519
SMTPFrom: "",
520+
TurnstileSiteverifyURL: envOr("TURNSTILE_SITEVERIFY_URL", yc.Security.TurnstileSiteverifyURL, DefaultTurnstileSiteverifyURL),
515521
OTelEnabled: envOrBoolOptional("OTEL_ENABLED", yc.Observability.Tracing.Enabled),
516522
OTelExporterOTLPEndpoint: envOr("OTEL_EXPORTER_OTLP_ENDPOINT", yc.Observability.Tracing.Endpoint, ""),
517523
OTelExporterOTLPHeaders: envOr("OTEL_EXPORTER_OTLP_HEADERS", yc.Observability.Tracing.Headers, ""),

backend/internal/infra/config/config_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,31 @@ geoip:
9797
assertPath(t, "geoip database", cfg.GeoIPDatabasePath, filepath.Join(root, "data", "geoip.mmdb"))
9898
}
9999

100+
func TestLoadReadsTurnstileSiteverifyURL(t *testing.T) {
101+
cleanupConfigEnv(t)
102+
103+
configPath := filepath.Join(t.TempDir(), "config.yaml")
104+
configBody := []byte(`
105+
security:
106+
turnstile_siteverify_url: "https://turnstile.example.test/siteverify"
107+
`)
108+
if err := os.WriteFile(configPath, configBody, 0o644); err != nil {
109+
t.Fatalf("write config: %v", err)
110+
}
111+
t.Setenv("CONFIG_FILE", configPath)
112+
113+
cfg := Load()
114+
if cfg.TurnstileSiteverifyURL != "https://turnstile.example.test/siteverify" {
115+
t.Fatalf("expected turnstile siteverify url from config, got %q", cfg.TurnstileSiteverifyURL)
116+
}
117+
118+
t.Setenv("TURNSTILE_SITEVERIFY_URL", "https://turnstile-env.example.test/siteverify")
119+
cfg = Load()
120+
if cfg.TurnstileSiteverifyURL != "https://turnstile-env.example.test/siteverify" {
121+
t.Fatalf("expected turnstile siteverify url from env, got %q", cfg.TurnstileSiteverifyURL)
122+
}
123+
}
124+
100125
func TestValidateAllowsOnlyDevAndProdEnvironment(t *testing.T) {
101126
tests := []struct {
102127
name string
@@ -145,6 +170,7 @@ func cleanupConfigEnv(t *testing.T) {
145170
"FRONTEND_DIST_DIR",
146171
"STORAGE_ROOT_DIR",
147172
"GEOIP_DATABASE_PATH",
173+
"TURNSTILE_SITEVERIFY_URL",
148174
}
149175
for _, key := range keys {
150176
key := key

backend/internal/infra/llm/openai_images.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ func applyOpenAIImageEditStreamParams(fields map[string]string, options map[stri
471471
if !ok {
472472
return
473473
}
474-
if value >= 0 {
474+
if value > 0 {
475475
fields["partial_images"] = fmt.Sprintf("%d", value)
476476
}
477477
}
@@ -554,7 +554,7 @@ func applyOpenAIImageGenerationStreamParams(payload map[string]interface{}, opti
554554
if !ok {
555555
return
556556
}
557-
if value >= 0 {
557+
if value > 0 {
558558
payload["partial_images"] = value
559559
}
560560
}

backend/internal/infra/llm/openai_images_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ func TestBuildOpenAIImageGenerationStreamRequestBodyOmitsDefaultPartialImages(t
8080
}
8181
}
8282

83+
func TestBuildOpenAIImageGenerationStreamRequestBodyOmitsZeroPartialImages(t *testing.T) {
84+
payload, err := buildOpenAIImageGenerationStreamRequestBody("gpt-image-1", GenerateInput{
85+
Messages: []Message{{Role: "user", Content: "A clean product render"}},
86+
Options: map[string]interface{}{"partial_images": 0},
87+
})
88+
if err != nil {
89+
t.Fatalf("build image stream request body: %v", err)
90+
}
91+
if _, ok := payload["partial_images"]; ok {
92+
t.Fatalf("partial_images must only be sent when configured as a positive value, got %#v", payload)
93+
}
94+
}
95+
8396
func TestBuildOpenAIImageGenerationRequestBodyDallEParams(t *testing.T) {
8497
payload, err := buildOpenAIImageGenerationRequestBody("dall-e-3", GenerateInput{
8598
Messages: []Message{{Role: "user", Content: "A clean product render"}},
@@ -183,6 +196,30 @@ func TestBuildOpenAIImageEditStreamMultipartRequest(t *testing.T) {
183196
}
184197
}
185198

199+
func TestBuildOpenAIImageEditStreamMultipartRequestOmitsZeroPartialImages(t *testing.T) {
200+
body, contentType, _, err := buildOpenAIImageEditMultipartRequest("gpt-image-1", GenerateInput{
201+
Messages: []Message{{
202+
Role: "user",
203+
Parts: []ContentPart{
204+
{Kind: ContentPartText, Text: "Replace the background"},
205+
{Kind: ContentPartImage, MimeType: "image/png", Data: []byte("source")},
206+
},
207+
}},
208+
Options: map[string]interface{}{"partial_images": 0},
209+
}, true)
210+
if err != nil {
211+
t.Fatalf("build image edit stream multipart request: %v", err)
212+
}
213+
req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(body))
214+
req.Header.Set("Content-Type", contentType)
215+
if err = req.ParseMultipartForm(10 << 20); err != nil {
216+
t.Fatalf("parse multipart body: %v", err)
217+
}
218+
if _, ok := req.MultipartForm.Value["partial_images"]; ok {
219+
t.Fatalf("partial_images must only be sent when configured as a positive value, got %#v", req.MultipartForm.Value)
220+
}
221+
}
222+
186223
func TestParseOpenAIImageGenerationOutput(t *testing.T) {
187224
output, err := parseOpenAIImageOutput([]byte(`{
188225
"created": 1713833628,

0 commit comments

Comments
 (0)