mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-04 22:46:49 +02:00
added state helpers to remote browser
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
5 files changed
+271
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
})();
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user