Files
2026-09-13 10:03:27 +02:00

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
}