diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 1e33e0bd..7c5845d7 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -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, diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index de3f99d4..9bc124a6 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -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(). diff --git a/backend/remotebrowser/runner.go b/backend/remotebrowser/runner.go index 9e3fd344..a3dd2b8c 100644 --- a/backend/remotebrowser/runner.go +++ b/backend/remotebrowser/runner.go @@ -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() diff --git a/backend/repository/script.go b/backend/repository/script.go index 6b47a5a1..2b382de2 100644 --- a/backend/repository/script.go +++ b/backend/repository/script.go @@ -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, diff --git a/backend/script/callable_test.go b/backend/script/callable_test.go new file mode 100644 index 00000000..9258f3c3 --- /dev/null +++ b/backend/script/callable_test.go @@ -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") + } +} diff --git a/backend/script/script.go b/backend/script/script.go index d609eb94..e6694eeb 100644 --- a/backend/script/script.go +++ b/backend/script/script.go @@ -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{})) }) diff --git a/backend/service/script.go b/backend/service/script.go index e1a78f14..5db4f054 100644 --- a/backend/service/script.go +++ b/backend/service/script.go @@ -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, diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte index 529c730a..8cae29ba 100644 --- a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte @@ -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 */ diff --git a/frontend/src/lib/components/script/ScriptEditor.svelte b/frontend/src/lib/components/script/ScriptEditor.svelte index df2b0587..f5391603 100644 --- a/frontend/src/lib/components/script/ScriptEditor.svelte +++ b/frontend/src/lib/components/script/ScriptEditor.svelte @@ -111,6 +111,14 @@ declare const event: { data: Record; }; +/** + * 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; + interface FetchOptions { /** HTTP method: GET (default), POST, PUT, PATCH, DELETE, ... */ method?: string;