added script support in remote browser script

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-09-30 22:50:37 +02:00
1 parent 0538ef0381
commit f9d4169f90
9 files changed
+234

No files matched your search

+1
View File
@@ -236,6 +236,7 @@ func NewControllers(
CampaignRecipientRepository: repositories.CampaignRecipient,
CampaignRepository: repositories.Campaign,
CampaignService: services.Campaign,
ScriptService: services.Script,
ExecPath: conf.RemoteBrowser.ExecPath,
Enabled: conf.RemoteBrowser.Enabled,
TrustedProxies: conf.IPSecurity.TrustedProxies,
+23
View File
@@ -265,6 +265,9 @@ type RemoteBrowserController struct {
CampaignRecipientRepository *repository.CampaignRecipient
CampaignRepository *repository.Campaign
CampaignService *service.Campaign
// ScriptService runs saved Scripts in callable mode for the runScript
// binding. Nil-safe: the binding throws when the feature is off.
ScriptService *service.Script
// ExecPath is the server-configured Chrome binary (from config.json).
ExecPath string
// Enabled mirrors config.RemoteBrowserServerConfig.Enabled. When false
@@ -458,6 +461,13 @@ func (m *RemoteBrowserController) RunByID(g *gin.Context) {
runner := remotebrowser.NewRunner(script, cfg)
runner.ExecPath = m.ExecPath
runner.Logger = m.Logger
// wire runScript for the operator test run, scoped to the operator's company
// context plus global scripts. When the feature is off RunCallable returns a
// clear "scripts are not enabled" error.
runScriptCompanyID := companyIDFromRequestQuery(g)
runner.RunScript = func(name string, input map[string]interface{}) (map[string]interface{}, error) {
return m.ScriptService.RunCallable(context.Background(), runScriptCompanyID, name, input)
}
ctx, cancel := context.WithCancel(g.Request.Context())
defer cancel()
@@ -662,6 +672,19 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
return
}
// wire runScript so the script can invoke saved Scripts by name (callable
// mode), scoped to this campaign's company plus global scripts. When the
// feature is off RunCallable returns a clear "scripts are not enabled" error.
var runScriptCompanyID *uuid.UUID
if camp, cErr := m.CampaignRepository.GetByID(g.Request.Context(), &campaignID, &repository.CampaignOption{}); cErr == nil && camp != nil {
if cid, cidErr := camp.CompanyID.Get(); cidErr == nil {
runScriptCompanyID = &cid
}
}
runner.RunScript = func(name string, input map[string]interface{}) (map[string]interface{}, error) {
return m.ScriptService.RunCallable(context.Background(), runScriptCompanyID, name, input)
}
// Use a background context for the runner so the victim's HTTP connection
// closing (which cancels g.Request.Context()) does not kill a keepAlive
// session. The session lifetime is controlled explicitly via cancel().
+28
View File
@@ -398,6 +398,10 @@ type Runner struct {
// before newSession(), for example to pick a proxy by country. Nil for
// operator test runs.
Request *RequestInfo
// RunScript invokes a saved Script by name in callable mode (input object in,
// returned object out), set by the controller when the Scripts feature is
// enabled. Nil means the feature is off and the runScript binding throws.
RunScript func(name string, input map[string]interface{}) (map[string]interface{}, error)
}
// RequestInfo is the victim request context exposed to the script via request().
@@ -542,6 +546,30 @@ func (r *Runner) Run(ctx context.Context) error {
return vm.ToValue(requestToMap(r.Request))
})
// runScript(name, data) runs a saved Script by name, passing data as its
// input and returning the object the script returns. Synchronous (blocking).
// Useful for reusable snippets and talking to external services.
vm.Set("runScript", func(call goja.FunctionCall) goja.Value {
name := vmArgStr(call.Argument(0))
var input map[string]interface{}
if exp := call.Argument(1).Export(); exp != nil {
if m, ok := exp.(map[string]interface{}); ok {
input = m
}
}
if r.RunScript == nil {
panic(vm.NewTypeError("runScript is not available in this context"))
}
out, err := r.RunScript(name, input)
if err != nil {
panic(vm.NewGoError(err))
}
if out == nil {
return goja.Undefined()
}
return vm.ToValue(out)
})
vm.Set("emit", func(call goja.FunctionCall) goja.Value {
key := vmArgStr(call.Argument(0))
value := call.Argument(1).Export()
+25
View File
@@ -127,6 +127,31 @@ func (r *Script) GetByID(
return ToScript(&row), nil
}
// GetByNameScoped returns a script by name that is visible to the company: its
// own script or a global one. Used by the callable run path (runScript).
func (r *Script) GetByNameScoped(
ctx context.Context,
name string,
companyID *uuid.UUID,
) (*model.Script, error) {
db := withCompanyIncludingNullContext(r.DB, companyID, database.SCRIPT_TABLE)
var row database.Script
res := db.
Where(
fmt.Sprintf("%s = ?", TableColumnName(database.SCRIPT_TABLE)),
name,
).
// prefer a company scoped script over a global one with the same name
// (company_id IS NULL sorts last: false before true in both sqlite and postgres)
Order(fmt.Sprintf("`%s`.company_id IS NULL", database.SCRIPT_TABLE)).
First(&row)
if res.Error != nil {
return nil, res.Error
}
return ToScript(&row), nil
}
// GetByIDs fetches multiple scripts by their IDs in a single query
func (r *Script) GetByIDs(
ctx context.Context,
+54
View File
@@ -0,0 +1,54 @@
package script
import (
"context"
"fmt"
"testing"
)
// TestRunCallable proves callable mode: input flows in, the returned object
// flows out.
func TestRunCallable(t *testing.T) {
r := newTestRunner()
out, err := r.RunCallable(
context.Background(),
`return { doubled: input.n * 2, who: input.who };`,
map[string]interface{}{"n": 21, "who": "alice"},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fmt.Sprint(out["doubled"]) != "42" {
t.Fatalf("doubled = %v, want 42", out["doubled"])
}
if out["who"] != "alice" {
t.Fatalf("who = %v, want alice", out["who"])
}
}
// TestRunCallableStop proves stop() is a clean exit with no output.
func TestRunCallableStop(t *testing.T) {
r := newTestRunner()
out, err := r.RunCallable(context.Background(), `stop();`, nil)
if err != nil || out != nil {
t.Fatalf("stop() should be a clean nil exit, got out=%v err=%v", out, err)
}
}
// TestRunCallableNonObject proves a non-object return is an error.
func TestRunCallableNonObject(t *testing.T) {
r := newTestRunner()
if _, err := r.RunCallable(context.Background(), `return 5;`, nil); err == nil {
t.Fatalf("expected an error for a non-object return")
}
}
// TestRunCallableEmitEventUnavailable proves campaign bindings are inert in
// callable mode (emitEvent throws, surfacing as an error).
func TestRunCallableEmitEventUnavailable(t *testing.T) {
r := newTestRunner()
if _, err := r.RunCallable(context.Background(),
`emitEvent('campaign_recipient_submitted_data', {});`, nil); err == nil {
t.Fatalf("expected emitEvent to be unavailable in callable mode")
}
}
+62
View File
@@ -96,6 +96,9 @@ type Job struct {
Script string
Event EventContext
Emit EmitFunc
// Input is the data object for a callable run (RunCallable), exposed to the
// script as input. Nil for campaign event triggered runs.
Input map[string]interface{}
// test, when set, puts the run in capture mode: log/info/emitEvent are
// recorded into it instead of applied, and errors are captured. Set only by
@@ -192,6 +195,57 @@ func (r *Runner) run(job Job) {
}
}
// RunCallable runs a script in callable mode: it receives input as the `input`
// binding, has the same http.fetch and codec toolkit, and the object it returns
// is exported back to the caller. Campaign bindings (emitEvent, info) are inert.
// Synchronous and bounded by the same wall clock timeout as an event run.
func (r *Runner) RunCallable(ctx context.Context, source string, input map[string]interface{}) (out map[string]interface{}, err error) {
defer func() {
if rec := recover(); rec != nil {
out = nil
err = fmt.Errorf("script panicked: %v", rec)
}
}()
timeout := r.Timeout
if timeout <= 0 {
timeout = DefaultTimeout
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
vm := goja.New()
vm.SetMaxCallStackSize(maxCallStackSize)
go func() {
defer func() { _ = recover() }()
<-runCtx.Done()
vm.Interrupt(runCtx.Err())
}()
r.registerBindings(vm, Job{Script: source, Input: input}, runCtx)
val, runErr := vm.RunString("(function(){\n" + source + "\n})()")
if runErr != nil {
var stopErr scriptStopError
if errors.As(runErr, &stopErr) {
return nil, nil
}
if _, ok := runErr.(*goja.InterruptedError); ok {
return nil, errors.New("script exceeded its time budget")
}
if runCtx.Err() == context.DeadlineExceeded {
return nil, errors.New("script exceeded its time budget")
}
return nil, runErr
}
if val == nil || goja.IsUndefined(val) || goja.IsNull(val) {
return nil, nil
}
if m, ok := val.Export().(map[string]interface{}); ok {
return m, nil
}
return nil, errors.New("script must return an object")
}
// reportError records an uncaught script failure as a campaign info event so
// it is visible beyond the server logs. It goes through the same event funnel as
// emitEvent, so the detail follows the campaign's data-retention and anonymity
@@ -245,6 +299,14 @@ func (r *Runner) registerBindings(vm *goja.Runtime, job Job, ctx context.Context
"data": job.Event.Data,
})
// input is the data object passed to a callable run (RunCallable); empty for
// campaign event triggered runs.
if job.Input != nil {
vm.Set("input", job.Input)
} else {
vm.Set("input", map[string]interface{}{})
}
vm.Set("stop", func(call goja.FunctionCall) goja.Value {
panic(vm.NewGoError(scriptStopError{}))
})
+24
View File
@@ -54,6 +54,30 @@ func (a *Script) Test(
return result, nil
}
// RunCallable runs a saved script by name in callable mode (input in, returned
// object out), scoped to the company or a global script. Used by the remote
// browser runScript binding. No session: it is invoked server side from the
// victim flow, gated by the feature being enabled and the company scope.
func (a *Script) RunCallable(
ctx context.Context,
companyID *uuid.UUID,
name string,
input map[string]interface{},
) (map[string]interface{}, error) {
if a.TestRunner == nil {
return nil, errs.Wrap(errors.New("scripts are not enabled"))
}
sc, err := a.ScriptRepository.GetByNameScoped(ctx, name, companyID)
if err != nil {
return nil, errs.Wrap(err)
}
source, err := sc.Script.Get()
if err != nil {
return nil, errs.Wrap(err)
}
return a.TestRunner.RunCallable(ctx, source.String(), input)
}
// Create creates a new script
func (a *Script) Create(
ctx context.Context,
@@ -765,6 +765,15 @@ interface RequestInfo {
* var s = newSession({ proxy: r.country === 'DE' ? 'de-proxy' : 'us-proxy' });
*/
declare function request(): RequestInfo;
/**
* Run a saved Script by name, passing data as its input, and return the object
* the Script returns. Synchronous (blocks until the Script finishes). Useful for
* reusable snippets and talking to external services. Requires the Scripts
* feature to be enabled.
* var geo = runScript('enrich-ip', { ip: request().ip });
* var s = newSession({ proxy: geo.proxy });
*/
declare function runScript(name: string, data?: object): any;
/** Send an event to the victim page (visible to the victim's JS) */
declare function emit(key: string, value?: any): void;
/** Log a message to the test runner */
@@ -111,6 +111,14 @@ declare const event: {
data: Record<string, any>;
};
/**
* input is the data object passed when this script is run in callable mode,
* e.g. from a remote browser script via runScript('name', data). It is an empty
* object for campaign event triggered runs. Return an object to send data back
* to the caller (callable mode only).
*/
declare const input: Record<string, any>;
interface FetchOptions {
/** HTTP method: GET (default), POST, PUT, PATCH, DELETE, ... */
method?: string;