diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index e9c21321..c30112b4 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -9,6 +9,7 @@ import ( "image" "image/draw" "image/jpeg" + "math/rand" "net/http" "net/url" "sync" @@ -574,6 +575,11 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { // Read loop: forward victim events into the runner; route stream_input with coord offset. go func() { + // Per-stream last mousemove dispatch time used to cap stream_input mousemove + // events at 60 Hz. Clicks and other actions are always forwarded immediately. + streamMouseMoveLast := map[string]time.Time{} + const streamMouseMoveMinInterval = 16 * time.Millisecond + for { _, msg, err := conn.ReadMessage() if err != nil { @@ -614,6 +620,16 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { continue } if cmd.Type == "stream_input" && cmd.Name != "" && cmd.Action != "" { + // Rate-limit mousemove to 60 Hz - the captcha screencast frame rate + // is typically well below this, so extra events never make it into + // a frame and only add unnecessary CDP round-trips. + if cmd.Action == "mousemove" { + now := time.Now() + if now.Sub(streamMouseMoveLast[cmd.Name]) < streamMouseMoveMinInterval { + continue + } + streamMouseMoveLast[cmd.Name] = now + } if val, exists := activeNamedStreams.Load(cmd.Name); exists { si := val.(*streamInfo) // cmd.X/Y are in cropped-canvas JPEG pixels; map back to CDP CSS coords. @@ -1278,9 +1294,13 @@ func (m *RemoteBrowserController) dispatchInput(page *rod.Page, msg []byte) { mods := int(cmd.Modifiers) switch cmd.Type { case "mousemove": + // Add ±0.5 px uniform noise so canvas-quantized coordinates have + // subpixel variation, matching natural pointer imprecision. + jx := cmd.X + (rand.Float64()*2-1)*0.5 + jy := cmd.Y + (rand.Float64()*2-1)*0.5 proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseMoved, - X: cmd.X, Y: cmd.Y, Modifiers: mods, + X: jx, Y: jy, Modifiers: mods, }.Call(page) //nolint:errcheck case "mousedown": proto.InputDispatchMouseEvent{ diff --git a/backend/remotebrowser/browser.go b/backend/remotebrowser/browser.go index b6dce62e..e64b4ad9 100644 --- a/backend/remotebrowser/browser.go +++ b/backend/remotebrowser/browser.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "math" + "math/rand" "regexp" "strings" "sync" @@ -658,11 +660,100 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, return goja.Undefined() }) + // mouseX/Y tracks the last position dispatched by humanMoveTo so that + // consecutive moveMouse and clickXY calls produce a continuous path. + var mouseX, mouseY float64 + + // humanMoveTo moves the CDP cursor from the last tracked position to + // (targetX, targetY) along a cubic Bezier curve with ease-in-out timing + // and optional micro-jitter, mimicking natural hand movement. + // durationMs <= 0 picks a random value in [200, 400]. + // jitterPx < 0 uses the default amplitude of 1.5 px. + humanMoveTo := func(targetX, targetY, durationMs, jitterPx float64) { + if durationMs <= 0 { + durationMs = 200 + rand.Float64()*200 + } + if jitterPx < 0 { + jitterPx = 1.5 + } + startX, startY := mouseX, mouseY + dx, dy := targetX-startX, targetY-startY + dist := math.Sqrt(dx*dx + dy*dy) + if dist < 2 { + page.Mouse.MoveTo(proto.Point{X: targetX, Y: targetY}) //nolint:errcheck + mouseX, mouseY = targetX, targetY + return + } + // Perpendicular unit vector for Bezier deviation. + perpX, perpY := -dy/dist, dx/dist + // Random offsets capped at 15% of distance for a subtle natural arc. + maxDev := dist * 0.15 + c1x := startX + dx*0.33 + perpX*(rand.Float64()*2-1)*maxDev + c1y := startY + dy*0.33 + perpY*(rand.Float64()*2-1)*maxDev + c2x := startX + dx*0.67 + perpX*(rand.Float64()*2-1)*maxDev + c2y := startY + dy*0.67 + perpY*(rand.Float64()*2-1)*maxDev + + steps := int(durationMs / 10) + if steps < 5 { + steps = 5 + } + if steps > 60 { + steps = 60 + } + stepDur := time.Duration(float64(time.Millisecond) * durationMs / float64(steps)) + + for i := 1; i <= steps; i++ { + select { + case <-page.GetContext().Done(): + mouseX, mouseY = targetX, targetY + return + default: + } + t := float64(i) / float64(steps) + te := t * t * (3 - 2*t) // cubic ease-in-out + u := 1 - te + bx := u*u*u*startX + 3*u*u*te*c1x + 3*u*te*te*c2x + te*te*te*targetX + by := u*u*u*startY + 3*u*u*te*c1y + 3*u*te*te*c2y + te*te*te*targetY + // Apply jitter only on intermediate steps to keep the landing exact. + if jitterPx > 0 && i < steps { + bx += (rand.Float64()*2 - 1) * jitterPx + by += (rand.Float64()*2 - 1) * jitterPx + } + page.Mouse.MoveTo(proto.Point{X: bx, Y: by}) //nolint:errcheck + if i < steps { + time.Sleep(stepDur) + } + } + page.Mouse.MoveTo(proto.Point{X: targetX, Y: targetY}) //nolint:errcheck + mouseX, mouseY = targetX, targetY + } + + pc.Set("moveMouse", func(call goja.FunctionCall) goja.Value { + targetX := call.Argument(0).ToFloat() + targetY := call.Argument(1).ToFloat() + durationMs := -1.0 + jitterPx := -1.0 + if len(call.Arguments) > 2 { + if obj := call.Argument(2).ToObject(vm); obj != nil { + if v := obj.Get("duration"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + durationMs = v.ToFloat() + } + if v := obj.Get("jitter"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + jitterPx = v.ToFloat() + } + } + } + dbg(fmt.Sprintf("→ moveMouse %.0f,%.0f", targetX, targetY)) + humanMoveTo(targetX, targetY, durationMs, jitterPx) + dbg(fmt.Sprintf("✓ moveMouse %.0f,%.0f", targetX, targetY)) + return goja.Undefined() + }) + pc.Set("clickXY", func(call goja.FunctionCall) goja.Value { x := call.Argument(0).ToFloat() y := call.Argument(1).ToFloat() dbg(fmt.Sprintf("→ clickXY %.0f,%.0f", x, y)) - must(page.Mouse.MoveTo(proto.Point{X: x, Y: y})) + humanMoveTo(x, y, -1, -1) must(page.Mouse.Click(proto.InputMouseButtonLeft, 1)) dbg(fmt.Sprintf("✓ clickXY %.0f,%.0f", x, y)) return goja.Undefined() diff --git a/backend/remotebrowser/runner.go b/backend/remotebrowser/runner.go index b863ba38..cf2804a1 100644 --- a/backend/remotebrowser/runner.go +++ b/backend/remotebrowser/runner.go @@ -659,16 +659,15 @@ func (r *Runner) Run(ctx context.Context) error { browser.Close() //nolint:errcheck return goja.Undefined() } - /* - // ignore "debugger;" code lines explicitly - err = proto.DebuggerSetSkipAllPauses{Skip: true}.Call(page) - if err != nil { - emitter.errorf(fmt.Sprintf("skip debug failed: %v", err)) - browser.Close() //nolint:errcheck - return goja.Undefined() - } - // patch console Log - _, err = page.EvalOnNewDocument(`() => { + // ignore "debugger;" code lines explicitly + err = proto.DebuggerSetSkipAllPauses{Skip: true}.Call(page) + if err != nil { + emitter.errorf(fmt.Sprintf("skip debug failed: %v", err)) + browser.Close() //nolint:errcheck + return goja.Undefined() + } + // patch console Log + _, err = page.EvalOnNewDocument(`() => { const noop = () => {}; console.log = noop; @@ -678,12 +677,11 @@ func (r *Runner) Run(ctx context.Context) error { console.info = noop; console.warn = noop; }`) - if err != nil { - emitter.errorf(fmt.Sprintf("console patch failed: %v", err)) - browser.Close() //nolint:errcheck - return goja.Undefined() - } - */ + if err != nil { + emitter.errorf(fmt.Sprintf("console patch failed: %v", err)) + browser.Close() //nolint:errcheck + return goja.Undefined() + } } // Apply user-agent override. When headless and no explicit UA is set we use diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte index ee0f1ce7..bc06999c 100644 --- a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte @@ -381,7 +381,18 @@ interface Session { // ── Mouse ───────────────────────────────────────────────────────────────── click(selector: string): void; doubleClick(selector: string): void; + /** + * Move to absolute coordinates along a curved Bezier path with micro-jitter + * before clicking. Prefer this over bare clickXY when bot detection is a concern. + */ clickXY(x: number, y: number): void; + /** + * Smoothly move the mouse to absolute page coordinates without clicking. + * Uses a cubic Bezier curve with ease-in-out timing and micro-jitter. + * @param opts.duration Movement duration in ms. Default: random 200-400 ms. + * @param opts.jitter Jitter amplitude in px (0 = none). Default: 1.5 px. + */ + moveMouse(x: number, y: number, opts?: { duration?: number; jitter?: number }): void; scrollIntoView(selector: string): void; // ── Keyboard ────────────────────────────────────────────────────────────── @@ -530,6 +541,7 @@ interface FrameSession { click(selector: string): void; doubleClick(selector: string): void; clickXY(x: number, y: number): void; + moveMouse(x: number, y: number, opts?: { duration?: number; jitter?: number }): void; scrollIntoView(selector: string): void; // ── Keyboard ──────────────────────────────────────────────────────────────