From ed567b3e312c98a888802d05de59d1b1a033c99a Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Sat, 12 Sep 2026 13:39:19 +0200 Subject: [PATCH] added state helpers to remote browser Signed-off-by: RonniSkansing --- backend/embedded/files.go | 3 + backend/embedded/remotebrowser_prelude.js | 118 ++++++++++++++++++ backend/remotebrowser/prelude_test.go | 95 ++++++++++++++ backend/remotebrowser/runner.go | 8 ++ .../remote-browser/RemoteBrowserEditor.svelte | 47 +++++++ 5 files changed, 271 insertions(+) create mode 100644 backend/embedded/remotebrowser_prelude.js create mode 100644 backend/remotebrowser/prelude_test.go diff --git a/backend/embedded/files.go b/backend/embedded/files.go index c9c408f7..f9d1bb7c 100644 --- a/backend/embedded/files.go +++ b/backend/embedded/files.go @@ -10,6 +10,9 @@ var TrackingPixel []byte //go:embed remotebrowser_inject.js var RemoteBrowserInjectJS string +//go:embed remotebrowser_prelude.js +var RemoteBrowserPreludeJS string + // SigningKey1 is verifing the signed .sig file when updating // //go:embed signingkeys/public1.bin diff --git a/backend/embedded/remotebrowser_prelude.js b/backend/embedded/remotebrowser_prelude.js new file mode 100644 index 00000000..a7e28d33 --- /dev/null +++ b/backend/embedded/remotebrowser_prelude.js @@ -0,0 +1,118 @@ +// Remote browser script prelude. +// +// Adds a small state machine on top of the session so a script declares how to +// recognize each page once, then runs a loop that acts on the current page. +// Loaded into the script VM before the user script, so these helpers are ready +// when newSession() is called. Built only on the public session methods. +(function () { + if (typeof newSession !== "function") { + return; + } + var baseNewSession = newSession; + + // pageInspector reads the current page. Passed to matchers and actions as p. + // p.url current URL as a plain string + // p.present(sel) the selector matches at least one node + // p.count(sel) how many nodes match + // p.text(sel) text of the first match + // p.visible(sel) the first match is rendered and not hidden + // p.query(name) value of a URL query parameter, decoded, or null + function pageInspector(s) { + var url = s.location(); + return { + url: url, + present: function (sel) { return s.getNodeCount(sel) > 0; }, + count: function (sel) { return s.getNodeCount(sel); }, + text: function (sel) { return s.getText(sel); }, + visible: function (sel) { + return s.evaluate( + "(function(){var e=document.querySelector(" + JSON.stringify(sel) + ");" + + "if(!e){return false;}var r=e.getBoundingClientRect();var st=getComputedStyle(e);" + + "return (r.width>0||r.height>0)&&st.visibility!=='hidden'&&st.display!=='none';})()" + ) === true; + }, + query: function (name) { + var key = String(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + var m = url.match(new RegExp("[?&]" + key + "=([^&]*)")); + return m ? decodeURIComponent(m[1]) : null; + } + }; + } + + // firstMatch checks each rule in insertion order and returns the first state + // name whose matcher is truthy, or null when none match. + function firstMatch(s, rules) { + var p = pageInspector(s); + var names = Object.keys(rules); + for (var i = 0; i < names.length; i++) { + var name = names[i]; + var hit = false; + try { hit = !!rules[name](p); } catch (e) { hit = false; } + if (hit) { return name; } + } + return null; + } + + // waitForState polls the rules until one matches or the timeout runs out. + // Returns the matching state name, or "timeout". + function waitForState(s, rules, timeoutMs) { + if (!timeoutMs) { timeoutMs = 10000; } + var deadline = Date.now() + timeoutMs; + while (true) { + var name = firstMatch(s, rules); + if (name) { return name; } + if (Date.now() >= deadline) { return "timeout"; } + s.wait(250); + } + } + + newSession = function (opts) { + var s = baseNewSession(opts); + var declared = null; + + // states declares the detection rules once: name -> matcher(p). + s.states = function (rules) { + declared = rules; + return s; + }; + + // waitForState returns the current state name using the given rules, or the + // rules declared with states(). Returns "timeout" if none match in time. + s.waitForState = function (rules, timeoutMs) { + return waitForState(s, rules || declared || {}, timeoutMs); + }; + + // run drives the loop: detect the current state, run its action, repeat. An + // action ends the loop by returning false or calling loop.stop(); any other + // return re-detects. The built in "timeout" state fires when nothing matched + // within detectTimeout. opts: { detectTimeout, timeout } in milliseconds. + s.run = function (actions, opts) { + if (!declared) { throw new Error("run: call states({...}) before run({...})"); } + opts = opts || {}; + var detectTimeout = opts.detectTimeout || 10000; + var overall = opts.timeout || 0; + var startedAt = Date.now(); + var stopped = false; + var loop = { state: null, stop: function () { stopped = true; } }; + + while (true) { + var state; + if (overall && Date.now() - startedAt > overall) { + state = "timeout"; + } else { + state = waitForState(s, declared, detectTimeout); + } + loop.state = state; + var action = actions[state]; + if (!action) { + if (typeof log === "function") { log("[run] no action for state", { state: state }); } + return state; + } + var result = action(pageInspector(s), loop); + if (result === false || stopped) { return state; } + } + }; + + return s; + }; +})(); diff --git a/backend/remotebrowser/prelude_test.go b/backend/remotebrowser/prelude_test.go new file mode 100644 index 00000000..6cab611c --- /dev/null +++ b/backend/remotebrowser/prelude_test.go @@ -0,0 +1,95 @@ +package remotebrowser + +import ( + "testing" + + "github.com/dop251/goja" + "github.com/phishingclub/phishingclub/embedded" +) + +// TestPreludeStateMachine runs the real prelude JS in a goja VM against a +// stubbed session and asserts states(), run(), and the loop control work. It +// checks the two mechanics the prelude relies on: reassigning the newSession +// global and adding methods to the session object from JS. +func TestPreludeStateMachine(t *testing.T) { + vm := goja.New() + + // stage advances as actions run, simulating page progression: + // 0 password, 1 totp, 2 done. + stage := 0 + + newSession := func(goja.FunctionCall) goja.Value { + s := vm.NewObject() + _ = s.Set("location", func(goja.FunctionCall) goja.Value { + if stage >= 2 { + return vm.ToValue("https://myaccount.example/home") + } + return vm.ToValue("https://login.microsoftonline.com/step") + }) + _ = s.Set("getNodeCount", func(call goja.FunctionCall) goja.Value { + sel := call.Argument(0).String() + if stage == 0 && sel == "input[type=password]" { + return vm.ToValue(1) + } + if stage == 1 && sel == "input[name=otc]" { + return vm.ToValue(1) + } + return vm.ToValue(0) + }) + _ = s.Set("getText", func(goja.FunctionCall) goja.Value { return vm.ToValue("") }) + _ = s.Set("evaluate", func(goja.FunctionCall) goja.Value { return vm.ToValue(false) }) + _ = s.Set("wait", func(goja.FunctionCall) goja.Value { return goja.Undefined() }) + return s + } + if err := vm.Set("newSession", newSession); err != nil { + t.Fatalf("set newSession: %v", err) + } + + var logs []string + _ = vm.Set("log", func(call goja.FunctionCall) goja.Value { + logs = append(logs, call.Argument(0).String()) + return goja.Undefined() + }) + + if _, err := vm.RunString(embedded.RemoteBrowserPreludeJS); err != nil { + t.Fatalf("prelude failed to load: %v", err) + } + + script := ` + var visited = []; + var s = newSession({}); + if (typeof s.states !== "function") { throw new Error("s.states missing"); } + if (typeof s.run !== "function") { throw new Error("s.run missing"); } + if (typeof s.waitForState !== "function") { throw new Error("s.waitForState missing"); } + + s.states({ + password: function (p) { return p.present("input[type=password]"); }, + totp: function (p) { return p.present("input[name=otc]"); }, + done: function (p) { return !p.url.includes("microsoftonline.com"); }, + }); + + s.run({ + password: function (p, loop) { visited.push("password"); advance(); }, + totp: function (p, loop) { visited.push("totp"); advance(); }, + done: function (p, loop) { visited.push("done"); loop.stop(); }, + }, { detectTimeout: 1000 }); + + visited.join(","); + ` + + // advance() bumps the Go stage counter so the stub page moves forward. + _ = vm.Set("advance", func(goja.FunctionCall) goja.Value { + stage++ + return goja.Undefined() + }) + + v, err := vm.RunString(script) + if err != nil { + t.Fatalf("script failed: %v", err) + } + got := v.String() + want := "password,totp,done" + if got != want { + t.Fatalf("state walk = %q, want %q (logs: %v)", got, want, logs) + } +} diff --git a/backend/remotebrowser/runner.go b/backend/remotebrowser/runner.go index 7a5d36e8..163531a1 100644 --- a/backend/remotebrowser/runner.go +++ b/backend/remotebrowser/runner.go @@ -25,6 +25,8 @@ import ( "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/launcher/flags" "github.com/go-rod/rod/lib/proto" + + "github.com/phishingclub/phishingclub/embedded" ) // Config holds browser connection and execution settings configurable by platform admins. @@ -1554,6 +1556,12 @@ func (r *Runner) Run(ctx context.Context) error { return session }) + // Load the script prelude (state machine helpers) before the user script so + // its helpers are ready when the script calls newSession(). + if _, perr := vm.RunString(embedded.RemoteBrowserPreludeJS); perr != nil { + emitter.log("[prelude] " + perr.Error()) + } + _, err := vm.RunString("(function(){\n" + r.Script + "\n})()") if err != nil { // errors.Is/As traverse goja.Exception.Unwrap(), which extracts the Go error diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte index 4d7ecf7b..90c4fff6 100644 --- a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte @@ -326,6 +326,37 @@ interface RaceCondition { after?: number; } +/** Reads the current page. Passed to state matchers and run actions as p. */ +interface PageInspector { + /** Current URL as a plain string */ + url: string; + /** The selector matches at least one node */ + present(selector: string): boolean; + /** How many nodes match the selector */ + count(selector: string): number; + /** Text of the first match */ + text(selector: string): string; + /** The first match is rendered and not hidden */ + visible(selector: string): boolean; + /** Value of a URL query parameter, decoded, or null */ + query(name: string): string | null; +} + +/** Controls the loop started by run(). Passed to each action as the 2nd argument. */ +interface RunLoop { + /** Name of the state currently being handled */ + state: string; + /** End the loop after the current action finishes. Works from any callback. */ + stop(): void; +} + +interface RunOptions { + /** Milliseconds to wait for a state to match each cycle (default 10000) */ + detectTimeout?: number; + /** Overall budget in milliseconds for the whole loop (0 = no limit) */ + timeout?: number; +} + interface Session { // ── Navigation ──────────────────────────────────────────────────────────── /** Navigate to a URL and wait for the page to load */ @@ -634,6 +665,22 @@ interface FrameSession { */ withTimeout(ms: number, fn: (s: FrameSession) => void): boolean; + // ── State machine ───────────────────────────────────────────────────────── + /** Declare how to recognize each page: state name -> matcher. Call before run(). */ + states(rules: { [state: string]: (p: PageInspector) => boolean }): Session; + /** + * Return the current page state name, or "timeout" if none match within + * timeoutMs (default 10000). Uses the given rules, or those set with states(). + */ + waitForState(rules?: { [state: string]: (p: PageInspector) => boolean }, timeoutMs?: number): string; + /** + * Run the state loop: detect the current state, run its action, repeat. + * An action ends the loop by returning false or calling loop.stop(); any + * other return re-detects. The built in "timeout" state fires when nothing + * matched within detectTimeout. + */ + run(actions: { [state: string]: (p: PageInspector, loop: RunLoop) => any }, options?: RunOptions): string; + // ── Nested iframes ──────────────────────────────────────────────────────── /** Scope a sub-session to a nested iframe within this frame. Returns null if not found. */ frame(selector: string): FrameSession | null;