From fbeda80a96e138d2956f8acca9f8db49811ffff9 Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Sun, 13 Sep 2026 10:03:27 +0200 Subject: [PATCH] add remote browser manipulate headers Signed-off-by: RonniSkansing --- backend/remotebrowser/browser.go | 68 ++++ backend/remotebrowser/headers.go | 302 ++++++++++++++++++ .../remote-browser/RemoteBrowserEditor.svelte | 24 ++ 3 files changed, 394 insertions(+) create mode 100644 backend/remotebrowser/headers.go diff --git a/backend/remotebrowser/browser.go b/backend/remotebrowser/browser.go index 3b3e908f..13da2765 100644 --- a/backend/remotebrowser/browser.go +++ b/backend/remotebrowser/browser.go @@ -1657,4 +1657,72 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, dbg("✓ injectScript") return goja.Undefined() }) + + // Header rewriting on the traffic between the remote browser and the sites it + // talks to. Four methods register rules that a single Fetch handler applies: + // setRequestHeader(name, value, targets?) + // removeRequestHeader(name, targets?) + // setResponseHeader(name, value, targets?) + // removeResponseHeader(name, targets?) + // targets is an optional URL glob or array of globs; omitted means all + // requests. set creates or overwrites, so it also covers replacing a header. + // Rules run through continueRequest and continueResponse only, so the browser + // still loads each response itself and the connection is not disturbed. + hr := &headerRules{} + + // parseTargets reads the optional trailing targets argument as a single glob + // string or an array of glob strings. Missing or empty means all requests. + parseTargets := func(v goja.Value) []string { + if goja.IsUndefined(v) || goja.IsNull(v) { + return nil + } + switch t := v.Export().(type) { + case string: + if t == "" { + return nil + } + return []string{t} + case []interface{}: + var out []string + for _, e := range t { + if s, ok := e.(string); ok && s != "" { + out = append(out, s) + } + } + return out + } + return nil + } + + // headerBinding builds one of the four header methods. + headerBinding := func(name string, dir headerDir, isSet bool) func(goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + key := strings.TrimSpace(argStr(call.Argument(0))) + if key == "" { + panic(vm.NewTypeError(name + ": header name required")) + } + if headerEditForbidden(key) { + panic(vm.NewGoError(fmt.Errorf("%s: refusing to edit protected header %q, it frames or routes the message", name, key))) + } + var value string + var targetsArg goja.Value + if isSet { + value = argStr(call.Argument(1)) + targetsArg = call.Argument(2) + } else { + targetsArg = call.Argument(1) + } + if headerEditRisky(key) && emitter != nil { + emitter.log(fmt.Sprintf("[remoteBrowser] %s(%q) can break the login flow; editing anyway", name, key)) + } + hr.add(dir, headerRule{key: key, value: value, set: isSet, targets: parseTargets(targetsArg)}) + must(hr.ensureInstalled(page, emitter)) + dbg(fmt.Sprintf("✓ %s %q", name, key)) + return goja.Undefined() + } + } + pc.Set("setRequestHeader", headerBinding("setRequestHeader", headerDirRequest, true)) + pc.Set("removeRequestHeader", headerBinding("removeRequestHeader", headerDirRequest, false)) + pc.Set("setResponseHeader", headerBinding("setResponseHeader", headerDirResponse, true)) + pc.Set("removeResponseHeader", headerBinding("removeResponseHeader", headerDirResponse, false)) } diff --git a/backend/remotebrowser/headers.go b/backend/remotebrowser/headers.go new file mode 100644 index 00000000..896142b5 --- /dev/null +++ b/backend/remotebrowser/headers.go @@ -0,0 +1,302 @@ +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 +} diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte index 98b8ac01..7b4cea92 100644 --- a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte @@ -511,6 +511,30 @@ interface Session { */ setAcceptLanguage(lang: string): void; + // ── Header rewriting ──────────────────────────────────────────────────── + // Rewrite HTTP headers on the traffic between the remote browser and the sites + // it talks to. Rules run over the CDP Fetch domain without re-fetching, so the + // browser still loads each response itself and the connection is not disturbed. + // targets is an optional URL glob ("*" matches any run of characters) or an + // array of globs; omitted means every request. Call before navigate(). + // Framing and routing headers (Content-Length, Transfer-Encoding, Host, + // Connection, HTTP/2 pseudo-headers) are rejected because editing them breaks + // the connection. + /** + * Add or overwrite a request header on the way to the site. + * @example s.setRequestHeader("X-Debug", "1", "*login.example.com*") + */ + setRequestHeader(name: string, value: string, targets?: string | string[]): void; + /** Drop a request header before it reaches the site. */ + removeRequestHeader(name: string, targets?: string | string[]): void; + /** Add or overwrite a response header before the browser sees it. */ + setResponseHeader(name: string, value: string, targets?: string | string[]): void; + /** + * Drop a response header before the browser sees it. + * @example s.removeResponseHeader("X-FooBar") + */ + removeResponseHeader(name: string, targets?: string | string[]): void; + // ── Capture ─────────────────────────────────────────────────────────────── /** Capture cookies and storage; saves to campaign timeline automatically */ capture(options?: CaptureOptions): CaptureResult;