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
This commit is contained in:
2026-06-07 14:15:43 +01:00
parent 9265bc55ec
commit 8bfd64f7ed
10 changed files with 105 additions and 37 deletions
-1
View File
@@ -7,7 +7,6 @@ CAPTCHA_LLM_API_KEY=sk-...
CAPTCHA_LLM_MODEL=claude-opus-4-8 CAPTCHA_LLM_MODEL=claude-opus-4-8
# Optional separate audio/transcription model (e.g. whisper-1). Empty = use MODEL. # Optional separate audio/transcription model (e.g. whisper-1). Empty = use MODEL.
CAPTCHA_LLM_AUDIO_MODEL= CAPTCHA_LLM_AUDIO_MODEL=
CAPTCHA_LLM_TEMPERATURE=0
CAPTCHA_LLM_MAX_TOKENS=1024 CAPTCHA_LLM_MAX_TOKENS=1024
CAPTCHA_LLM_TIMEOUT=60s CAPTCHA_LLM_TIMEOUT=60s
CAPTCHA_LOG_LEVEL=info CAPTCHA_LOG_LEVEL=info
+2 -3
View File
@@ -21,8 +21,7 @@ func DetectHTML(html string) []Detection {
Type: "recaptcha_v3", Sitekey: m[1], LLMSolvable: false, Type: "recaptcha_v3", Sitekey: m[1], LLMSolvable: false,
Notes: "score/behavioral — not LLM-solvable; needs browser reputation", Notes: "score/behavioral — not LLM-solvable; needs browser reputation",
}) })
} } else if strings.Contains(html, "g-recaptcha") || strings.Contains(html, "google.com/recaptcha/api2") {
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, out = append(out, Detection{Type: "recaptcha_v2", Sitekey: firstSitekey(html), LLMSolvable: true,
Notes: "image-grid; LLM returns tiles, caller clicks + harvests token"}) 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, out = append(out, Detection{Type: "turnstile", Sitekey: firstSitekey(html), LLMSolvable: false,
Notes: "proof-of-work/behavioral — not LLM-solvable"}) 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, out = append(out, Detection{Type: "funcaptcha", Sitekey: firstPkey(html), LLMSolvable: true,
Notes: "rotation/visual — best-effort"}) Notes: "rotation/visual — best-effort"})
} }
+17 -1
View File
@@ -13,7 +13,7 @@ func TestDetectHTML(t *testing.T) {
{"recaptcha v3", `<script src="https://www.google.com/recaptcha/api.js?render=6Lc_v3key"></script>`, "recaptcha_v3", false}, {"recaptcha v3", `<script src="https://www.google.com/recaptcha/api.js?render=6Lc_v3key"></script>`, "recaptcha_v3", false},
{"hcaptcha", `<div class="h-captcha" data-sitekey="hk_123"></div>`, "hcaptcha", true}, {"hcaptcha", `<div class="h-captcha" data-sitekey="hk_123"></div>`, "hcaptcha", true},
{"turnstile", `<div class="cf-turnstile" data-sitekey="0x4AAA"></div>`, "turnstile", false}, {"turnstile", `<div class="cf-turnstile" data-sitekey="0x4AAA"></div>`, "turnstile", false},
{"funcaptcha", `<div id="arkose" data-pkey="ABC"></div>`, "funcaptcha", true}, {"funcaptcha", `<div class="arkoselabs" data-pkey="ABC"></div>`, "funcaptcha", true},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { 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 := `<script src="https://www.google.com/recaptcha/api.js?render=6Lc_v3key"></script>` +
`<div class="g-recaptcha" data-sitekey="6Lc_hidden" style="display:none"></div>`
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")
}
}
+21 -6
View File
@@ -2,12 +2,27 @@ package captcha
import "strings" 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 { func extractJSON(s string) string {
start := strings.Index(s, "{") start := strings.IndexByte(s, '{')
end := strings.LastIndex(s, "}") if start < 0 {
if start >= 0 && end > start { return s
return s[start : end+1]
} }
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:]
} }
+38
View File
@@ -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)
}
})
}
}
+11 -16
View File
@@ -10,16 +10,18 @@ import (
// Config is the validated runtime configuration. Fields are ordered // Config is the validated runtime configuration. Fields are ordered
// largest-alignment-first to satisfy the fieldalignment linter. // 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 { type Config struct {
Provider string Provider string
BaseURL string BaseURL string
APIKey string APIKey string
Model string Model string
AudioModel string AudioModel string
LogLevel string LogLevel string
Temperature float64 Timeout time.Duration
Timeout time.Duration MaxTokens int
MaxTokens int
} }
var validProviders = map[string]bool{ var validProviders = map[string]bool{
@@ -47,13 +49,6 @@ func Load() (Config, error) {
if c.Model == "" { if c.Model == "" {
return Config{}, fmt.Errorf("CAPTCHA_LLM_MODEL is required") 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 != "" { if v := os.Getenv("CAPTCHA_LLM_MAX_TOKENS"); v != "" {
n, err := strconv.Atoi(v) n, err := strconv.Atoi(v)
if err != nil { if err != nil {
+3 -1
View File
@@ -16,6 +16,8 @@ import (
var dataURIRe = regexp.MustCompile(`^data:([^;,]+)(;base64)?,(.*)$`) var dataURIRe = regexp.MustCompile(`^data:([^;,]+)(;base64)?,(.*)$`)
var fetchClient = &http.Client{Timeout: 30 * time.Second}
// Point is a pixel coordinate. // Point is a pixel coordinate.
type Point struct { type Point struct {
X int X int
@@ -65,7 +67,7 @@ func fetchURL(url string) ([]byte, string, error) {
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
resp, err := http.DefaultClient.Do(req) resp, err := fetchClient.Do(req)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
+2 -3
View File
@@ -2,7 +2,6 @@ package llm
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
) )
@@ -16,9 +15,9 @@ type geminiClient struct {
} }
func (c *geminiClient) Vision(_ context.Context, _ string, _ []Image, _ Options) (string, error) { 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) { func (c *geminiClient) Transcribe(_ context.Context, _ []byte, _ string, _ Options) (string, error) {
return "", fmt.Errorf("gemini provider not yet implemented") return "", ErrUnsupported{Provider: "gemini"}
} }
+10 -5
View File
@@ -73,11 +73,15 @@ type SolveAudioIn struct {
} }
func (h *Handlers) solveAudio(ctx context.Context, _ *mcp.CallToolRequest, in SolveAudioIn) (*mcp.CallToolResult, captcha.AudioResult, error) { 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 { if err != nil {
return nil, captcha.AudioResult{}, err 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 var unsupported llm.ErrUnsupported
if errors.As(err, &unsupported) { 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) 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 { type FingerprintIn struct {
OS string `json:"os,omitempty" jsonschema:"windows | macos | linux"` 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"` Locale string `json:"locale,omitempty"`
Seed uint64 `json:"seed,omitempty" jsonschema:"seed for a stable identity across runs"` 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 { 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"` 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) { 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{ return nil, SolverInfoOut{
Provider: h.provider, Model: h.model, Provider: h.provider, Model: h.model,
Capabilities: map[string]bool{ Capabilities: map[string]bool{
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// Action is a high-level target the caller wants to perform. // Action is a high-level target the caller wants to perform.
type Action struct { type Action struct {
Type string `json:"type"` // click | type | scroll Type string `json:"type"` // click | type
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
X int `json:"x,omitempty"` X int `json:"x,omitempty"`
Y int `json:"y,omitempty"` Y int `json:"y,omitempty"`