mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-03 05:56:52 +02:00
added script support in remote browser script
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
9 files changed
+234
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{}))
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user