fix(captcha): устойчивый разбор debug_info и сигнал дрейфа версии скрипта

This commit is contained in:
samosvalishe
2026-07-07 19:15:50 +03:00
parent 623d9a22c2
commit 3b33d33b9e
2 changed files with 99 additions and 10 deletions
@@ -36,7 +36,7 @@ func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
const (
captchaAPIVersion = "5.131"
captchaScriptVersion = "1.1.1370"
captchaScriptVersion = "1.1.1374"
captchaAPIOrigin = "https://id.vk.ru"
captchaDomain = "vk.ru"
captchaDeviceInfo = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1147,"innerHeight":945,"devicePixelRatio":1,"language":"ru-RU","languages":["ru-RU"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":16,"connectionEffectiveType":"4g","notificationsPermission":"denied"}`
@@ -49,6 +49,7 @@ var (
reCaptchaWindowInit = regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;`)
reCaptchaScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`)
reCaptchaDebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`)
reCaptchaHex64 = regexp.MustCompile(`"([a-fA-F0-9]{64})"`)
reCaptchaVersion = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`)
errCaptchaRateLimit = errors.New("captcha session rate limit reached")
@@ -57,6 +58,7 @@ var (
captchaMaxAttempts = 2
captchaDebugCache sync.Map // scriptURL -> string
captchaVersionSeen sync.Map // script version -> struct{} (drift warned once)
captchaHeaderOrder = []string{
"host",
"content-length",
@@ -224,11 +226,7 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
}
s.logger().Debugf("[Captcha] browser_fp source=%s len=%d", browserFPSource, len(browserFP))
if m := reCaptchaVersion.FindStringSubmatch(page.ScriptURL); len(m) > 1 {
if m[1] != captchaScriptVersion {
s.logger().Debugf("[Captcha] script version drift: known=%s latest=%s", captchaScriptVersion, m[1])
}
}
s.warnVersionDrift(page.ScriptURL)
debugInfo, err := s.fetchDebugInfo(page.ScriptURL)
if err != nil {
@@ -331,16 +329,54 @@ func (s *captchaSession) fetchDebugInfo(scriptURL string) (string, error) {
if err != nil {
return "", err
}
m := reCaptchaDebugInfo.FindSubmatch(body)
if len(m) < 2 {
return "", errors.New("debug_info match not found")
v, usedFallback, err := extractDebugInfo(body)
if err != nil {
return "", err
}
if usedFallback {
s.logger().Warnf("[Captcha] debug_info primary pattern missed; used windowed fallback (script format drift)")
}
v := string(m[1])
captchaDebugCache.Store(scriptURL, v)
s.logger().Debugf("[Captcha] debug_info fetched url=%s", scriptURL)
return v, nil
}
// warnVersionDrift предупреждает один раз на версию, если VK отдал скрипт новее
// оттестированного baseline: wire мог измениться, надо перепроверить против live.
func (s *captchaSession) warnVersionDrift(scriptURL string) {
m := reCaptchaVersion.FindStringSubmatch(scriptURL)
if len(m) < 2 || m[1] == "" || m[1] == captchaScriptVersion {
return
}
if _, seen := captchaVersionSeen.LoadOrStore(m[1], struct{}{}); seen {
return
}
s.logger().Warnf("[Captcha] script version %s differs from tested %s; wire unverified - re-check if BOT-rejections rise", m[1], captchaScriptVersion)
}
// extractDebugInfo достаёт fallback-константу debug_info из тела скрипта.
// VK-формат: `debug_info:(...window.vk.X)||"<64hex>"`; имя window.vk.X плавает
// от билда к билду, поэтому основной паттерн ловит только hash-литерал.
// usedFallback=true - основной паттерн промазал, взят первый 64-hex в окне после
// `debug_info` (страховка смены обёртки). Иначе - ошибка со снапшотом окна.
func extractDebugInfo(body []byte) (value string, usedFallback bool, err error) {
if m := reCaptchaDebugInfo.FindSubmatch(body); len(m) >= 2 {
return string(m[1]), false, nil
}
idx := bytes.Index(body, []byte("debug_info"))
if idx < 0 {
return "", false, errors.New("debug_info marker not found in script")
}
end := idx + 256
if end > len(body) {
end = len(body)
}
if m := reCaptchaHex64.FindSubmatch(body[idx:end]); len(m) >= 2 {
return string(m[1]), true, nil
}
return "", false, fmt.Errorf("debug_info match not found near: %q", body[idx:end])
}
func parseCaptchaPage(html string) (*captchaPage, error) {
page := &captchaPage{}
@@ -77,6 +77,59 @@ func TestEncodeCaptchaPoW(t *testing.T) {
}
}
func TestExtractDebugInfo(t *testing.T) {
const hash = "59f60d917b13be6a22c076adb2c17df37302c5314d8353a27e72f9fbcc9b4838"
tests := []struct {
name string
body string
want string
wantFallback bool
fail bool
}{
{
name: "primary vk format",
body: `x,debug_info:(null===(r=window.vk)||void 0===r?void 0:r.brlefapmjnpg)||"` + hash + `"});var a`,
want: hash,
},
{
name: "windowed fallback no or-wrapper",
body: `debug_info: someRuntimeCall(),foo:"` + hash + `"`,
want: hash,
wantFallback: true,
},
{
name: "no hash near marker",
body: `debug_info:window.vk.x||"tooshort"`,
fail: true,
},
{
name: "no marker",
body: `nothing here`,
fail: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, usedFallback, err := extractDebugInfo([]byte(tt.body))
if tt.fail {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("extractDebugInfo = %q, want %q", got, tt.want)
}
if usedFallback != tt.wantFallback {
t.Fatalf("usedFallback = %v, want %v", usedFallback, tt.wantFallback)
}
})
}
}
func TestCaptchaDomainFromRedirectURI(t *testing.T) {
tests := []struct {
name string