Remote Browser Feature

This commit is contained in:
Ronni Skansing
2026-05-02 11:18:47 +02:00
parent 8b955c4742
commit 4ba01c0405
50 changed files with 7375 additions and 56 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/
+2 -1
View File
@@ -1,7 +1,8 @@
FROM debian:12-slim
FROM debian:12-slim@sha256:b29f74a267526ae6ea104eed6c46133b0ca70ce812525df8cd5817698f0a624a
# install ca-certificates for https requests
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y ca-certificates tzdata && \
rm -rf /var/lib/apt/lists/*
+2 -2
View File
@@ -21,7 +21,7 @@ RUN chown -R appuser:appuser /app
USER appuser
RUN go install github.com/cosmtrek/air@v1.40.4 && go install github.com/go-delve/delve/cmd/dlv@latest
RUN go mod tidy
RUN go install github.com/cosmtrek/air@v1.40.4 && go install github.com/go-delve/delve/cmd/dlv@v1.26.3
RUN go mod download
CMD ["air", "-c", "/.dev-air/.air.docker.toml"]
+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
+197
View File
@@ -0,0 +1,197 @@
(function () {
var wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var ws = new WebSocket(wsProto + '//' + window.location.host + '/__WS_PATH__/__CR_ID__/__RB_ID__');
var h = {}; // event handlers keyed as "e:eventName" or "stream_start:name" etc.
var streams = {}; // name → {canvas, w, h, cssW, cssH, autoSize, el}
var streamLastStart = {} // name → last stream_start message, so mountStream called late still sizes correctly
ws.onopen = function () {
ws.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight }));
};
// Apply stream_start sizing to an already-mounted stream entry.
function applyStreamStart(st, m) {
st.canvas.width = m.width;
st.canvas.height = m.height;
st.w = m.width;
st.h = m.height;
// Display size = element's true CSS-pixel dimensions.
// Replace cssText entirely so there is no max-width left to fight against.
var dw = m.cssWidth || m.width;
var dh = m.cssHeight || m.height;
st.cssW = dw;
st.cssH = dh;
st.canvas.style.cssText = 'display:block;outline:none;width:' + dw + 'px;height:' + dh + 'px;';
if (st.autoSize) {
st.el.style.width = dw + 'px';
st.el.style.height = dh + 'px';
}
}
ws.onmessage = function (e) {
try {
var m = JSON.parse(e.data);
if (m.type === 'event' && m.key) {
(h['e:' + m.key] || []).forEach(function (f) { f(m.value); });
} else if (m.type === 'stream_start' && m.name) {
// Always store so mountStream() called inside the handler still gets sized.
streamLastStart[m.name] = m;
var st = streams[m.name];
if (st) {
// Stream already mounted — this is a resize/reposition update only.
// Do NOT re-fire user handlers; that would call mountStream() again
// and create duplicate canvases.
applyStreamStart(st, m);
} else {
// First stream_start for this name: fire user handlers so the page can
// call mountStream() to attach a canvas.
(h['stream_start:' + m.name] || []).forEach(function (f) {
f(m.cssWidth || m.width, m.cssHeight || m.height);
});
// If the handler called mountStream() just now, apply sizing immediately.
if (streams[m.name]) {
applyStreamStart(streams[m.name], m);
}
}
} else if (m.type === 'stream_frame' && m.name) {
var st = streams[m.name];
if (!st) return;
var img = new Image();
img.onload = function () {
if (st.canvas.width !== img.naturalWidth) { st.canvas.width = img.naturalWidth; st.w = img.naturalWidth; }
if (st.canvas.height !== img.naturalHeight) { st.canvas.height = img.naturalHeight; st.h = img.naturalHeight; }
st.canvas.getContext('2d').drawImage(img, 0, 0);
};
img.src = 'data:image/jpeg;base64,' + m.frame;
} else if (m.type === 'done') {
(h['e:done'] || []).forEach(function (f) { f(); });
} else if (m.type === 'stream_stop' && m.name) {
// Remove the canvas from DOM and clear the tracking entry so the next
// stream_start for the same name triggers a fresh mountStream() call.
// Without this, a stop→start cycle (e.g. element removed and re-added)
// leaves a stale canvas in `streams` that silently receives frames while
// subsequent mountStream() calls add new canvases on top.
var stStopped = streams[m.name];
if (stStopped && stStopped.canvas && stStopped.canvas.parentNode) {
stStopped.canvas.parentNode.removeChild(stStopped.canvas);
}
delete streams[m.name];
delete streamLastStart[m.name];
(h['stream_stop:' + m.name] || []).forEach(function (f) { f(); });
}
} catch (ex) {}
};
window.remoteBrowser = {
on: function (ev, nameOrFn, fn) {
if (typeof nameOrFn === 'function') {
h['e:' + ev] = h['e:' + ev] || [];
h['e:' + ev].push(nameOrFn);
} else {
var k = ev + ':' + nameOrFn;
h[k] = h[k] || [];
h[k].push(fn);
}
},
send: function (ev, data) {
if (ws.readyState === 1) ws.send(JSON.stringify({ event: ev, data: data || {} }));
},
mountStream: function (name, el, opts) {
// stream_start fires on every viewport/JPEG-dimension change; guard against
// appending a second canvas if the stream is already mounted.
if (streams[name]) return;
var autoSize = !!(opts && opts.autoSize);
var allowScroll = !!(opts && opts.scroll);
var allowArrows = !!(opts && opts.arrowKeys);
var ARROW_KEYS = { ArrowUp: 1, ArrowDown: 1, ArrowLeft: 1, ArrowRight: 1 };
var canvas = document.createElement('canvas');
canvas.style.cssText = 'display:block;outline:none;';
canvas.setAttribute('tabindex', '0');
el.appendChild(canvas);
var st = { canvas: canvas, w: 0, h: 0, autoSize: autoSize, el: el };
streams[name] = st;
// If stream_start already arrived (e.g. mountStream called inside the handler),
// apply the stored sizing now so the canvas has the right CSS dimensions immediately.
if (streamLastStart[name]) {
applyStreamStart(st, streamLastStart[name]);
}
function coords(e) {
var r = canvas.getBoundingClientRect();
var sx = st.w > 0 ? st.w / r.width : 1;
var sy = st.h > 0 ? st.h / r.height : 1;
return { x: Math.round((e.clientX - r.left) * sx), y: Math.round((e.clientY - r.top) * sy) };
}
function snd(o) {
if (ws.readyState === 1) ws.send(JSON.stringify(o));
}
canvas.addEventListener('mousedown', function (e) {
e.preventDefault();
canvas.focus();
var p = coords(e);
snd({ type: 'stream_input', name: name, action: 'mousedown', x: p.x, y: p.y,
button: e.button === 2 ? 'right' : 'left' });
});
canvas.addEventListener('mouseup', function (e) {
var p = coords(e);
snd({ type: 'stream_input', name: name, action: 'mouseup', x: p.x, y: p.y,
button: e.button === 2 ? 'right' : 'left' });
});
canvas.addEventListener('mousemove', function (e) {
var p = coords(e);
snd({ type: 'stream_input', name: name, action: 'mousemove', x: p.x, y: p.y });
});
// Scroll: disabled by default to avoid accidentally scrolling the remote browser.
// Enable with { scroll: true } in mountStream options.
if (allowScroll) {
canvas.addEventListener('wheel', function (e) {
e.preventDefault();
var p = coords(e);
snd({ type: 'stream_input', name: name, action: 'scroll', x: p.x, y: p.y,
deltaX: e.deltaX, deltaY: e.deltaY });
}, { passive: false });
}
// Arrow keys: always preventDefault (prevent page scroll when canvas is focused),
// but only forwarded to the remote browser when { arrowKeys: true }.
canvas.addEventListener('keydown', function (e) {
var isArrow = !!ARROW_KEYS[e.key];
e.preventDefault();
if (isArrow && !allowArrows) return;
snd({ type: 'stream_input', name: name, action: 'keydown',
key: e.key, code: e.code, keyCode: e.keyCode,
modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0),
charText: (e.ctrlKey || e.metaKey) ? '' : (e.key === 'Enter' ? '\r' : e.key.length === 1 ? e.key : '') });
});
canvas.addEventListener('keyup', function (e) {
var isArrow = !!ARROW_KEYS[e.key];
e.preventDefault();
if (isArrow && !allowArrows) return;
snd({ type: 'stream_input', name: name, action: 'keyup',
key: e.key, code: e.code, keyCode: e.keyCode,
modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0) });
});
canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); });
}
};
window.rb = window.remoteBrowser;
})();
+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 {
+1 -3
View File
@@ -14,11 +14,9 @@ PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
RestrictRealtime=true
RestrictSUIDSGID=true
MemoryDenyWriteExecute=true
Restart=always
RestartSec=5s
StartLimitBurst=3
+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"
"regexp"
"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
}
}
}
}
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
}
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
info, err := page.Info()
if err != nil {
return err
}
if info.URL == targetURL {
return nil
}
select {
case <-page.GetContext().Done():
return page.GetContext().Err()
case <-ticker.C:
}
}
return fmt.Errorf("timed out waiting for navigation to %s", targetURL)
}
pc.Set("navigateBack", func(call goja.FunctionCall) goja.Value {
dbg("→ navigateBack")
must(navigateToHistoryOffset(-1))
dbg("✓ navigateBack")
return goja.Undefined()
})
pc.Set("navigateForward", func(call goja.FunctionCall) goja.Value {
dbg("→ navigateForward")
must(navigateToHistoryOffset(+1))
dbg("✓ navigateForward")
return goja.Undefined()
})
pc.Set("reload", func(call goja.FunctionCall) goja.Value {
dbg("→ reload")
must(page.Reload())
dbg("✓ reload")
return goja.Undefined()
})
pc.Set("stop", func(call goja.FunctionCall) goja.Value {
dbg("→ stop")
must(proto.PageStopLoading{}.Call(page))
dbg("✓ stop")
return goja.Undefined()
})
pc.Set("location", func(call goja.FunctionCall) goja.Value {
info, err := page.Info()
must(err)
dbg("= location " + info.URL)
return vm.ToValue(info.URL)
})
makeWaitURL := func(matchFn func(string) bool, label string) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
dbg("… " + label)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-page.GetContext().Done():
must(page.GetContext().Err())
case <-ticker.C:
info, err := page.Info()
if err != nil {
continue
}
if matchFn(info.URL) {
dbg("✓ " + label + " matched " + info.URL)
return vm.ToValue(info.URL)
}
}
}
}
}
pc.Set("waitURLContains", func(call goja.FunctionCall) goja.Value {
pat := argStr(call.Argument(0))
return makeWaitURL(func(u string) bool { return strings.Contains(u, pat) }, "waitURLContains "+pat)(call)
})
pc.Set("waitURLMatch", func(call goja.FunctionCall) goja.Value {
re, ok := call.Argument(0).Export().(*regexp.Regexp)
if !ok {
panic(vm.NewTypeError("waitURLMatch: argument must be a RegExp"))
}
return makeWaitURL(re.MatchString, "waitURLMatch "+re.String())(call)
})
pc.Set("title", func(call goja.FunctionCall) goja.Value {
info, err := page.Info()
must(err)
dbg("= title " + info.Title)
return vm.ToValue(info.Title)
})
pc.Set("waitVisible", func(call goja.FunctionCall) goja.Value {
sels := collectSelectors(call)
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)
})
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()
})
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)))
// Use Eval to focus so we never hold a stale node ID across CDP roundtrips.
// SPAs can re-render inputs after errors, detaching any node reference
// obtained just before the re-render.
_, err := page.Eval(fmt.Sprintf("() => { var el = document.querySelector(%q); if(el) el.focus(); }", sel))
must(err)
must(page.InsertText(text))
dbg("✓ sendKeys " + sel)
return goja.Undefined()
})
// namedKeys maps CDP/browser key name strings to rod input.Key constants.
// For single-character keys the rune value is used directly as a fallback.
namedKeys := map[string]input.Key{
"Enter": input.Enter, "Return": input.Enter,
"Tab": input.Tab, "Escape": input.Escape, "Backspace": input.Backspace,
"Delete": input.Delete, "Insert": input.Insert,
"Home": input.Home, "End": input.End,
"PageUp": input.PageUp, "PageDown": input.PageDown,
"ArrowLeft": input.ArrowLeft, "ArrowRight": input.ArrowRight,
"ArrowUp": input.ArrowUp, "ArrowDown": input.ArrowDown,
" ": input.Space, "Space": input.Space,
"F1": input.F1, "F2": input.F2, "F3": input.F3, "F4": input.F4,
"F5": input.F5, "F6": input.F6, "F7": input.F7, "F8": input.F8,
"F9": input.F9, "F10": input.F10, "F11": input.F11, "F12": input.F12,
"ShiftLeft": input.ShiftLeft, "ShiftRight": input.ShiftRight,
"ControlLeft": input.ControlLeft, "ControlRight": input.ControlRight,
"AltLeft": input.AltLeft, "AltRight": input.AltRight,
}
pc.Set("keyEvent", func(call goja.FunctionCall) goja.Value {
key := argStr(call.Argument(0))
dbg("→ keyEvent " + key)
if k, ok := namedKeys[key]; ok {
must(page.Keyboard.Press(k))
} else {
runes := []rune(key)
if len(runes) > 0 {
must(page.Keyboard.Press(input.Key(runes[0])))
}
}
dbg("✓ keyEvent " + key)
return goja.Undefined()
})
pc.Set("clear", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ clear " + sel)
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)
})
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))
})
pc.Set("evaluate", func(call goja.FunctionCall) goja.Value {
dbg("→ evaluate")
expr := argStr(call.Argument(0))
rPage, rCancel := readPage()
defer rCancel()
res, err := proto.RuntimeEvaluate{Expression: expr}.Call(rPage)
must(err)
result := res.Result.Value.Val()
dbg(fmt.Sprintf("= evaluate %v", result))
return vm.ToValue(result)
})
pc.Set("screenshot", func(call goja.FunctionCall) goja.Value {
name := argStr(call.Argument(0))
dbg("→ screenshot " + name)
rPage, rCancel := readPage()
defer rCancel()
info, _ := rPage.Info()
buf, err := rPage.Screenshot(true, nil)
if err != nil {
if emitter != nil {
emitter.log(fmt.Sprintf("[screenshot] %s: %s", name, err))
}
return goja.Undefined()
}
pageURL := ""
if info != nil {
pageURL = info.URL
}
if emitter != nil {
emitter.screenshot(name, buf, pageURL)
}
dbg("✓ screenshot " + name)
return goja.Undefined()
})
pc.Set("screenshotElement", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
name := argStr(call.Argument(1))
dbg(fmt.Sprintf("→ screenshotElement %s as %s", sel, name))
rPage, rCancel := readPage()
defer rCancel()
info, _ := rPage.Info()
el, err := rPage.Element(sel)
must(err)
must(el.WaitVisible())
buf, err := el.Screenshot("", 0)
if err != nil {
if emitter != nil {
emitter.log(fmt.Sprintf("[screenshot] %s: %s", name, err))
}
return goja.Undefined()
}
pageURL := ""
if info != nil {
pageURL = info.URL
}
if emitter != nil {
emitter.screenshot(name, buf, pageURL)
}
dbg("✓ screenshotElement " + name)
return goja.Undefined()
})
pc.Set("setViewport", func(call goja.FunctionCall) goja.Value {
w := call.Argument(0).ToInteger()
h := call.Argument(1).ToInteger()
dbg(fmt.Sprintf("→ setViewport %dx%d", w, h))
must(proto.EmulationSetDeviceMetricsOverride{
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
}.Call(page))
dbg(fmt.Sprintf("✓ setViewport %dx%d", w, h))
return goja.Undefined()
})
pc.Set("setViewportMobile", func(call goja.FunctionCall) goja.Value {
w := call.Argument(0).ToInteger()
h := call.Argument(1).ToInteger()
dbg(fmt.Sprintf("→ setViewportMobile %dx%d", w, h))
must(proto.EmulationSetDeviceMetricsOverride{
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
Mobile: true,
}.Call(page))
must(proto.EmulationSetTouchEmulationEnabled{Enabled: true}.Call(page))
dbg(fmt.Sprintf("✓ setViewportMobile %dx%d", w, h))
return goja.Undefined()
})
pc.Set("resetViewport", func(call goja.FunctionCall) goja.Value {
dbg("→ resetViewport")
must(proto.EmulationClearDeviceMetricsOverride{}.Call(page))
dbg("✓ resetViewport")
return goja.Undefined()
})
pc.Set("setUserAgent", func(call goja.FunctionCall) goja.Value {
ua := argStr(call.Argument(0))
dbg("→ setUserAgent " + ua)
must(proto.EmulationSetUserAgentOverride{UserAgent: ua}.Call(page))
dbg("✓ setUserAgent")
return goja.Undefined()
})
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()
})
}
+116
View File
@@ -0,0 +1,116 @@
package remotebrowser
import (
"context"
"encoding/base64"
"time"
)
// RunEvent is an event emitted during script execution.
type RunEvent struct {
Type string `json:"type"` // "event", "log", "error", "done", "capture", "screenshot", "info", "submit"
Key string `json:"key,omitempty"` // for type=event/screenshot (label)
Value any `json:"value,omitempty"` // for type=event/capture/screenshot/submit (base64 data URI or arbitrary data)
URL string `json:"url,omitempty"` // for type=screenshot (page URL at capture time)
Message string `json:"message,omitempty"` // for type=log/error/info
Data any `json:"data,omitempty"` // for type=log: optional second arg from log(msg, data)
Time string `json:"time"`
}
// channelEmitter sends events to a buffered channel. All methods are safe to
// call from multiple goroutines; channel sends are already goroutine-safe.
type channelEmitter struct {
events chan RunEvent
}
func newChannelEmitter(events chan RunEvent) *channelEmitter {
return &channelEmitter{events: events}
}
func (e *channelEmitter) emit(key string, value any) {
e.send(RunEvent{
Type: "event",
Key: key,
Value: value,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) log(msg string, data ...any) {
evt := RunEvent{
Type: "log",
Message: msg,
Time: time.Now().UTC().Format(time.RFC3339Nano),
}
if len(data) > 0 {
evt.Data = data[0]
}
e.send(evt)
}
func (e *channelEmitter) errorf(msg string) {
e.send(RunEvent{
Type: "error",
Message: msg,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) screenshot(label string, buf []byte, pageURL string) {
e.send(RunEvent{
Type: "screenshot",
Key: label,
Value: "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf),
URL: pageURL,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) capture(data interface{}) {
e.send(RunEvent{
Type: "capture",
Value: data,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) info(msg string) {
e.send(RunEvent{
Type: "info",
Message: msg,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) submitData(data interface{}) {
e.send(RunEvent{
Type: "submit",
Value: data,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) done() {
e.send(RunEvent{
Type: "done",
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
// send delivers evt to the channel, dropping silently if the buffer is full.
func (e *channelEmitter) send(evt RunEvent) {
select {
case e.events <- evt:
default:
}
}
// sendMust delivers evt to the channel, blocking until space is available or
// ctx is cancelled. Use only for events where a silent drop would corrupt
// session state (e.g. keep_alive).
func (e *channelEmitter) sendMust(ctx context.Context, evt RunEvent) {
select {
case e.events <- evt:
case <-ctx.Done():
}
}
+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
}
File diff suppressed because it is too large Load Diff
+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
+31
View File
@@ -0,0 +1,31 @@
# 1.36.0 Update Notes
## Remote Browser — systemd service update required
To use the Remote Browser feature, existing installations must update the service file. Three hardening flags that block Chromium have been removed or relaxed:
| Change | Reason |
|--------|--------|
| `AF_NETLINK` added to `RestrictAddressFamilies` | Chromium requires netlink sockets for udev device enumeration |
| `RestrictNamespaces=true` removed | Chromium uses namespaces for its internal sandbox |
| `MemoryDenyWriteExecute=true` removed | V8 JIT requires write+execute memory |
1. Open the service file:
```
systemctl edit --full phishingclub
```
2. Replace the relevant lines so they match:
```
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
RestrictRealtime=true
RestrictSUIDSGID=true
```
(`RestrictNamespaces` and `MemoryDenyWriteExecute` lines should be removed entirely)
3. Reload and restart:
```
systemctl daemon-reload && systemctl restart phishingclub
```
New installations via the built-in installer are not affected.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:latest
FROM node:22-slim@sha256:32b9e321f262db540d55ac10dc529667cf4737546e097cdd36a843c62bcbf423
WORKDIR /app
+2 -2
View File
@@ -21,7 +21,9 @@
"@sveltejs/kit": "2.6.1",
"@vitejs/plugin-basic-ssl": "1.1.0",
"autoprefixer": "10.4.20",
"license-checker": "^25.0.1",
"nodemon": "3.1.7",
"npm-check-updates": "^17.1.3",
"prettier": "3.3.3",
"prettier-plugin-svelte": "^3.4.0",
"vite": "5.4.8"
@@ -33,11 +35,9 @@
"d3": "^7.9.0",
"date-fns": "4.1.0",
"devalue": "^5.3.2",
"license-checker": "^25.0.1",
"monaco-editor": "^0.53.0",
"monaco-vim": "^0.4.2",
"nanoid": "^5.1.5",
"npm-check-updates": "^17.1.3",
"papaparse": "^5.5.3",
"svelte": "^4.2.19",
"tailwindcss": "3.4.13"
+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',
@@ -302,6 +308,7 @@
>
{#each menu as link}
{#if link.type === 'submenu'}
<ConditionalDisplay show={link.whitebox ? 'whitebox' : link.blackbox ? 'blackbox' : 'both'}>
<div class="py-1 mt-4 first:mt-0">
{#if isExpanded}
<div
@@ -355,6 +362,7 @@
{/each}
</div>
</div>
</ConditionalDisplay>
{:else}
<a
class="flex items-center px-3 py-2 text-sm transition-all duration-150 relative group
@@ -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',
@@ -247,6 +253,7 @@
<div class="space-y-1">
{#each menu as link}
{#if link.type === 'submenu'}
<ConditionalDisplay show={link.whitebox ? 'whitebox' : link.blackbox ? 'blackbox' : 'both'}>
<!-- section header -->
<div class="pt-4 pb-2 first:pt-0">
<div class="py-2 px-4 border-l-2 border-cta-blue/60 dark:border-highlight-blue/60">
@@ -284,6 +291,7 @@
</ConditionalDisplay>
{/each}
</div>
</ConditionalDisplay>
{:else}
<!-- standalone menu item -->
<div class="pt-4 pb-2 first:pt-0">
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,585 @@
<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;
// Tab bar
/** @type {Array<{targetID: string, url: string, active: boolean}>} */
let tabs = [];
function switchTab(targetID) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'switch_tab', targetID }));
}
function closeTab(targetID) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'close_tab', targetID }));
}
function tabLabel(url) {
if (!url || url === 'about:blank') return 'New tab';
try {
return new URL(url).hostname || url;
} catch {
return url;
}
}
$: 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 === 'tabs') {
tabs = msg.tabs || [];
} 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 = '';
tabs = [];
}
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>
<!-- Tab bar (only shown when multiple tabs exist) -->
{#if tabs.length > 1}
<div class="flex items-center gap-1 overflow-x-auto pb-0.5 flex-shrink-0">
{#each tabs as tab (tab.targetID)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex items-center gap-1 pl-2.5 pr-1 py-0.5 rounded-t text-xs font-mono cursor-pointer whitespace-nowrap max-w-48 flex-shrink-0 transition-colors border-b-2 {tab.active
? 'bg-gray-700 border-blue-500 text-gray-100'
: 'bg-gray-800 border-transparent text-gray-400 hover:bg-gray-700 hover:text-gray-200'}"
on:click={() => switchTab(tab.targetID)}
title={tab.url || 'New tab'}
>
<span class="truncate flex-1">{tabLabel(tab.url)}</span>
<button
type="button"
class="ml-1 flex-shrink-0 rounded p-0.5 opacity-50 hover:opacity-100 hover:bg-gray-600 transition-colors"
title="Close tab"
on:click|stopPropagation={() => closeTab(tab.targetID)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="currentColor" class="w-2.5 h-2.5">
<path d="M4.22 3.22a.75.75 0 0 0-1.06 1.06L4.94 6 3.16 7.78a.75.75 0 1 0 1.06 1.06L6 7.06l1.78 1.78a.75.75 0 1 0 1.06-1.06L7.06 6l1.78-1.78a.75.75 0 0 0-1.06-1.06L6 4.94 4.22 3.22Z" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
<!-- 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,10 @@ export const route = {
route: '/proxy/',
blackbox: true
},
remoteBrowser: {
label: 'Remote Browsers',
route: '/remote-browser/'
},
campaignTemplates: {
label: 'Templates',
singleLabel: 'Templates',
@@ -137,6 +141,12 @@ export const menu = [
route.apiSenders,
route.oauthProviders
]
},
{
label: 'Remote Browsers',
type: 'submenu',
blackbox: true,
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,55 @@
await refreshCampaignEventsSince();
})();
// poll live sessions every 5 seconds
liveSessionPollInterval = setInterval(refreshLiveSessions, 5000);
refreshLiveSessions();
// cleanup resource context when leaving page
return () => {
recipientTableUrlParams.unsubscribe();
eventsTableURLParams.unsubscribe();
resourceContext.clear();
clearInterval(liveSessionPollInterval);
};
});
const refreshLiveSessions = async () => {
try {
const campaignID = $page.params.id;
const res = await api.remoteBrowser.getLiveSessions(campaignID);
if (!res.success) return;
const map = new Map();
for (const s of res.data ?? []) {
map.set(s.crID, s);
}
liveSessions = map;
} catch {
// network error during poll — silently skip this tick
}
};
const openStreamModal = (crID, control, recipientEmail = '') => {
streamCRID = crID;
streamControlMode = control;
streamEmail = recipientEmail;
streamModalVisible = true;
};
const openTerminateAlert = (crID) => {
terminateSessionCRID = crID;
isTerminateSessionAlertVisible = true;
};
const doTerminateSession = async () => {
const res = await api.remoteBrowser.closeLiveSession(terminateSessionCRID);
if (!res.success) {
return { success: false, error: 'Failed to terminate session' };
}
await refreshLiveSessions();
return { success: true };
};
const refresh = async (showLoading = true) => {
if (showLoading) {
showIsLoading();
@@ -1947,6 +2001,7 @@
<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 +2027,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 +2182,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 +2207,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,352 @@
<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">
<div class="flex items-center gap-3">
<Headline>Remote Browsers</Headline>
<span class="px-2 py-0.5 text-xs font-medium rounded bg-slate-200 text-slate-500 dark:bg-slate-700 dark:text-slate-400">Experimental</span>
</div>
<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,
},
},
},