From 0538ef0381390f2b95566508fdb7da9ca43a2c3c Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Wed, 30 Sep 2026 20:19:35 +0200 Subject: [PATCH] add request() to remote browser Signed-off-by: RonniSkansing --- backend/controller/remoteBrowser.go | 42 +++++++++++ backend/remotebrowser/request_test.go | 72 +++++++++++++++++++ backend/remotebrowser/runner.go | 64 +++++++++++++++++ .../remote-browser/RemoteBrowserEditor.svelte | 30 ++++++++ 4 files changed, 208 insertions(+) create mode 100644 backend/remotebrowser/request_test.go diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index 038bbb2e..de3f99d4 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -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() diff --git a/backend/remotebrowser/request_test.go b/backend/remotebrowser/request_test.go new file mode 100644 index 00000000..1963c563 --- /dev/null +++ b/backend/remotebrowser/request_test.go @@ -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) + } +} diff --git a/backend/remotebrowser/runner.go b/backend/remotebrowser/runner.go index 163531a1..9e3fd344 100644 --- a/backend/remotebrowser/runner.go +++ b/backend/remotebrowser/runner.go @@ -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() diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte index 7b4cea92..529c730a 100644 --- a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte @@ -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 */