added state helpers to remote browser

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-09-12 13:39:19 +02:00
1 parent f162e9d49c
commit ed567b3e31
5 files changed
+271

No files matched your search

+3
View File
@@ -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
+118
View File
@@ -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;
};
})();
+95
View File
@@ -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)
}
}
+8
View File
@@ -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
@@ -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;