add request() to remote browser

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-09-30 20:19:35 +02:00
1 parent 170a2eace9
commit 0538ef0381
4 files changed
+208

No files matched your search

+42
View File
@@ -12,8 +12,10 @@ import (
"image/png"
"math"
"math/rand"
"net"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
@@ -28,6 +30,7 @@ import (
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/ipdata"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/remotebrowser"
"github.com/phishingclub/phishingclub/repository"
@@ -548,6 +551,42 @@ func (m *RemoteBrowserController) RunByID(g *gin.Context) {
// The handler bridges victim WebSocket messages into the runner's Incoming channel and
// forwards runner events back to the victim. When the runner emits a "capture" event
// the cookies are saved as a CampaignEvent so they appear alongside AITM captures.
// buildRequestInfo captures the victim connection for the script's request()
// binding: the trusted proxy aware IP, its country and ASNs from the ipdata
// store, the JA4 fingerprint, and the request headers.
func (m *RemoteBrowserController) buildRequestInfo(g *gin.Context) *remotebrowser.RequestInfo {
ip := utils.ExtractClientIP(g.Request, m.TrustedProxies)
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
// read the JA4 fingerprint the same way middleware.GetJA4FromContext does,
// by the literal context key and header. The constants are not imported
// because the middleware package imports controller (import cycle).
ja4 := g.GetString("ja4_fingerprint")
if ja4 == "" {
ja4 = g.Request.Header.Get("X-JA4")
}
info := &remotebrowser.RequestInfo{
IP: ip,
JA4: ja4,
UserAgent: g.Request.UserAgent(),
AcceptLanguage: g.GetHeader("Accept-Language"),
Headers: map[string]string{},
}
for k := range g.Request.Header {
info.Headers[strings.ToLower(k)] = g.Request.Header.Get(k)
}
if store := ipdata.Get(); store != nil {
if country, ok := store.LookupCountry(ip); ok {
info.Country = country
}
for _, a := range store.LookupASNDetails(ip) {
info.ASNs = append(info.ASNs, remotebrowser.RequestASN{Number: a.ASN, Name: a.Name})
}
}
return info
}
func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
if !m.isEnabled(g) {
return
@@ -612,6 +651,9 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
runner := remotebrowser.NewRunner(scriptVal.String(), cfg)
runner.ExecPath = m.ExecPath
runner.Logger = m.Logger
// describe the victim connection so the script can read it via request()
// before newSession(), e.g. to pick a proxy by country
runner.Request = m.buildRequestInfo(g)
campaignID, err1 := cr.CampaignID.Get()
recipientID, err2 := cr.RecipientID.Get()
+72
View File
@@ -0,0 +1,72 @@
package remotebrowser
import (
"testing"
"github.com/dop251/goja"
)
// TestRequestToMapNil proves a nil Request still yields a fully shaped object so
// a script reading request().country never hits undefined.
func TestRequestToMapNil(t *testing.T) {
m := requestToMap(nil)
for _, k := range []string{"ip", "country", "asns", "ja4", "userAgent", "acceptLanguage", "headers"} {
if _, ok := m[k]; !ok {
t.Fatalf("nil request map missing key %q", k)
}
}
if m["country"] != "" {
t.Fatalf("expected empty country, got %v", m["country"])
}
if got := m["asns"].([]interface{}); len(got) != 0 {
t.Fatalf("expected empty asns, got %v", got)
}
}
// TestRequestToMapValues proves the fields map to the lowercase JS keys.
func TestRequestToMapValues(t *testing.T) {
ri := &RequestInfo{
IP: "1.2.3.4",
Country: "DE",
ASNs: []RequestASN{{Number: 16509, Name: "AMAZON-02"}},
JA4: "t13d1516h2_x",
Headers: map[string]string{"user-agent": "UA"},
}
m := requestToMap(ri)
if m["ip"] != "1.2.3.4" || m["country"] != "DE" || m["ja4"] != "t13d1516h2_x" {
t.Fatalf("scalar fields wrong: %v", m)
}
asn := m["asns"].([]interface{})[0].(map[string]interface{})
if asn["number"] != uint32(16509) || asn["name"] != "AMAZON-02" {
t.Fatalf("asn mapping wrong: %v", asn)
}
if m["headers"].(map[string]interface{})["user-agent"] != "UA" {
t.Fatalf("headers mapping wrong: %v", m["headers"])
}
}
// TestRequestBindingGoja proves the request() binding round-trips into JS the
// way the runner wires it, so request().country and request().asns[0].name are
// readable from a script.
func TestRequestBindingGoja(t *testing.T) {
r := &Runner{Request: &RequestInfo{
IP: "9.9.9.9",
Country: "DK",
ASNs: []RequestASN{{Number: 15169, Name: "GOOGLE"}},
Headers: map[string]string{"accept-language": "da-DK"},
}}
vm := goja.New()
vm.Set("request", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(requestToMap(r.Request))
})
v, err := vm.RunString(`(function(){
var r = request();
return r.country + "|" + r.asns[0].name + "|" + r.headers["accept-language"];
})()`)
if err != nil {
t.Fatalf("script error: %v", err)
}
if got := v.String(); got != "DK|GOOGLE|da-DK" {
t.Fatalf("unexpected: %q", got)
}
}
+64
View File
@@ -393,6 +393,63 @@ type Runner struct {
// keepAliveActive is set by s.keepAlive() so Run() parks after the script
// finishes, waiting for the operator to explicitly end the session.
keepAliveActive atomic.Bool
// Request describes the victim connection that started this session. The
// controller populates it before Run so the script can read it via request()
// before newSession(), for example to pick a proxy by country. Nil for
// operator test runs.
Request *RequestInfo
}
// RequestInfo is the victim request context exposed to the script via request().
// It is filled by the caller (the controller) from the incoming connection.
type RequestInfo struct {
IP string `json:"ip"`
Country string `json:"country"`
ASNs []RequestASN `json:"asns"`
JA4 string `json:"ja4"`
UserAgent string `json:"userAgent"`
AcceptLanguage string `json:"acceptLanguage"`
Headers map[string]string `json:"headers"`
}
// RequestASN is one autonomous system the request IP belongs to.
type RequestASN struct {
Number uint32 `json:"number"`
Name string `json:"name"`
}
// requestToMap converts the request info into a plain map so goja exposes the
// exact lowercase JS keys. A nil Request yields an empty shaped object so a
// script reading request().country never hits undefined.
func requestToMap(ri *RequestInfo) map[string]interface{} {
if ri == nil {
return map[string]interface{}{
"ip": "",
"country": "",
"asns": []interface{}{},
"ja4": "",
"userAgent": "",
"acceptLanguage": "",
"headers": map[string]interface{}{},
}
}
asns := make([]interface{}, 0, len(ri.ASNs))
for _, a := range ri.ASNs {
asns = append(asns, map[string]interface{}{"number": a.Number, "name": a.Name})
}
headers := make(map[string]interface{}, len(ri.Headers))
for k, v := range ri.Headers {
headers[k] = v
}
return map[string]interface{}{
"ip": ri.IP,
"country": ri.Country,
"asns": asns,
"ja4": ri.JA4,
"userAgent": ri.UserAgent,
"acceptLanguage": ri.AcceptLanguage,
"headers": headers,
}
}
// IncomingMsg is an event sent from the client into the running script.
@@ -478,6 +535,13 @@ func (r *Runner) Run(ctx context.Context) error {
panic(vm.NewGoError(scriptStopError{}))
})
// request() returns the victim connection that started this session (IP,
// country, ASNs, JA4, headers). Available before newSession() so a script can
// gate or pick a proxy by country.
vm.Set("request", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(requestToMap(r.Request))
})
vm.Set("emit", func(call goja.FunctionCall) goja.Value {
key := vmArgStr(call.Argument(0))
value := call.Argument(1).Export()
@@ -735,6 +735,36 @@ interface FrameSession {
/** Open a new browser session */
declare function newSession(options?: SessionOptions): Session;
interface RequestASN {
/** Autonomous system number, e.g. 16509 */
number: number;
/** Autonomous system name / org, e.g. "AMAZON-02" */
name: string;
}
interface RequestInfo {
/** The victim's request IP (trusted proxy aware). */
ip: string;
/** ISO country code from the GeoIP database, e.g. "DE". Empty if unknown. */
country: string;
/** Autonomous systems the IP belongs to. Empty unless the ASN package is downloaded. */
asns: RequestASN[];
/** JA4 TLS fingerprint of the connection. Empty if not captured. */
ja4: string;
/** The User-Agent header. */
userAgent: string;
/** The Accept-Language header. */
acceptLanguage: string;
/** All request headers, keys lowercased. */
headers: { [name: string]: string };
}
/**
* The victim connection that started this session. Available before
* newSession(), for example to pick a proxy by country:
* var r = request();
* var s = newSession({ proxy: r.country === 'DE' ? 'de-proxy' : 'us-proxy' });
*/
declare function request(): RequestInfo;
/** 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 */