mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-03 14:06:51 +02:00
303 lines
8.9 KiB
Go
303 lines
8.9 KiB
Go
package remotebrowser
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/go-rod/rod/lib/proto"
|
|
)
|
|
|
|
// Header rewriting lets an operator script add, replace, or drop HTTP headers on
|
|
// the traffic between the remote browser and the sites it talks to. It is driven
|
|
// by four script methods (setRequestHeader, removeRequestHeader,
|
|
// setResponseHeader, removeResponseHeader) that register rules here.
|
|
//
|
|
// The rewrite runs over the CDP Fetch domain using continueRequest and
|
|
// continueResponse only. The browser still opens its own connection and loads
|
|
// the body itself; this code only edits the header list as it passes. Responses
|
|
// are never re-fetched or synthesized, so the real protocol, transfer sizes, and
|
|
// connection timing stay intact and a normal login is not disturbed.
|
|
|
|
// headerDir is the traffic direction a rule applies to.
|
|
type headerDir int
|
|
|
|
const (
|
|
headerDirRequest headerDir = iota
|
|
headerDirResponse
|
|
)
|
|
|
|
// headerRule is one add-or-replace (set) or drop (remove) instruction, scoped to
|
|
// a set of URL globs. An empty targets list matches every request.
|
|
type headerRule struct {
|
|
key string
|
|
value string // used only when set is true
|
|
set bool // true = add or overwrite, false = remove
|
|
targets []string
|
|
}
|
|
|
|
// headerRules holds the per-session rule set and the Fetch interception state.
|
|
// It is read from the Fetch event goroutine and written from the script (goja)
|
|
// thread, so every access is guarded by mu.
|
|
type headerRules struct {
|
|
mu sync.Mutex
|
|
request []headerRule
|
|
response []headerRule
|
|
installed bool
|
|
}
|
|
|
|
// add appends a rule for one direction.
|
|
func (hr *headerRules) add(dir headerDir, r headerRule) {
|
|
hr.mu.Lock()
|
|
defer hr.mu.Unlock()
|
|
if dir == headerDirResponse {
|
|
hr.response = append(hr.response, r)
|
|
} else {
|
|
hr.request = append(hr.request, r)
|
|
}
|
|
}
|
|
|
|
// ensureInstalled enables Fetch interception and starts the event loop the first
|
|
// time any rule is registered. It is safe to call on every rule add. Callers
|
|
// register rules before navigation, so at install time the page is about:blank
|
|
// with no requests in flight and none can be missed while the loop starts.
|
|
//
|
|
// Both the request and the response stage are enabled so request-direction and
|
|
// response-direction rules both fire. That pauses each request twice; the cost
|
|
// is one extra continue per request, paid only once header rewriting is used.
|
|
func (hr *headerRules) ensureInstalled(page *rod.Page, emitter *channelEmitter) error {
|
|
hr.mu.Lock()
|
|
if hr.installed {
|
|
hr.mu.Unlock()
|
|
return nil
|
|
}
|
|
hr.installed = true
|
|
hr.mu.Unlock()
|
|
|
|
// EachEvent subscribes and enables Fetch (request stage) synchronously, then
|
|
// returns a wait function that drains events. The explicit FetchEnable below
|
|
// runs after it to add the response stage; the later enable sets the patterns.
|
|
wait := page.EachEvent(func(e *proto.FetchRequestPaused) {
|
|
hr.handle(page, e)
|
|
})
|
|
|
|
err := proto.FetchEnable{
|
|
Patterns: []*proto.FetchRequestPattern{
|
|
{URLPattern: "*", RequestStage: proto.FetchRequestStageRequest},
|
|
{URLPattern: "*", RequestStage: proto.FetchRequestStageResponse},
|
|
},
|
|
}.Call(page)
|
|
if err != nil {
|
|
hr.mu.Lock()
|
|
hr.installed = false
|
|
hr.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
go wait()
|
|
|
|
if emitter != nil {
|
|
emitter.log("[session] header rewriting enabled (Fetch interception)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handle continues one paused request, editing headers when a rule matches. It
|
|
// must continue every request it sees, matched or not, or the request stalls.
|
|
func (hr *headerRules) handle(page *rod.Page, e *proto.FetchRequestPaused) {
|
|
url := ""
|
|
if e.Request != nil {
|
|
url = e.Request.URL
|
|
}
|
|
|
|
// Response stage: the request is at the response stage when a status code or
|
|
// an error reason is present. Only a real response (status code set) can have
|
|
// its headers rewritten; an errored response is passed through untouched.
|
|
if e.ResponseStatusCode != nil || e.ResponseErrorReason != "" {
|
|
hr.mu.Lock()
|
|
rules := hr.response
|
|
hr.mu.Unlock()
|
|
|
|
if e.ResponseStatusCode == nil || !anyRuleMatches(rules, url) {
|
|
_ = proto.FetchContinueResponse{RequestID: e.RequestID}.Call(page)
|
|
return
|
|
}
|
|
existing := entriesFromHeaderList(e.ResponseHeaders)
|
|
newHeaders := applyHeaderRules(rules, url, existing)
|
|
// continueResponse requires the response code alongside modified headers.
|
|
code := *e.ResponseStatusCode
|
|
_ = proto.FetchContinueResponse{
|
|
RequestID: e.RequestID,
|
|
ResponseCode: &code,
|
|
ResponsePhrase: e.ResponseStatusText, // empty lets CDP fill the standard phrase
|
|
ResponseHeaders: newHeaders,
|
|
}.Call(page)
|
|
return
|
|
}
|
|
|
|
// Request stage.
|
|
hr.mu.Lock()
|
|
rules := hr.request
|
|
hr.mu.Unlock()
|
|
|
|
if e.Request == nil || !anyRuleMatches(rules, url) {
|
|
_ = proto.FetchContinueRequest{RequestID: e.RequestID}.Call(page)
|
|
return
|
|
}
|
|
existing := entriesFromNetworkHeaders(e.Request.Headers)
|
|
newHeaders := applyHeaderRules(rules, url, existing)
|
|
_ = proto.FetchContinueRequest{
|
|
RequestID: e.RequestID,
|
|
Headers: newHeaders,
|
|
}.Call(page)
|
|
}
|
|
|
|
// headerKV is one header name and value while rules are applied.
|
|
type headerKV struct {
|
|
name string
|
|
value string
|
|
}
|
|
|
|
func entriesFromHeaderList(list []*proto.FetchHeaderEntry) []headerKV {
|
|
out := make([]headerKV, 0, len(list))
|
|
for _, h := range list {
|
|
if h == nil {
|
|
continue
|
|
}
|
|
out = append(out, headerKV{name: h.Name, value: h.Value})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func entriesFromNetworkHeaders(h proto.NetworkHeaders) []headerKV {
|
|
out := make([]headerKV, 0, len(h))
|
|
for name, v := range h {
|
|
out = append(out, headerKV{name: name, value: v.Str()})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// applyHeaderRules rebuilds the header list for one request with every matching
|
|
// rule applied. Removes are applied first, then sets, so a remove followed by a
|
|
// set of the same key yields a single clean value. A set replaces any existing
|
|
// value for its key rather than adding a second one.
|
|
func applyHeaderRules(rules []headerRule, url string, existing []headerKV) []*proto.FetchHeaderEntry {
|
|
remove := map[string]bool{}
|
|
setOrder := []headerKV{}
|
|
setIndex := map[string]int{}
|
|
|
|
for _, r := range rules {
|
|
if !ruleMatches(r, url) {
|
|
continue
|
|
}
|
|
lk := strings.ToLower(r.key)
|
|
if r.set {
|
|
if i, ok := setIndex[lk]; ok {
|
|
setOrder[i] = headerKV{name: r.key, value: r.value}
|
|
} else {
|
|
setIndex[lk] = len(setOrder)
|
|
setOrder = append(setOrder, headerKV{name: r.key, value: r.value})
|
|
}
|
|
delete(remove, lk) // a set wins over an earlier remove of the same key
|
|
} else {
|
|
remove[lk] = true
|
|
}
|
|
}
|
|
|
|
out := make([]*proto.FetchHeaderEntry, 0, len(existing)+len(setOrder))
|
|
for _, e := range existing {
|
|
lk := strings.ToLower(e.name)
|
|
if remove[lk] {
|
|
continue
|
|
}
|
|
if _, ok := setIndex[lk]; ok {
|
|
continue // re-added below with the operator value
|
|
}
|
|
out = append(out, &proto.FetchHeaderEntry{Name: e.name, Value: e.value})
|
|
}
|
|
for _, s := range setOrder {
|
|
out = append(out, &proto.FetchHeaderEntry{Name: s.name, Value: s.value})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func anyRuleMatches(rules []headerRule, url string) bool {
|
|
for _, r := range rules {
|
|
if ruleMatches(r, url) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ruleMatches(r headerRule, url string) bool {
|
|
if len(r.targets) == 0 {
|
|
return true
|
|
}
|
|
for _, t := range r.targets {
|
|
if globMatch(t, url) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// globMatch reports whether s matches a pattern where "*" stands for zero or
|
|
// more characters. Matching is case sensitive; the parts between stars must
|
|
// appear in order, and the pattern is anchored at each end unless it starts or
|
|
// ends with a star.
|
|
func globMatch(pattern, s string) bool {
|
|
if pattern == "" || pattern == "*" {
|
|
return true
|
|
}
|
|
parts := strings.Split(pattern, "*")
|
|
pos := 0
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
idx := strings.Index(s[pos:], part)
|
|
if idx < 0 {
|
|
return false
|
|
}
|
|
if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 {
|
|
return false
|
|
}
|
|
pos += idx + len(part)
|
|
}
|
|
if !strings.HasSuffix(pattern, "*") {
|
|
last := parts[len(parts)-1]
|
|
if last != "" && !strings.HasSuffix(s, last) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// headerEditForbidden reports headers that frame or route the message. Editing
|
|
// them corrupts the request or response, so the script methods reject them
|
|
// outright rather than let a rule break the connection.
|
|
func headerEditForbidden(key string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
|
case "content-length", "transfer-encoding", "host", "connection":
|
|
return true
|
|
}
|
|
// HTTP/2 pseudo-headers.
|
|
return strings.HasPrefix(strings.TrimSpace(key), ":")
|
|
}
|
|
|
|
// headerEditRisky reports headers that are safe to edit at the protocol level but
|
|
// commonly break a login flow when changed (cookies and CORS). The script
|
|
// methods allow these but log a warning.
|
|
func headerEditRisky(key string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
|
case "set-cookie", "cookie",
|
|
"access-control-allow-origin",
|
|
"access-control-allow-credentials",
|
|
"access-control-allow-headers",
|
|
"access-control-allow-methods":
|
|
return true
|
|
}
|
|
return false
|
|
}
|