From 8bfd64f7ed2f163cbb0f11b051b0598177a9d8d1 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 7 Jun 2026 14:15:43 +0100 Subject: [PATCH] fix: address code review (v3/v2 detect, gemini caps, fetch timeout, audio mime, json extract, dead temp config) - detect: recaptcha_v3 now suppresses recaptcha_v2 (hidden g-recaptcha div on v3 pages no longer mis-detected as LLM-solvable); funcaptcha drops over-broad bare "arkose" substring - llm/gemini: stub methods return ErrUnsupported{Provider:"gemini"} so audio handler errors.As guard catches it; drop unused fmt import - mcp/tools: remove gemini from audio capability (stub); pick audio filename (.wav/.mp3) from sniffed media type; schema notes for browser/actions no longer advertise unimplemented options - imageutil: fetchURL uses a package client with 30s Timeout instead of http.DefaultClient - captcha/json: extractJSON brace-counts to return the first balanced object (handles prose, markdown fences, example-before-real) - config: remove dead CAPTCHA_LLM_TEMPERATURE / Config.Temperature; solving is fixed at temperature 0 - stealth: Action.Type doc comment drops nonexistent scroll case --- .env.example | 1 - internal/captcha/detect.go | 5 ++--- internal/captcha/detect_test.go | 18 +++++++++++++++- internal/captcha/json.go | 27 +++++++++++++++++------ internal/captcha/json_test.go | 38 +++++++++++++++++++++++++++++++++ internal/config/config.go | 27 ++++++++++------------- internal/imageutil/imageutil.go | 4 +++- internal/llm/gemini.go | 5 ++--- internal/mcp/tools.go | 15 ++++++++----- internal/stealth/interaction.go | 2 +- 10 files changed, 105 insertions(+), 37 deletions(-) create mode 100644 internal/captcha/json_test.go diff --git a/.env.example b/.env.example index 226759a..1ad6def 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,6 @@ CAPTCHA_LLM_API_KEY=sk-... CAPTCHA_LLM_MODEL=claude-opus-4-8 # Optional separate audio/transcription model (e.g. whisper-1). Empty = use MODEL. CAPTCHA_LLM_AUDIO_MODEL= -CAPTCHA_LLM_TEMPERATURE=0 CAPTCHA_LLM_MAX_TOKENS=1024 CAPTCHA_LLM_TIMEOUT=60s CAPTCHA_LOG_LEVEL=info diff --git a/internal/captcha/detect.go b/internal/captcha/detect.go index 50d4035..e5200a5 100644 --- a/internal/captcha/detect.go +++ b/internal/captcha/detect.go @@ -21,8 +21,7 @@ func DetectHTML(html string) []Detection { Type: "recaptcha_v3", Sitekey: m[1], LLMSolvable: false, Notes: "score/behavioral — not LLM-solvable; needs browser reputation", }) - } - if strings.Contains(html, "g-recaptcha") || strings.Contains(html, "google.com/recaptcha/api2") { + } else if strings.Contains(html, "g-recaptcha") || strings.Contains(html, "google.com/recaptcha/api2") { out = append(out, Detection{Type: "recaptcha_v2", Sitekey: firstSitekey(html), LLMSolvable: true, Notes: "image-grid; LLM returns tiles, caller clicks + harvests token"}) } @@ -34,7 +33,7 @@ func DetectHTML(html string) []Detection { out = append(out, Detection{Type: "turnstile", Sitekey: firstSitekey(html), LLMSolvable: false, Notes: "proof-of-work/behavioral — not LLM-solvable"}) } - if strings.Contains(html, "arkoselabs") || strings.Contains(html, "funcaptcha") || strings.Contains(html, "arkose") { + if strings.Contains(html, "arkoselabs") || strings.Contains(html, "funcaptcha") { out = append(out, Detection{Type: "funcaptcha", Sitekey: firstPkey(html), LLMSolvable: true, Notes: "rotation/visual — best-effort"}) } diff --git a/internal/captcha/detect_test.go b/internal/captcha/detect_test.go index 1faa8cf..5db090c 100644 --- a/internal/captcha/detect_test.go +++ b/internal/captcha/detect_test.go @@ -13,7 +13,7 @@ func TestDetectHTML(t *testing.T) { {"recaptcha v3", ``, "recaptcha_v3", false}, {"hcaptcha", `
`, "hcaptcha", true}, {"turnstile", `
`, "turnstile", false}, - {"funcaptcha", `
`, "funcaptcha", true}, + {"funcaptcha", `
`, "funcaptcha", true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -27,3 +27,19 @@ func TestDetectHTML(t *testing.T) { }) } } + +// A v3 page often also carries a hidden g-recaptcha div; only v3 must fire. +func TestDetectHTMLV3SuppressesV2(t *testing.T) { + html := `` + + `` + got := DetectHTML(html) + if len(got) != 1 { + t.Fatalf("want exactly 1 detection, got %d: %+v", len(got), got) + } + if got[0].Type != "recaptcha_v3" { + t.Fatalf("want recaptcha_v3, got %q", got[0].Type) + } + if got[0].LLMSolvable { + t.Fatalf("recaptcha_v3 must not be LLMSolvable") + } +} diff --git a/internal/captcha/json.go b/internal/captcha/json.go index 47c48e9..274b2c6 100644 --- a/internal/captcha/json.go +++ b/internal/captcha/json.go @@ -2,12 +2,27 @@ package captcha import "strings" -// extractJSON returns the first {...} JSON object found in s, or s unchanged. +// extractJSON returns the first balanced {...} object found in s, or s unchanged +// if no opening brace is present. It scans by brace depth so prose, markdown +// fences, or an example object before the real one do not corrupt parsing. +// Note: it does not account for braces inside string literals; that is +// acceptable for the short, structured captcha responses this handles. func extractJSON(s string) string { - start := strings.Index(s, "{") - end := strings.LastIndex(s, "}") - if start >= 0 && end > start { - return s[start : end+1] + start := strings.IndexByte(s, '{') + if start < 0 { + return s } - return s + depth := 0 + for i := start; i < len(s); i++ { + switch s[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return s[start : i+1] + } + } + } + return s[start:] } diff --git a/internal/captcha/json_test.go b/internal/captcha/json_test.go new file mode 100644 index 0000000..564151b --- /dev/null +++ b/internal/captcha/json_test.go @@ -0,0 +1,38 @@ +package captcha + +import "testing" + +func TestExtractJSON(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"plain", `{"text":"ab12"}`, `{"text":"ab12"}`}, + {"prose wrapped", `Sure, here is the answer: {"text":"ab12"} hope it helps`, `{"text":"ab12"}`}, + { + "markdown fenced", + "```json\n{\"tiles\":[1,2,3]}\n```", + `{"tiles":[1,2,3]}`, + }, + { + "nested object", + `prefix {"a":{"b":1},"c":2} suffix`, + `{"a":{"b":1},"c":2}`, + }, + { + "first balanced object wins", + `For example {"text":"EXAMPLE"} but the real one is {"text":"REAL"}`, + `{"text":"EXAMPLE"}`, + }, + {"no brace", `no json here`, `no json here`}, + {"unbalanced returns from start", `tail {"text":"x"`, `{"text":"x"`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := extractJSON(tc.in); got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index f3bb9ed..98e7cca 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,16 +10,18 @@ import ( // Config is the validated runtime configuration. Fields are ordered // largest-alignment-first to satisfy the fieldalignment linter. +// +// Temperature is intentionally not configurable: captcha solving is fixed at +// temperature 0 for deterministic results (see the LLM provider request bodies). type Config struct { - Provider string - BaseURL string - APIKey string - Model string - AudioModel string - LogLevel string - Temperature float64 - Timeout time.Duration - MaxTokens int + Provider string + BaseURL string + APIKey string + Model string + AudioModel string + LogLevel string + Timeout time.Duration + MaxTokens int } var validProviders = map[string]bool{ @@ -47,13 +49,6 @@ func Load() (Config, error) { if c.Model == "" { return Config{}, fmt.Errorf("CAPTCHA_LLM_MODEL is required") } - if v := os.Getenv("CAPTCHA_LLM_TEMPERATURE"); v != "" { - f, err := strconv.ParseFloat(v, 64) - if err != nil { - return Config{}, fmt.Errorf("CAPTCHA_LLM_TEMPERATURE: %w", err) - } - c.Temperature = f - } if v := os.Getenv("CAPTCHA_LLM_MAX_TOKENS"); v != "" { n, err := strconv.Atoi(v) if err != nil { diff --git a/internal/imageutil/imageutil.go b/internal/imageutil/imageutil.go index 7582973..597aaac 100644 --- a/internal/imageutil/imageutil.go +++ b/internal/imageutil/imageutil.go @@ -16,6 +16,8 @@ import ( var dataURIRe = regexp.MustCompile(`^data:([^;,]+)(;base64)?,(.*)$`) +var fetchClient = &http.Client{Timeout: 30 * time.Second} + // Point is a pixel coordinate. type Point struct { X int @@ -65,7 +67,7 @@ func fetchURL(url string) ([]byte, string, error) { if err != nil { return nil, "", err } - resp, err := http.DefaultClient.Do(req) + resp, err := fetchClient.Do(req) if err != nil { return nil, "", err } diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 2b82a54..b9a54e1 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -2,7 +2,6 @@ package llm import ( "context" - "fmt" "net/http" ) @@ -16,9 +15,9 @@ type geminiClient struct { } func (c *geminiClient) Vision(_ context.Context, _ string, _ []Image, _ Options) (string, error) { - return "", fmt.Errorf("gemini provider not yet implemented") + return "", ErrUnsupported{Provider: "gemini"} } func (c *geminiClient) Transcribe(_ context.Context, _ []byte, _ string, _ Options) (string, error) { - return "", fmt.Errorf("gemini provider not yet implemented") + return "", ErrUnsupported{Provider: "gemini"} } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 7a1374b..ddef847 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -73,11 +73,15 @@ type SolveAudioIn struct { } func (h *Handlers) solveAudio(ctx context.Context, _ *mcp.CallToolRequest, in SolveAudioIn) (*mcp.CallToolResult, captcha.AudioResult, error) { - data, _, err := imageutil.Decode(in.Audio) + data, mt, err := imageutil.Decode(in.Audio) if err != nil { return nil, captcha.AudioResult{}, err } - res, err := captcha.SolveAudio(ctx, h.llm, data, "audio.wav", in.Language) + filename := "audio.wav" + if mt == "audio/mpeg" { + filename = "audio.mp3" + } + res, err := captcha.SolveAudio(ctx, h.llm, data, filename, in.Language) var unsupported llm.ErrUnsupported if errors.As(err, &unsupported) { return nil, captcha.AudioResult{}, fmt.Errorf("audio captcha unsupported: configured provider %q has no transcription; set CAPTCHA_LLM_PROVIDER=openai|gemini and CAPTCHA_LLM_AUDIO_MODEL", h.provider) @@ -137,7 +141,7 @@ func (h *Handlers) stealthScript(_ context.Context, _ *mcp.CallToolRequest, in S type FingerprintIn struct { OS string `json:"os,omitempty" jsonschema:"windows | macos | linux"` - Browser string `json:"browser,omitempty" jsonschema:"chrome | firefox"` + Browser string `json:"browser,omitempty" jsonschema:"chrome (firefox not yet differentiated)"` Locale string `json:"locale,omitempty"` Seed uint64 `json:"seed,omitempty" jsonschema:"seed for a stable identity across runs"` } @@ -152,7 +156,7 @@ func (h *Handlers) fingerprint(_ context.Context, _ *mcp.CallToolRequest, in Fin } type PlanInteractionIn struct { - Actions []stealth.Action `json:"actions" jsonschema:"clicks/types/scrolls to humanize"` + Actions []stealth.Action `json:"actions" jsonschema:"actions to humanize (click | type)"` Seed uint64 `json:"seed,omitempty"` } @@ -170,7 +174,8 @@ type SolverInfoOut struct { } func (h *Handlers) solverInfo(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, SolverInfoOut, error) { - audio := h.provider == "openai" || h.provider == "openai-compatible" || h.provider == "gemini" + // gemini is a stub; re-add when implemented + audio := h.provider == "openai" || h.provider == "openai-compatible" return nil, SolverInfoOut{ Provider: h.provider, Model: h.model, Capabilities: map[string]bool{ diff --git a/internal/stealth/interaction.go b/internal/stealth/interaction.go index aa2d85a..611811e 100644 --- a/internal/stealth/interaction.go +++ b/internal/stealth/interaction.go @@ -7,7 +7,7 @@ import ( // Action is a high-level target the caller wants to perform. type Action struct { - Type string `json:"type"` // click | type | scroll + Type string `json:"type"` // click | type Text string `json:"text,omitempty"` X int `json:"x,omitempty"` Y int `json:"y,omitempty"`