Skip to content

feat(html): an HTML Input block, and remote control of the page it renders - #879

Merged
srperens merged 52 commits into
mainfrom
feat/cef-devtools-proxy
Oct 2, 2026
Merged

srperens merged 52 commits into
mainfrom
feat/cef-devtools-proxy

Conversation

@srperens

@srperens srperens commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

What this is

An HTML source that is a block rather than raw elements, and a way for an
operator to click in the page it renders.

A page behind a login shows its login screen for as long as the flow runs,
because the browser is on the server and nobody can reach it. This adds a route
to that browser: the operator opens a link, sees the page exactly as it is on
air, and clicks and types into it — logs in, clears a consent dialog, picks a
tab. Nothing about the session is copied anywhere; the browser that is
rendering is the browser that gets authenticated.

Warning

The debug port is now open by default. Before this PR, no Chromium debug port was opened unless an operator configured one. Now every Strom that runs an HTML source opens the full Chrome DevTools Protocol on a loopback port, even with no authentication and no cef.debug_port set. Strom needs it to guard every page and for remote control.

Loopback is not a credential. Any other local user or process that shares the network namespace can connect without authenticating, for example another container on host networking. Through the port it can run arbitrary JavaScript in the page, read file:// URLs, and read every cookie, including the sessions of pages an operator logged in to.

Run Strom where nothing untrusted shares its network namespace: its own container on its own network, which is already the rule for a shared Strom. Do not publish or forward the port.

The HTML Input block

builtin.html_input. URL, viewport width and height, framerate, and whether the
page's audio comes out as a second pad.

cefsrc -> capsfilter -> cefdemux -> video -> videoconvert -> [video_out]
                                 -> audio -> audioconvert -> audioresample -> [audio_out]

cefsrc sizes its browser from the caps negotiated downstream, so the viewport
belongs to the block rather than to whatever follows. cefdemux is built only
when audio is asked for, since splitting the audio back out of the video buffers
is all it does. Audio-only still renders the page and drops the frames, because
the audio arrives as metadata on them.

Raw cefsrc pipelines keep working. The block spares the operator the caps, and
gives Strom a name for "this HTML source" — which is what the link endpoint
needs in order to hand out a link for one page rather than for every page.

The URL is live: cefsrc loads a new url into a running browser. It does not
flag the property GST_PARAM_MUTABLE_PLAYING, so the generic live-write path
used to refuse it; html_input::try_apply_live_url writes it directly. The
viewport is not live, and says so.

An HTML source renders http, https and data URLs only. It is an
allowlist, not a list of what to refuse: view-source:file:// reaches the
filesystem without starting with file:, and Chromium keeps its own pages
behind chrome:, devtools: and chrome-extension:. A bare address such as
example.com is read as https://. The same normalize_url check holds when
the block is built, when its URL changes on air, and in the remote control
proxy.

Remote control

With authentication configured, Strom passes remote-debugging-port to CEF
on a free loopback port. Nothing outside Strom connects to it: links go
through Strom's proxy, and Strom guards every page through it (below), so
there is no instance-level opt-in. cef.debug_port (or
STROM_CEF_DEBUG_PORT) only pins the number. Whether a block hands out links
is its Remote Control switch. persist-session-cookies rides along, because
without it a login lands in a cookie Chromium keeps in memory only and the
next flow start is logged out again. Chromium binds the port to loopback and
it stays there.

Remote control requires Strom's authentication to be configured.
auth_middleware passes everything when authentication is off, so minting a
link would take no credentials at all. With no STROM_ADMIN_USER or
STROM_API_KEY the debug port is not opened, and the first HTML source built
says once that its page is not guarded.

The link carries the page and nothing else

The Chrome DevTools Protocol behind the debug port is full control of the
browser process: arbitrary JavaScript, navigation to file://, every cookie in
the profile, an HTTP client inside the network. Handing that to whoever the
operator wants to have log in is not acceptable, so a link does not hand it out.

A link opens Strom's own page (backend/static/remote-control/), served from
under the key. It shows the page through Page.startScreencast, sends mouse,
touch and keyboard input back, and has back, forward, reload and home. Strom
terminates the WebSocket and forwards only:

Page.enable  Page.startScreencast  Page.stopScreencast  Page.screencastFrameAck
Input.dispatchMouseEvent  Input.dispatchKeyEvent  Input.insertText
Page.getNavigationHistory  Page.navigateToHistoryEntry  Page.reload  Page.navigate

Every navigation and history command is rebuilt with only the parameters it
needs. Page.reload also takes scriptToEvaluateOnLoad, which is arbitrary
JavaScript, and Page.navigate takes a frameId. Page.navigate goes only to
an address normalize_url accepts.

Two commands are Strom's own, not Chromium's:

  • Strom.goHome: the proxy turns it into Page.navigate to the block's URL.
  • Strom.setHome (the pin button): makes the page the operator is on the
    block's URL, through update_block_properties with the same check, and
    broadcasts FlowUpdated. An operator who has logged in or clicked through to
    the right page sets it as where the source starts.

What a link reaches, and what its warning now says: the page's picture and
input, any http, https or data address, and the block's URL. From inside
the network an http address reaches whatever the server can, and a data
address runs script of the holder's choosing. It never reaches the filesystem
or Chromium's own pages.

Each session opens with a Strom.context message from the proxy carrying the
block's name and the home URL, so the page's header says which source is under
control. The page names that source and nothing else: no product name and no
flow name, so it can be handed on as it is or put behind another product, such
as Open Live, with a reverse proxy. Its socket is one path along from wherever
the page was loaded, so it works under any path prefix. A session that cannot
open, because the block's flow is not running, gets Strom.unavailable with
the reason and the page shows it.

On a phone: a tap is a click, a drag scrolls, and a keyboard button focuses a
hidden field whose text goes over as Input.insertText.

Nothing the client sends goes to Chromium as it arrived: an allowed command is
rebuilt from its parsed id, method and params, so anything else on the
message (a top-level sessionId, say, which would aim it at another session)
stays behind. Everything else is answered with a CDP error against the
command's own id, so a client gets a refusal rather than a socket that swallows
things. The filter is
in the proxy, not in the page: the page we serve is only the first client of the
link, and the filter is what makes it safe to hand to a second one.

Terminating the socket is also what keeps Chromium's Origin check
(--remote-allow-origins) out of the picture: our connection to Chromium
carries no origin.

Why the filter cannot be added later

DevTools needs exactly the domains the filter exists to refuse — Runtime,
Debugger, DOM, Network. Serving DevTools and filtering the protocol are two
modes, not one with a switch. Shipping the unfiltered one first would mean
never being able to restrict it without taking the feature away. The feature's
purpose needs a picture and an input device, nothing more, so that is the
default and the only thing a link does unless the operator opts out.

Full DevTools, opt-in

cef.full_devtools = true (or STROM_CEF_FULL_DEVTOOLS=1) serves Chromium's
own DevTools application instead, with the protocol unfiltered. CEF serves that
application itself, so nothing is fetched from the internet. The /ui/ asset
route exists only in this mode.

In this mode a link is control of the host, and it is instance-wide: one
CEF process serves every cefsrc in an instance, and the unfiltered protocol
can attach to any page in it, so a session started on one page reaches all of
them. Separate browser contexts (below) do not change that, so full DevTools
must stay off on a Strom shared between customers. The docs and the
warning the API returns say this in so many words, and the two modes carry
different warnings (SCREENCAST_CONTROL_WARNING vs REMOTE_CONTROL_WARNING),
so no client can present one as the other.

The link is a capability

Minting needs Strom's authentication. The link itself is one opaque key — no
API token, no Chromium target id, no address or port. It is meant to be pasted
into a browser or read off a phone screen, so anything in it is something the
operator cannot avoid handing over with it.

POST   /api/flows/{flow}/blocks/{block}/devtools/link
POST   /api/devtools/targets/{target_id}/link
  -> {"id": "...", "path": "/devtools/<key>", "expires_in_seconds": 1800, "warning": "..."}

GET    /api/devtools/targets        which pages exist
GET    /api/devtools/links          what is live, by id, without keys
DELETE /api/devtools/links/{id}     revoke one
DELETE /api/devtools/links          revoke all

The key is the credential for everything under /devtools/<key>: the page and
its socket (plus the DevTools files in full mode). It expires after 30 minutes
of disuse. Opening the link starts that over, and so does the operator's input
in a session on it (clicks, keys, navigation and Strom's own commands), so a
session being worked in does not expire. Screencast upkeep such as frame acks
does not count, so a tab left open and only watched is closed when the time
runs out. The session reports use to the link table every 15 s and sweeps it
at the same time, so expiry ends a session even when nothing else touches the
table. The id is not a credential — it is what revocation takes,
so a link can be killed without anyone keeping the key. Expiry and revocation
both end sessions that are already open, not only the next one.

A link is for the block, not for one page of it. A session resolves the
block's page when it opens, so the same link works across a restart of the
flow, and the listing names links by flow and block. When the session opens,
on every 15 s check and on Home, it reads the block's home address and Strict
Network Access, so a change made in the UI is not missed. A session ends when
the block's page closes, wherever it is looking, and when the flow stops. The
start page cannot be set while a popup is on show: the popup's one-time login
address would go on air and be saved.

Which page is a block's

Chromium says two things about a page: its target id and its current URL.
Matching blocks to pages by URL broke in both directions. A page that had
logged in or redirected to another site was reported as "not rendering a page
yet" while it was on air. Two blocks on one URL, such as two HTML Input blocks
left on the default address, were refused as ambiguous.

The target id, unlike the URL, stays the same for the whole life of a page,
across navigations to any site. So every page is now named when it is born.
With the debug port open (that is, with authentication configured), each
cefsrc starts on about:blank#strom-<token>,
with a random token nothing else knows. Once Chromium lists a page on that URL,
Strom records its target id against the block or element and points the
element at its real URL. The blank start is Chromium's initial empty document,
which the first navigation replaces. The page guard (below) makes Chromium
keep it as a history entry, so the guard clears the history once the page has
left the start and finished loading. A URL set on air while the page is being
born is the one it loads: live writes and the birth go through one lock. If
Chromium has not listed the page 5 s after its element started, Strom loads
the URL anyway rather than leave a blank page on air. Reading the element's
url while its page is being born gives the URL it is about to load, never the
marker.

This needs nothing from gstcefsrc beyond its url property, so it works with
the upstream plugin as well as Strom's build. Without a debug port nothing
changes: the element loads its URL straight away. The URL matching and the
409 for two blocks on one URL are gone. The target endpoint traces a page
back to the block it was born for, and a raw cefsrc page has an owner but
no Remote Control switch, so it is refused.

Guarding every page

Strom's gstcefsrc patches fix some of what an offscreen browser does wrong,
and Strom cannot tell from the plugin which of them it has. The debug port is
therefore always open (on loopback; authentication gates minting links, not
who can reach a loopback port), and every named page gets a guard: a
DevTools session of Strom's own for the page's life. It intercepts file
choosers, denies downloads, makes print() a no-op, dismisses alert,
confirm and prompt, and refuses getDisplayMedia. The last two are
replaced on the prototype before any page script runs, so the page cannot
take them back, and the guard auto-attaches to frames from other sites and
gives them the same. These hold only while a session is attached, so it stays
attached, and it is in place before the page leaves its blank start.

Screen capture needed this on every build: gstcefsrc runs Chromium with
enable-media-stream, which grants media requests without asking the
element, so the permission handler the old offscreen-handlers patch installed
never ran. getDisplayMedia was granted on upstream and on Strom's build
alike. In strom-full that is an empty Xvfb screen; on a desktop it is the
operator's real one.

gstcefsrc also loses its browser once a popup closes (below), and then
ignores a new url. From birth on, an on-air URL therefore goes to the page
over DevTools instead of through the element, on every build.

Popups

A login that opens a window - "Sign in with...", an OAuth consent screen - is a
target of its own, and a session used to show only the page its link was
minted for, so clicking "Sign in" appeared to do nothing. With the Chrome
runtime a popup is not painted into cefsrc's video, so it never goes on air.

The proxy now watches the browser's targets (Target.setDiscoverTargets on
the browser endpoint) and follows the ones the session's page opened, through
any number of popups. A new popup is shown straight away; when it closes -
an OAuth window closes itself when done - the session goes back to whoever
opened it. The page lists the windows as tabs, and a popup can be closed from
there. Strom.switchPage and Strom.closePage name a target, and the proxy
refuses any target that is not the session's page or opened from it.

A login that runs entirely in a popup (Microsoft Teams, for one) leaves the
popup logged in and the source's own page still on the sign-in screen. Each
popup tab has a button that loads the popup's page into the source and closes
the popup: Strom.adoptPage names the popup's target only, the proxy looks the
address up in the target info it already follows, refuses anything but the
session's own popups, and puts the address through the same scheme and Strict
Network Access checks as any other navigation. Setting it as the start page
stays a separate step, so a one-time address with a login token in it is not
saved by accident.

The target listing leaves out anything with an opener, since a popup belongs
to the page that opened it.

A gstcefsrc bug this surfaced

A popup is created with the element's own CEF client, so its OnBeforeClose
arrives in the element's handler, which set src->browser to null without
checking which browser closed. From then on the element had lost its browser
while the page kept running:

  • a new url was accepted by Strom and silently ignored by cefsrc
    (if (src->browser) LoadURL(...)), so URL changes and "set as start page"
    stopped working after any OAuth login;
  • on stop the page was never closed, and kept running - logged in, on the
    network - until the process exited.

docker/gstcefsrc/popup-close.patch ignores a close for any browser but the
element's own, and the gstcefsrc Dockerfile applies it. Every page that opens
and closes a window hits this, remote control or not.

One browser context per HTML source

Upstream gstcefsrc creates every browser with a null request context, which is
the global one, so every HTML source in a Strom process shared one cookie jar,
one local storage and one HTTP cache. Measured in strom-full: flow B read the
session cookie, persistent cookie and localStorage that flow A's page had set.
On a Strom shared between customers, a login made for one customer was a
login for all of them, remote control or not.

docker/gstcefsrc/isolated-context.patch adds three cefsrc properties, all
off by default:

  • isolated-context: create the browser in its own CefRequestContext.
    Popups opened by the page inherit it.
  • context-cache-path: persist that context. Chromium accepts a profile only
    as a direct child of the root cache path, and silently keeps one in memory
    anywhere deeper.
  • persist-session-cookies: Chromium's flag reaches only the global context;
    an isolated one carries its own setting.

A persisted context initializes asynchronously, and CreateBrowserSync in it
returns null, which gstcefsrc then dereferenced. The browser is now made in
OnRequestContextInitialized, and a null browser is an element error instead
of a crash.

When the plugin has the property, Strom turns isolation on for every
cefsrc:

  • The HTML Input block gets a profile keyed by flow and block id. A new
    browser_profile property names a profile several blocks share.
  • Raw cefsrc elements get one keyed by flow and element id, under their
    own prefix, unless the flow sets isolated-context itself. This is how Open
    Live drives HTML sources, and how DSK graphics are made.

Without the property, Strom warns for every HTML source it builds, and a
filtered link's warning adds SHARED_CONTEXT_WARNING: a login made through the
link reaches every HTML source in the instance. Whether the plugin isolates is
checked when a link is minted, when the plugin is already loaded.

With the patched plugin, HTML sources of different customers do not see each
other's sessions, and a filtered link reaches only its own page and that
page's profile. That is not enough to share one Strom between customers who
may be hostile to each other; see Sandbox
below.

Strict Network Access

An HTML source could be pointed at the server itself. Through the block's
URL, remote control's address bar or the pin button, http://127.0.0.1:9222/json/list
rendered the debug port's listing into the source: every page in the process,
with its full URL, other customers' included. On a cloud VM
http://169.254.169.254/ would render the instance metadata. Chromium's Local
Network Access does not check a top-level load, so nothing stopped it. Found
by hand in the dev image.

The HTML Input block gets Strict Network Access, on by default. With it
on, the URL, a live URL change, Page.navigate and Strom.setHome refuse a
host on the server or its local network: loopback, RFC 1918, link-local,
100.64/10, unspecified and broadcast, IPv6 loopback, unique-local and
link-local, IPv4-mapped IPv6, and localhost / *.localhost. The address is
parsed the way Chromium parses it, so 127.1, 0x7f000001 and 2130706433
are loopback too. Off is for a Strom on the operator's own machine rendering its
own local pages. Its description says, in capitals, that a system letting
customers edit flows must never expose it.

What the page itself requests is Chromium's to check.
docker/gstcefsrc/strict-network.patch adds strict-network to cefsrc:

  • A strict browser's Local Network Access prompt is answered with deny, any
    other with allow. Offscreen CEF would otherwise only ignore the prompt.
  • enable-features can be repeated in GST_CEF_CHROME_EXTRA_FLAGS. The list
    is split on commas, so a feature list could not be passed at all before.

Strom now always enables LocalNetworkAccessForNavigations,
LocalNetworkAccessChecksWebSockets and LocalNetworkAccessChecksWebTransport.
It sets strict-network from the block, and on by default for raw cefsrc
elements.

WebRTC UDP is not restricted for strict sources. An earlier build set
webrtc.ip_handling_policy=disable_non_proxied_udp; strom4 drops it. A page
can only make the browser send STUN connectivity checks, not data of its own,
while blocking UDP broke most WebRTC pages, which are a real use as sources.
Probing internal UDP ports is the exposure left, and docs/HTML_RENDER.md
says so.

A hostname that only resolves to an internal address still gets past the
check on the page's own URL. That, and anything Chromium gets wrong, is the
deployment's to close (below).

Devices, dialogs and console output

Left to CEF, a browser with no handler of its own did real damage, measured
in the strom-full dev image: a click on <input type=file> built a GTK file
chooser inside Strom on Xvfb and aborted Strom with every flow in it (exit
133), a page's download was written to /root/Downloads without a click, and
window.print() never returned. The guard above now refuses all of it.

A page's console.log reached the container log twice, and a page logging in
a loop filled the disk. Chromium logs every console message at INFO,
console.error included, so the entrypoint's CEF log severity now defaults to
warning, and Strom sets the same default for native runs
(GST_CEF_LOG_SEVERITY still wins). Measured on upstream with a page logging
on all four levels for three seconds: 1200 lines per level at the old
default, none at warning.

Camera and microphone. Pages that will not start without them (a video
call joined to be watched) were refused outright. Strom now runs Chromium with
use-fake-device-for-media-stream, playing a silent WAV and a black Y4M frame
it writes into the CEF cache directory at startup. Chromium's own fake devices
beep and show a green card, which the others on a call would hear and see.
The switch goes in even when Strom cannot write its files, so a page gets the
test tone rather than the server's capture cards.

What each gstcefsrc patch still buys

With the guard in place, each patch was measured by building gstcefsrc
locally with fewer of them and running the same checks in strom-full
against each build:

Patch Without it Kept
offscreen-handlers The guard does what it did. Its media handling never ran (enable-media-stream). A right click through remote control does not take Strom down No, removed
popup-close With isolated-context and without it, the next flow start after a popup had closed aborted Strom with a corrupted heap: closing the page the element had lost runs the element's close handler on freed memory Yes
isolated-context Two sources share cookies and local storage (measured: B read A's cookie) Yes
strict-network A page's request to a local address hangs instead of being refused, a loose source cannot let a public page reach local addresses, and upstream keeps one enable-features, so WebSocket and WebTransport are not checked Yes

Because closing a page the element has lost corrupts memory, Strom no longer
closes such a page on stop. With popup-close there is none; on upstream
Strom logs that it is still running.

Chromium's sandbox is off in strom-full

Measured in the dev image, not introduced by this PR:

Setup Result
As shipped docker/strom-full/entrypoint.sh passes no-sandbox in every mode. Renderers run as root, in Strom's network namespace
Without no-sandbox, as root Chromium refuses: Running as root without --no-sandbox is not supported
Without no-sandbox, non-root user FATAL: No usable sandbox! Docker's default seccomp blocks the user namespaces it needs, and the FATAL takes Strom down (exit 133)
Same, seccomp unconfined (diagnostic only) The sandbox works: renderers get their own net, pid and user namespaces plus seccomp-bpf

So everything above holds against a page that plays by the browser's rules,
and nothing does against one that exploits a Chromium bug: that is code
running as root in the Strom that serves everyone. docs/HTML_RENDER.md now
states the rule for customers who must not reach each other: one Strom
container per customer
, no --network host, containers unable to reach each
other, and 169.254.0.0/16 dropped (IMDSv2 with hop limit 1 on AWS). Switching
the sandbox on needs a non-root image and a Chromium seccomp profile, which is
a PR of its own.

Publishing the patched plugin

The patches only reach an image through the gstcefsrc-deps release, whose
archives were named by CEF version only. A patched build under that name would
have replaced gstcefsrc-144.0.21-*, the binaries every existing strom-full
image downloads. build-gstcefsrc.yml therefore takes a build_suffix, and
refuses to overwrite a published asset (overwrite_files: false). It prints
each archive's sha256. version.txt in the archive records the gstcefsrc ref
and the patches.

Status: docker/strom-full/Dockerfile downloads
gstcefsrc-144.0.21-strom5 (run 37004818856): popup-close, isolated-context
and strict-network. Each archive is checked against a per-architecture
sha256 before it is unpacked, and a missing asset fails the build. Before
switching, the arm64 archive's sha256 was checked against the release digest
and the published plugin ran the suite in strom-full. The earlier builds
strom1–strom4, and 122.1.13 and 144.0.11, which no tag uses, are
deleted from the release; 138.0.56, 144.0.12 and 144.0.21 stay for the
released tags that download them.

Cleaning up the CEF cache

A profile used to stay in the cache root after its block was gone, holding
the cookies of whatever it was logged in to. Deleting a flow now removes its
sources' own profiles at once. At startup, before any flow runs, Strom removes
every block, element and named profile no stored flow uses. Startup is the
only point where that is safe for a block taken out of a flow that keeps
running. Named profiles go only at startup, since another flow may share one.

Chromium locks the cache root with a SingletonLock naming host and pid, and
treats a lock from another host as held. After a hard kill under a different
hostname (a laptop changing networks is enough) every cefsrc failed with a
bare "Failed to start". The cache root is per data directory, so Strom clears
a lock from another host before CEF starts. A lock from this host is left to
Chromium, which checks the pid itself. In strom-full the entrypoint wipes the
cache at container start anyway; this matters for a cache that persists.

In the UI

A live text property went out on every keystroke, so typing a URL into a
running HTML source loaded every prefix of it. Free-text live properties now
settle: they are sent once the value has not changed for 700 ms. Sliders,
checkboxes and dropdowns keep the 80 ms debounce with a leading edge.

A QR code and an "Open" button in the block's properties, and double-click on
the node, the way the compositor, AES67, NDI, WHEP and WHIP blocks already work.

The UI shows the warning the server returns with each link, which differs by
mode and by whether sources share a browser context, and keeps the link in
memory rather than passing it through local storage, which in a browser is on
disk. Live property writes are debounced per flow, block and property; block
ids repeat across copied flows.

remote_control on the block gates it, off by default. The server refuses to
mint without it. It can be switched on while the flow runs, which is the whole
point — an operator turns it on to intervene in a page that is already on air.

What was measured

Against eyevinntechnology/strom-full with CEF 144 (Chrome 144.0.7559.248,
arm64), driving a real cefsrc through this proxy with a locally built Linux
binary mounted into the image. This was done before the protocol filter
landed, i.e. in what is now the full-DevTools mode:

Flow built from the block, link minted, opened with no credential but the key works
DevTools application and its files through the proxy 200, including the 99 kB common.js
CDP socket through the proxy 101, protocol both ways
Network.setCookie, Input.dispatchMouseEvent, Input.dispatchKeyEvent applied to the live page
Page.startScreencast 240 frames in 8 s, ~30 fps sustained through the proxy
Revoked key, unknown key, malformed key 404

Screencast bandwidth, measured separately: an animated 1080p page costs
~4.8 Mbit/s at 30 fps; capped to 720p/q50/every second frame, ~1 Mbit/s. A
static login page sent one frame in ten seconds — the screencast is
change-driven, and the login case is the cheapest there is. Frames are
ack-gated, so a slow link costs frame rate rather than an unbounded queue.

Also confirmed empirically: a per-element chromium-debug-port cannot give each
cefsrc its own port. CEF initializes once per process, and only the last
element's value takes effect. This is why the setting is instance-level.

The default, filtered mode, measured the same way after the filter landed
(arm64, same image), with a flow built from the block with remote_control on:

Link minted from the block endpoint works
/devtools/<key> 200, the remote control page
/devtools/<key>/ui/* (DevTools assets) 404 — not served in this mode
Socket at /devtools/<key>/ws 101
Runtime.evaluate, Page.navigate, Network.getAllCookies refused, -32601 against the command's id
Input.dispatchMouseEvent applied
First message on the socket Strom.context with flow, block and home URL (the flow name has since been dropped)
Page.getNavigationHistory, Strom.goHome, Page.reload applied
Page.navigate to https:// applied
Page.navigate to file:, view-source:file:, chrome: refused with the reason
Strom.setHome block's stored url updated, page loaded
Block url changed through the API on a running flow the running browser moved to the new page
Block url set to file: on a running flow rejected with the reason, stored value unchanged
Page.startScreencast through the filter 180 frames in 6 s
Revoke by id, then open the path 204, then 404

Pages named at birth, at 614e7d5 in strom-full:dev-e19ffe7 (arm64)
with a locally built Linux binary copied in:

Two HTML Input blocks on the default URL, one flow both links minted, each session on its own page
Page A navigated to https://example.com/ through its link B's page and history unchanged; a new link for A opens A on example.com
Blank start before the real URL marker listed at 0.35 s after flow start, real URL at 0.37 s; not in the page's history
Raw cefsrc with no url loads the plugin's default; target endpoint refuses its page (403)
Target endpoint for each block's page 200
Mint with the flow stopped 404
Flow restarted new target ids, old ones gone, link opens the new page
about:blank pages left over none

Measured before, by hand, with CDP alone: a target id is unchanged after navigating
to another site and then to a data: URL; about:blank#... as the first URL
leaves no history entry once the page navigates.

The guard, on upstream and on Strom's gstcefsrc, at 59f71c6 in
strom-full:latest (upstream gstcefsrc, no isolated-context) and
strom-full:dev-e19ffe7 (Strom's build), arm64, locally built binary copied
in. A data page that calls print(), alert(), confirm() and clicks a
download on load, with a file input and a button that opens a popup and closes
it 1.5 s later:

upstream Strom's build
Page after load all calls returned, confirm false, title ticking same
File input clicked through remote control Strom up, page rendering same
Popup opened and closed page keeps rendering same
On-air URL change after the popup closed lands (steered over DevTools) lands (element)
/root/Downloads empty empty
Flow stopped Strom logs the page outlived its element and closes it page closed by the element
History on first start, and after a restart the real page only same
Same link after a restart opens the new page same
Same link, flow stopped Strom.unavailable with the reason same

The port Strom picks, and page console output, at 7b9defc on upstream
gstcefsrc with the new entrypoint and no cef.debug_port: Strom opened the
port on a free loopback port, a link minted, and the guard was in place
(print() returned). A page logging on all four console levels for three
seconds put 1200 lines per level into the log under the old entrypoint and
none under the new one; Strom now sets the same GST_CEF_LOG_SEVERITY
default for native runs.

The two plugins differ exactly where the popup-close patch says they would:
without it the element ignored the URL change and left its page running, and
Strom's guard covered both.

Popups, against CEF 144 in strom-full (arm64):

Click through the link on a page that opens a popup session switches to it, screencast at the popup's own size (400x300)
Strom.switchPage / Strom.closePage on another source's page refused
Mint a link while the popup is open works
Popup closes itself session back on the page
Popup opened and closed, then block url changed stock plugin: page stays; patched: page navigates
Popup opened and closed, then flow stopped stock plugin: page still listed by CEF afterwards; patched: gone
OAuth-style flow (popup approves, postMessage to opener, closes) tried by hand in a browser: works

Also measured: CDP's mouseWheel takes the DOM's sign (positive deltaY
scrolls down), which the first version of the page had inverted.

The first version of the page was tried by hand in a desktop browser and on an
iPhone against a real page, where taps, scrolling and typing did not work. The
touch and keyboard handling that fixes that has not yet been tried on a
phone
.

Tests

Run locally at fd98c2f (macOS arm64): cargo test --lib (742 passed, 4
ignored), openapi_test (snapshot updated: the link summary names flow and
block instead of a URL, the 409 is gone, descriptions reworded),
html_remote_control_property_test and pipeline_lifecycle_test; the frontend
properties tests. cargo clippy --all-targets is clean, native and wasm32.
The full suite was not run locally at this head; CI runs it.

New tests that guard this round's fixes, each failing without its fix:
cef_pages::a_url_set_on_air_during_birth_is_the_one_loaded and the two
beside it (a fake element with a url property stands in for cefsrc),
a_page_being_born_shows_the_url_it_will_load,
a_stopped_flow_forgets_its_pages_and_no_one_elses,
the_same_block_id_in_two_flows_keeps_two_pending_values in the frontend,
and cef_flags tests for the flag order and the debug-port rules. The guard
and the session changes have no CI test: they need a real CEF. The table
above is how they were checked. Not checked by hand: the pin button greyed out
on a popup in the page, and the refusal of Strom.setHome from a popup.

Run locally on an earlier head (macOS arm64, after rebasing onto main): the full
cargo test, 42 test binaries, all green. That includes pipeline_lifecycle_test,
liveaudiorouter_test and html_remote_control_property_test, which had not
run on this branch in CI before. After the last refactor I re-ran the
devtools and html_input unit tests (68). cargo clippy --all-targets is clean.

New since the last review: a_strict_source_does_not_reach_the_server_or_its_network,
a_block_is_strict_unless_switched_off, filter::a_strict_source_is_not_navigated_onto_the_server,
and in the frontend typed_text_goes_out_once_the_typing_stops (fails without
the fix: the first keystroke went out) and a_slider_still_goes_out_on_the_first_change.
Also: filter::only_the_parsed_command_is_forwarded,
filter::only_the_operator_keeps_a_link_alive,
working_in_a_session_keeps_its_link_alive (the link hold, not the pump that
calls it), without_isolation_the_warning_says_a_login_reaches_every_source,
and the profile-directory tests in html_input, including
a_raw_cefsrc_gets_a_profile_no_block_or_name_can_land_on.

New in 614e7d5: cef_pages unit tests for the page registry, and
placement tests including two_blocks_on_the_same_url_each_get_their_own_page
and a_page_is_found_wherever_it_has_navigated. They run against the
registry, not a browser; the end-to-end check is the table above. At this head
I ran cargo test --lib (733 passed), openapi_test (snapshot updated for the
removed 409 and two reworded descriptions), html_remote_control_property_test
and pipeline_lifecycle_test; clippy is clean.

New in 675fe4e, in cef_profiles:
startup_keeps_the_profiles_flows_use_and_removes_the_rest,
a_deleted_flow_takes_its_own_profiles_and_nothing_else,
a_lock_from_another_host_is_cleared_and_one_from_this_host_is_not and
the_host_is_everything_before_the_pid. They call the module's functions
against a temporary cache root. The calls from load_from_storage,
delete_flow and main are not covered: they read the process-wide
GST_CEF_CACHE_LOCATION, which a test cannot set without racing the others
in its binary.

CI runs the full suite. Specifically relevant: the api::devtools unit tests
(including the method filter, the parameter rewrites, Strom.goHome ignoring any
address the client sends, and refusal-by-id), the html_input unit tests,
html_remote_control_property_test, and the OpenAPI snapshot, which was updated
deliberately. cargo clippy clean for native and wasm32.

html_remote_control_property_test now also refuses file:, view-source: and
chrome: on a running flow through update_block_properties; without the
check the rejection reason is a pipeline write failure and the test fails.

html_remote_control_property_test is a real guard: with the special case
removed, the switch comes back rejected and it fails. A companion test pins the
viewport as not live so the two cannot drift into each other.

Measured by hand in dev-c3e470c (arm64, Docker Desktop, bridge network), with a
data: probe page in two flows, one strict and one loose:

From the page Strict Loose
fetch to 127.0.0.1:9222, localhost:8080, 169.254.169.254 blocked blocked
fetch, XHR, img, window.open to file: blocked blocked
WebSocket to 127.0.0.1:9222 blocked blocked
fetch to https://example.com (control) reached reached
Block URL set to http://127.0.0.1:9222/json/list refused by Strom applied, and rendered

A data: page is not a secure context, so Chromium blocks its local requests
without asking. The strict-network permission answer only decides for https
pages, and that path is not yet tested. Neither are navigation, WebSocket
(the result above does not tell LNA from the debug port's Origin check) and
WebRTC with the new patch.

Not covered by a test that runs in CI: everything involving a real cefsrc,
including try_apply_live_url actually moving a running browser, popup
following, the popup-close, isolated-context and strict-network patches, and the pump
calling the link hold. The isolation for the block was verified by hand
against CEF 144 in strom-full with the patched plugin: two flows isolated,
one profile name shared across flows, session and persistent cookies and
storage kept across a restart, and a popup seeing its opener's context. The
raw-element path uses the same function but has not been run against a real
plugin. The target-family logic that decides
which popups a session may reach is unit-tested in devtools::pages.
A CI test needs one, which #810 is adding; the natural guards to add there are
that a minted link resolves to the browser its block is rendering, and that a
method outside the allowlist is refused against the live browser.

Not in this PR

  • One CEF process per instance. Contexts are per source, but the process
    and the debug port are not. It matters in full-DevTools mode, where it is the
    whole blast radius.
  • Profiles live in the container filesystem, and the strom-full
    entrypoint clears the CEF cache when the container starts. So a login
    survives a flow restart, not a container restart.
  • Chromium's sandbox (see above), and a hostname that resolves to an
    internal address
    as the page's own URL. Both are left to the deployment
    rule in docs/HTML_RENDER.md.
  • Chromium writes its cookie store on a timer, so a login made seconds
    before the process is killed can still be lost.
  • Hands-on checks not yet repeated: touch and keyboard on a phone, the
    strict-network answer for an https page, navigation, WebSocket and WebRTC
    with the patch, and the raw-cefsrc isolation path.
  • Drops: the guard cannot reach them and they were not measured;
    remote control's filter does not forward drag events.
  • CEF 154 and gstcefsrc master. CEF here is 144 (Chromium 144.0.7559.248,
    about eight months behind 154), and renderers run unsandboxed as root in
    strom-full. gstcefsrc master has 22 commits since the pinned ref,
    including leak fixes and new caps towards cefdemux. A PR of its own, run
    against the same local suite before anything is published.
  • Spotify and other DRM pages. They need Widevine, which this CEF build
    has support for but downloads through the component updater the
    entrypoint switches off. STROM_CEF_WIDEVINE=1 leaves it on; Google ships
    the Linux CDM for x86_64 only, and this build has no AAC. Not yet tried on
    amd64.
  • A stale SingletonLock from another host is cleared, which is wrong if
    two Stroms on different hosts share one cache directory. Strom's cache is
    per data directory, so that takes a shared volume used by two containers at
    once.

🤖 Generated with Claude Code

@srperens

srperens commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Update: This was implemented in a9001bd. Links now open Strom's own remote control page, the proxy forwards an allowlist of CDP methods, and the DevTools frontend is behind cef.full_devtools. The allowlist has since grown to include history, reload and Page.navigate, limited to http, https and data URLs. The PR description has the current list.


What the DevTools frontend can and cannot be made to show

The right-hand developer pane is reasonable as a default, but it is not what an operator
logging in to a page actually needs. I measured this against a running CEF instance
(Chrome/144) rather than inferring it, so the following is observed.

The alternative entrypoints exist, but none of them helps

CEF serves six working entrypoints: inspector.html, devtools_app.html, js_app.html,
node_app.html, ndb_app.html and worker_app.html. toolbox.html and
rehydrated_devtools_app.html are empty (0 bytes).

inspector.html is already the right one, and it is worth knowing why: it is exactly
devtools_app.js plus the screencast panel registered as an AppProvider. So
devtools_app.html is the same DevTools without the screencast — the opposite of what
we want. The rest are Node and worker debugging and render no page at all.

Why the panel is always there

ScreencastApp.presentUI() builds a vertical SplitWidget whose main widget is the
screencast view and whose sidebar is the whole InspectorView. The "Toggle screencast"
toolbar button only drives the screencast-enabled setting, and the code behind it is:

e ? this.rootSplitWidget.showBoth() : this.rootSplitWidget.hideMain()

It can hide the page, never the developer panel. There is no built-in screencast-only
mode.

Seeding the split state does not work either. It persists under
inspector-view.screencast-split-view-state, and since we serve the frontend from Strom's
own origin a wrapper page could write it to localStorage — but
onScreencastEnabledChanged() calls showBoth() on every model add and overwrites the
show mode immediately. That route is closed.

What an operator can do today is drag the splitter to the right; that position is
remembered per browser.

What is actually configurable

Grepping the bundles for query parameters that are read: panel=<name>, can_dock and
test. Nothing else. panel selects which panel is preselected, not whether the panel is
visible. So there is no knob here that gets us a page-only view.

The real route: our own CDP client

If we want just the page, the answer is to stop using the DevTools frontend. The proxy
already terminates the WebSocket, and the surface is small: Page.startScreencast,
Page.screencastFrameAck, Input.dispatchMouseEvent and Input.dispatchKeyEvent. A few
hundred lines of JavaScript on a page we serve ourselves. The pump doc comment in this PR
already describes the screencast's ack-based flow control, so the thinking is half done.

There are two payoffs, and the second is the significant one:

  1. The operator gets exactly the remote screen they need and nothing else.
  2. The proxy can then whitelist CDP methods. Today the socket is raw CDP, so a key
    grants Storage.getCookies, Runtime.evaluate and Page.navigate to file:// —
    precisely what the module doc honestly says it grants. With our own client we forward
    screencast and input and nothing else, and the key becomes an actual capability rather
    than total control of the browser. That removes most of the blast radius of a leaked
    key.

inspector.html is worth keeping behind a separate "full DevTools" link for
troubleshooting, but as a deliberately privileged mode rather than the default.

One detail in passing

The entrypoint HTML sets <meta name="referrer" content="no-referrer"> itself, so the key
in the URL is not leaked through Referer — Chromium already covers that. Its CSP does
allow script-src https://chrome-devtools-frontend.appspot.com, and devtoolsFrontendUrl
in /json/list still advertises appspot, but nothing is actually fetched from the internet
because CEF serves the frontend locally and every reference in the served HTML is relative.
The module doc's claim on that point holds.

@srperens srperens mentioned this pull request Sep 25, 2026

@srperens srperens left a comment •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes — the filter and the auth interlock hold up, but one stated behaviour of the link lifetime is contradicted by the code, and half the integration suite never ran at this head. (Posted as a comment: GitHub refuses this verdict from the PR's own account.)

Requested changes

  1. Keep an active session alive, or stop claiming it is. Expiry is pushed out only when a link is resolved or a socket opens — backend/src/api/devtools/mod.rs:294 — link.expires = now + LINK_TTL; — and pump() never touches it. Any later sweep then cancels a socket that has been in continuous use for 30 min: backend/src/api/devtools/mod.rs:235 — if l.expires > now {. Either refresh expires from pump on client traffic (throttled, e.g. once a minute), with a unit test beside an_expiring_link_ends_the_session_on_it that keeps a busy session past the TTL; or say in the body and docs/HTML_RENDER.md that an open session is hard-limited to 30 min.
  2. Forward what you parsed, not what arrived. backend/src/api/devtools/filter.rs:127 — _ if REMOTE_CONTROL_METHODS.contains(&method) => Ok(Forward::AsIs), — sends the client's raw bytes, which serde_json and Chromium parse independently. SPECULATIVE (not verified): a message with two method keys is judged by one parser's pick and executed by the other's. Sending message.to_string() removes the question whichever way Chromium resolves duplicates, at no cost.
  3. Rerun the red job — gh run rerun 36272417465 --failed. See Tests & CI.

Claims

Claim Verdict Evidence
"every use starts that over, so a session in progress does not expire under the operator" CONTRADICTED item 1
Debug port stays closed without Strom auth CONFIRMED backend/src/main.rs:410 — if config.cef_debug_port.is_some() && !auth::AuthConfig::is_configured_in_env() {
Remote control is off per block by default CONFIRMED backend/src/blocks/builtin/html_input.rs:532 — default_value: Some(PropertyValue::Bool(false)),
Network.setCookie etc. apply to a live cefsrc (CEF 144) EXTERNAL author's measurement; assumed
The one red job is test_jitterbuffer_recovers_with_drop_on_latency CONTRADICTED at 4b51d59 it is liveaudiorouter_test::a_routing_change_moves_audio_between_output_channels_while_playing (not-negotiated)

Diagnosis — Right layer: the filter is in the proxy. Mint routes sit in protected_api_router. Page.reload, Page.navigate and history are rebuilt with only their needed params; Page.navigate / Strom.setHome go through the same normalize_url as the block URL.

Radius — SHARED: router, the generic live-write path in properties.rs (gated on an id ending :cefsrc) and CEF start-up flags. With cef.debug_port unset, only the new block changes behaviour. Both defaults above meet #823's shared-instance constraint.

Overlaps — #823: this PR is its chosen direction (my re-read of today). #810 owns the real-cefsrc CI this PR's untested half needs. #789 and #852 also edit main.rs / config.rs: textual conflicts. No new StromEvent variant, so #789's exhaustive match is not hit.

Tests & CI — Check (Linux) red at 4b51d59. cargo test stopped at liveaudiorouter_test: html_remote_control_property_test (4) and the api::devtools unit tests passed, but pipeline_lifecycle_test and every binary after it never ran. That flake is what #881 addresses. macOS/Windows skipped; nothing platform-specific. API Contract Check green.

Also — mod.rs is 1989 lines; CLAUDE.md asks for a split over 1500.

Confidence: MED — filter, pump, link table, URL check and start-up gate read; the page and frontend skimmed.

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This supersedes my review at 4b51d59. The verdict stands at Request changes. The two new commits, 2e2acde (popups) and 7b8132f (gstcefsrc patch), add a feature. Neither addresses requested change 1 or 2, and Check (Linux) is red again at the same flake. (Posted as a comment: GitHub refuses this verdict from the PR's own account.)

Requested changes

  1. Still open: keep an active session alive, or stop claiming it is. Nothing in the new session.rs touches expires. It is still refreshed only on resolve or open, at backend/src/api/devtools/mod.rs:276 — link.expires = now + LINK_TTL;. The sweep still cancels an open socket once 30 min have passed: backend/src/api/devtools/mod.rs:232 — if l.expires > now {. The PR body still says "every use starts that over, so a session in progress does not expire". The module doc now makes the same claim ("after LINK_TTL of disuse"). Refresh from the pump, or reword both.
  2. Still open: forward what you parsed. backend/src/api/devtools/filter.rs:158 — _ if REMOTE_CONTROL_METHODS.contains(&method) => Ok(Forward::AsIs),.
  3. Rebase onto main. The branch's merge-base is 61072ce, which predates #881's liveaudiorouter fix (3292cee). The rerun I asked for could not help.
  4. Say in the body that the popup-close patch reaches no shipped image yet. docker/strom-full/Dockerfile installs the released gstcefsrc-${GSTCEFSRC_VERSION} tarball from the gstcefsrc-deps release, and 7b8132f's message says that tarball is not rebuilt. Until a maintainer publishes a patched build under a new name and bumps the version, strom-full keeps the orphaned-page bug. The "Verified in strom-full" row was a local image build.

Claims

Claim Verdict Evidence
"a session in progress does not expire" CONTRADICTED See item 1
Another source's page cannot be switched to or closed CONFIRMED SwitchPage and ClosePage both gate on family.contains(&target) in backend/src/api/devtools/session.rs. ClosePage also refuses the root. contains walks the opener chain, bounded by the target count. devtools::pages::tests::a_popup_from_our_page_is_ours_and_another_source_is_not ... ok
The patch stops a popup's close from nulling the element's browser EXTERNAL Upstream gstcefsrc at the pinned GSTCEFSRC_REF. The Docker image is built only by manual/tag workflows, so no PR job applies the patch. SPECULATIVE (not verified): the early return also fires when src->browser is still null. If the element's own browser can close before OnAfterCreated runs, state never reaches CEF_SRC_CLOSED

Radius: SHARED, carried from my previous review. The popup watch adds a browser-endpoint socket per session. It lives inside the pump and ends with it.

Overlaps: #823, #810, #789 and #852 are as in my previous review.

Tests & CI: I checked run 36419917992 at 7b8132f. Check (Linux) failed in liveaudiorouter_test (a_routing_change_moves_audio_between_output_channels_while_playing, a_fader_alone_keeps_a_fixed_point_bus_summing_in_float, panicking at line 417). The 6 new devtools::pages and 11 devtools::filter unit tests and html_remote_control_property_test ran first and passed. pipeline_lifecycle_test never ran. The other checks are green. macOS and Windows were skipped.

Also: mod.rs shrank from 1989 to 1782 lines, which is still over CLAUDE.md's 1500. Most of the rest is tests.

Confidence: MED. I read the filter, session, pages and link table. I skimmed the page JS.

srperens and others added 21 commits September 30, 2026 14:45
An HTML source renders server-side, so an operator cannot click in it: a
page behind a login stays on the login screen for as long as the flow
runs. Chromium can be driven remotely over the DevTools protocol, and
CEF opens that port when asked, so the operator can log in to the very
browser that is on air rather than transporting a session into it.

The port is unauthenticated and Chromium binds it to loopback. Keep it
there and reach it through Strom's own port and authentication, the way
WHEP and WHIP are already fronted: /json/list to find the pages,
/devtools/* for the DevTools application (CEF serves the whole thing
itself, so nothing is fetched from the internet), and the per-page
WebSocket for the protocol. Strom terminates that socket and opens its
own, which is what keeps Chromium's Origin check out of the picture.

`cef.debug_port` enables it and nothing else does. `persist-session-cookies`
rides along, because without it a login lands in a cookie Chromium keeps
in memory only and the next flow start is logged out again.

One CEF process serves every cefsrc in the instance, so this is an
instance-wide privilege: from any target the protocol reaches every
cookie in the profile and navigates anywhere, including file://. That is
fine for an operator running their own instance and wrong for one whose
HTML sources belong to different customers, where the isolation has to
come from separate Strom instances.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ance

One CEF process serves every cefsrc in a Strom instance, so a session
opened against one HTML source is not confined to it: it reaches every
other source, every page they are logged in to, and the files on the
host. Keeping customers apart is an orchestration choice - a Strom
process per customer - and not something this API can do, so the only
honest thing is to say so where an operator will actually read it.

Three places: the target listing carries the warning as a field, so
every client says the same thing rather than inventing its own wording;
the startup log states it once when the port is enabled; and the HTML
rendering guide documents the flow with the same caveat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HTML sources have been raw cefsrc/cefdemux elements in imported
pipelines - that is how Open Live drives them today. It works, but it
leaves the operator writing caps by hand, and it leaves Strom with no
name for "this HTML source".

The block takes the URL, the viewport and the framerate, and exposes the
page's audio as a second pad when asked. cefsrc sizes its browser from
the caps negotiated downstream, so the viewport belongs here rather than
in whatever follows. cefdemux is built only when audio is wanted, since
that is the only thing it is there for; audio-only still renders the
page, because the audio arrives as metadata on the video buffers and
something has to consume them.

A missing cefsrc or cefdemux says the plugin is gstcefsrc and that it
ships in strom-full, rather than leaving a bare element-creation error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The link handed to an operator carried the API token, the Chromium
target id and the address and port the DevTools application should call
back on. Anyone the link passes to gets all of it, and a link is meant
to be pasted into a browser or read off a phone screen.

Now it is one opaque key: minting needs Strom's authentication, the key
is the credential for everything beneath /devtools/<key>, and serving
the application below the key is what lets its own relative paths work
without a cookie carrying anything. The key expires after half an hour
of disuse, every use starts that over so a session in progress does not
expire under the operator, and it can be revoked outright. The only
address that still appears is the host the operator themselves reached
us on, taken from their own request, because the application has to be
told where to open its socket.

This also drops the previous handler's trick of turning an API token
into a session cookie to get the application's own requests past
authentication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An operator works in the graph, not in a list of Chromium targets, so
the link comes from the block: a QR code to point a phone at, a button
that opens a tab, and double-click on the node for the same thing the
other blocks already do that way.

The block decides whether it can be controlled at all. Remote Control is
off by default, and the server refuses to mint a link without it - that
is a deliberate step rather than a security boundary, since a session
still reaches every HTML source in the instance, and the panel says so
next to the code.

The server resolves the block to a browser, because only it knows which
page a block is rendering, and the answer changes whenever the page is
recreated. It matches on the URL, which is all Chromium exposes about a
browser, so two blocks sharing a URL are indistinguishable and the
request is refused with a reason rather than guessing between them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The property maps to `_block`, and block-level properties are otherwise
refused at runtime as having no element to write to. That is right for
the viewport, which is baked into the capsfilter when the block is
built, and wrong for this one: nothing in the pipeline reads it, the API
reads it from the stored block when a link is minted, so storing it is
the whole write.

It also has to work on a running flow to be worth having. An operator
turns it on to intervene in a page that is already on air, which is
exactly when stopping the flow is not an option.

Same shape as the Live Audio Router's crosspoint fade, handled before
the `_block` rejection. The guard test fails with the special case
removed - the switch comes back rejected - and the companion test pins
the viewport as not live, so the two cannot drift into each other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correctness:

- Resolve the block's URL through one shared fallback, so a block left at
  its default can mint a link instead of being told it has no URL set.
- Match a block to its Chromium target in three tiers (exact URL, same
  origin, sole survivor) so a login redirect no longer makes the page
  unfindable. Ambiguity is refused rather than guessed at, and matching is
  instance-wide because one CEF process serves every source.
- Gate the target-id endpoint on the block's Remote Control property, by
  the same rule the block endpoint uses, so the two cannot disagree.
- Give both cefdemux branches a queue, as gstcefsrc's own pipeline does,
  so one branch cannot stall the other.
- Follow the debug port actually in force when the operator has set
  remote-debugging-port in GST_CEF_CHROME_EXTRA_FLAGS, rather than dialling
  the configured one.
- Reject a non-boolean Remote Control value instead of storing it and
  reporting it as applied.
- Bound the viewport properties so a large value cannot wrap when cast.
- Carry the flow and block ids as a pair rather than parsing them back out
  of a storage key.
- Reuse one HTTP client for the proxied DevTools assets.
- Revert an unnecessary visibility widening in auth.

Security:

- Revocation and expiry now end a session that is already open. The socket
  outlived both before, so the only emergency stop closed the door to new
  sessions while leaving the browser under the holder's control.
- Redact any 64-hex path segment from the request span. The key is the
  credential and the DevTools application loads roughly a hundred files
  under it, so it was written to the log on every request.
- Validate X-Forwarded-Host as a bare host and port, and percent-encode it,
  so it cannot inject query parameters into the redirect.
- Give each link a non-secret id, list the live links without ever handing
  the key back, and add a revoke-all. Revoking is keyed on the id, so it no
  longer requires keeping a copy of the credential.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A link now opens Strom's own page: the source as it is being rendered, with
clicks and keystrokes passed back to it. The proxy forwards only the screencast
and input methods and answers everything else with a protocol error, so the
link is what it looks like.

The allowlist is the control, and it cannot be added later. DevTools needs
exactly the domains the filter exists to refuse - Runtime, Debugger, DOM,
Network - so serving DevTools and filtering the protocol are two modes, not one
with a switch. Shipping the unfiltered one first would mean never being able to
restrict it without taking the feature away. The feature's own purpose, letting
an operator log in to a page that is on air, needs a picture and an input
device and nothing more.

Without the filter a link was arbitrary file read on this host, every cookie in
the shared profile, and a HTTP client inside the network - handed, by design,
to whoever the operator wanted to have log in.

Chromium's DevTools application stays available behind cef.full_devtools, off
by default, documented as full control of the browser. The DevTools asset route
is served only in that mode.

Remote control also now requires authentication to be configured. auth_middleware
passes everything when authentication is off, so minting was unauthenticated
whenever it was; the debug port is no longer opened at all in that case.

- Refuse anything outside SCREENCAST_METHODS, answering the command it refuses
  by id so a client is not left waiting.
- Serve the remote control page from under the key. It derives the socket from
  its own location, so this path trusts no forwarded header.
- Say which of the two modes a link is, in the warning the API hands clients
  and in the block's property description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The warning still described full DevTools - every page, every cookie, the
host's files - when links carry only the screencast and input. Say what a
link grants in the mode that is in force, and keep saying that the port
itself is full control to anything that can reach loopback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ady uses

Open ran the link through make_external_url, which swaps localhost for the
server's hostname. That is for a QR code read by another device. For a tab
opened in this browser it only breaks things wherever the server's name does
not resolve here - behind Docker it became the container id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…control page

An operator logging in needs to get back from a wrong click, and a phone is
what is at hand. The page now has back, forward, reload and home, takes taps,
drags and the on-screen keyboard, and says which flow and block it controls.

Navigation stays inside what the browser has already shown. History steps
and reload are forwarded with their parameters rebuilt: Page.reload also
takes scriptToEvaluateOnLoad, which is arbitrary JavaScript. Home is
Strom.goHome, which the proxy turns into Page.navigate to the block's own
URL, so the client never names an address. Typing an address stays out:
from inside the network, http alone reaches whatever the server can.

The proxy opens each session with a Strom.context message carrying the flow
and block names and the home URL, so the header can say what is under
control without a new endpoint.

Touch: one finger taps as a click and drags as wheel scrolling, and a
hidden text field takes the phone's keyboard, with its text sent as
Input.insertText. The mouse wheel was inverted - the protocol takes the
DOM's sign, measured against CEF 144 - and Enter and Tab now carry their
characters so a form submits.

The filter moves to devtools/filter.rs, which keeps devtools.rs from
growing past where it already was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stopped flow renders nothing, but its HTML blocks were counted as sources
still looking for their page. The sole-survivor rule - one page left over,
one source left over - then refused any page that had redirected across
origins, which a bare google.com landing on www.google.com already does.
Only running flows are considered now.

A block whose flow is stopped now gets the "is the flow running?" answer
instead of "Block not found".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt page

An HTML source renders http, https and data URLs and nothing else. It is an
allowlist, because a list of what to refuse misses view-source:file:// and
every scheme Chromium keeps its own pages behind. A bare address is read as
https://. The check holds in three places, all through normalize_url: when
the block is built, when its URL changes on air, and in the proxy for
Page.navigate.

The remote control address bar now takes an address, and a pin button makes
the page the operator is on the block's URL (Strom.setHome), so the source
starts there from then on. The proxy performs that against Strom and
broadcasts FlowUpdated so the UI follows. The link warning says what this
now reaches: whatever the server reaches over http, and script of the
holder's choosing through a data address.

The URL was never actually live: cefsrc loads a new url into a running
browser but does not flag the property GST_PARAM_MUTABLE_PLAYING, so the
generic write path refused it. html_input::try_apply_live_url sets it
directly, the way time_offset intercepts its own live write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A login that opens a window - "Sign in with...", an OAuth consent screen -
did nothing an operator could see: the popup is a target of its own, and
the session only ever showed the page the link was minted for. With the
Chrome runtime a popup is not painted into cefsrc's video, so it never went
on air, but it could not be reached either.

The proxy now watches the browser's targets (Target.setDiscoverTargets on
the browser endpoint) and follows the ones the session's page opened,
through any number of popups. A new popup is shown straight away, since the
operator just clicked for it, and when it closes - an OAuth window closes
itself when it is done - the session goes back to whoever opened it. The
page lists the windows as tabs, and a popup can be closed from there.

Strom.switchPage and Strom.closePage name a target, and the proxy checks it
is the session's page or one opened from it. Another HTML source's page, or
its popups, cannot be switched to or closed. Home goes back to the
session's own page before navigating.

Popups were also counted as pages when matching a page to its block. An
open popup looked like a page no block could claim, which could make a
link refuse or pick wrongly. page_targets and the target listing now leave
out anything with an opener; /json/list does not say, so both read the
browser endpoint.

pump moves to devtools/session.rs and the popup logic lives in
devtools/pages.rs, which keeps devtools/mod.rs from growing.

Verified against CEF 144 in strom-full: a click through the link opens the
popup and the session switches to it and screencasts it at its own size;
another source's page is refused for switching and closing; minting still
works with the popup open; when the popup closes itself the session is back
on the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oses

A popup the page opens is created with the element's own CEF client, so its
OnBeforeClose arrives in the same handler, which set src->browser to null
and the state to closed without asking which browser it was. The video
kept flowing, since painting does not look at that pointer, but stop()
checks it: with it null, the page was never closed. It kept running - its
JavaScript, its network traffic and whatever it was logged in to - until
the process exited.

Every page that opens and closes a popup did this, which is exactly what an
OAuth login does. It also broke remote control afterwards: the orphaned
page and the restarted one share a URL, so a link for the block was refused
as ambiguous.

The patch, applied when gstcefsrc is built, ignores a close for any browser
but the element's own.

Verified in strom-full with CEF 144: a page opens a popup that closes
itself, then the flow stops. With the stock plugin the page is still listed
by CEF afterwards; with the patch it is gone.

The released gstcefsrc tarball is not rebuilt here: it is named by CEF
version only, so a patched build needs a suffix before it can be published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…was parsed

A link's expiry was pushed out only when the link was resolved or a socket
opened, so a session the operator was working in was cut off 30 minutes
after it started, while the docs said it would not be. The session now holds
its link: input and navigation from the operator start the time over, and
the session sweeps the table itself, so a tab left open and only watched is
ended by the expiry even when nothing else asks about links.

Allowed methods were forwarded as the client sent them. They are now rebuilt
from the parsed id, method and params, so anything else on the message - a
top-level sessionId, say - stays behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every cefsrc in a Strom process shares one CEF request context: gstcefsrc
creates each browser with a null context, which is the global one. Two
flows' HTML sources therefore share cookies, local storage and the HTTP
cache - on a Strom shared by several customers, a login made for one is
seen by the others. Measured in the strom-full image: flow B read the
session cookie, persistent cookie and localStorage flow A's page had set.

gstcefsrc has no setting for this, pinned or upstream. The patch in
docker/gstcefsrc adds three cefsrc properties, all off by default:

- isolated-context: create the browser in its own CefRequestContext.
  Popups opened by the page inherit it.
- context-cache-path: persist that context. Chromium accepts a profile
  only as a direct child of the root cache path, and silently keeps one
  in memory anywhere deeper.
- persist-session-cookies: the Chromium flag reaches only the global
  context; an isolated one carries its own setting.

A persisted context initializes asynchronously, and CreateBrowserSync in
it returns null - gstcefsrc then crashed on the next line. The browser is
now made in OnRequestContextInitialized, and a null browser is an element
error instead of a crash.

The HTML Input block turns isolation on when the plugin has it and warns
when it does not. A new browser_profile property names a profile several
sources share; empty gives the block its own, keyed by flow and block id,
since block ids are only unique within a flow. Profiles live in the CEF
cache directory, so they survive a flow restart and, in the Docker image,
last until the container is replaced.

Verified against CEF 144 in strom-full with the patched plugin: two flows
isolated, one profile name shared across flows, a restart keeps session
and persistent cookies and storage, a popup sees its opener's context.

Not in this commit: publishing a patched gstcefsrc build (the release
tarball is named by CEF version only, so it needs a suffix), removing a
block's profile when the block is deleted, and DSK graphics, which use
cefsrc outside this block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not be shared

The browser context per source reached only the HTML Input block. A flow
built from raw cefsrc elements - how Open Live drives HTML sources, and how
DSK graphics are made - still put every page in the one global context. The
isolation now lives in html_input::isolate_browser and is applied to every
raw cefsrc as well, with a profile keyed by flow and element id under its own
prefix, unless the flow sets isolated-context itself.

A filtered remote control link is scoped to one source only when the plugin
isolates. Without isolation a login made through it is a login for every
HTML source in the instance, so the link's warning now says that, asked of
the plugin when the link is minted.

docs/HTML_RENDER.md gets a section on profiles and on sharing one Strom
between customers: possible with Strom's gstcefsrc build, not without it;
full DevTools stays off; profile names and who may mint a link for which
source are the job of whatever drives Strom for several customers, or else
one Strom container per customer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matching a Chromium page to the HTML source that renders it is one job with
its own tests, and mod.rs was over the 1500 lines CLAUDE.md allows. It moves
to devtools/placement.rs unchanged; mod.rs is down to 1442 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@srperens
srperens force-pushed the feat/cef-devtools-proxy branch from 7b8132f to ac58994 Compare September 30, 2026 13:01
srperens and others added 8 commits September 30, 2026 19:09
- Check a live URL write in try_apply_live_url itself. The raw element
  endpoint and MCP reach it without update_block_properties, so file://
  or a metadata address could be loaded on air. A cefsrc without the
  strict-network property is treated as strict.
- Revoke links when their block stops allowing them. Switching Remote
  Control off now broadcasts FlowUpdated; a watcher revokes links whose
  flow or block is gone or whose switch is off, which also ends open
  sessions. The link handlers and the session's hold tick check too.
- Count raw cefsrc elements as HTML sources when placing a page, so a
  block's link cannot open a page rendered by a raw element in another
  flow. They never get a link. An exact URL match is also refused when
  another source names the same URL.
- Flush settling live property writes every frame, not only while a
  block is selected, so a URL typed just before deselecting is sent.
- Default URL is https://google.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qAAXC2wcTykBKqGDEYzM7
window.print() froze the page: with no print handler, CEF went into GTK's
print path inside Strom and never returned, tripping a GLib assertion on the
way. offscreen-handlers.patch now cancels every print, so print() returns at
once.

Pages that will not start without a camera and a microphone - a video call
joined to be watched - were refused outright. Strom now runs Chromium with
use-fake-device-for-media-stream, playing a silent WAV and a black Y4M frame
it writes into the CEF cache directory at startup. Chromium's own fake
devices beep and show a green card, which the others on a call would hear and
see. The patch grants camera and microphone only when that switch is on, so a
page gets the synthetic devices and never the server's; screen capture is
always refused.

WebRTC UDP is allowed again for strict sources. Blocking it bought little: a
page can only make the browser send STUN connectivity checks, not data of its
own, while it broke most WebRTC pages, a real use as sources. The remaining
exposure - probing internal UDP ports - is documented in HTML_RENDER.md,
which also gets a section on devices, dialogs and console output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gstcefsrc-144.0.21-strom4 (build run 36874402689): offscreen-handlers now
cancels printing and grants fake media devices, and strict-network no longer
restricts WebRTC UDP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Logging in to Microsoft Teams from its sign-in page runs the whole login in a
popup. When it is done the popup is the window that is logged in and showing
Teams, while the source's own page - the one on air - is still on the sign-in
page. The profile is shared, so pasting the popup's address into the source
worked, but it was an extra step every time.

Each popup tab on the remote control page gets a button that loads the
popup's page into the source and closes the popup. It sends Strom.adoptPage
with the popup's target id only: the proxy looks the address up in the
target info it already follows, refuses anything but this session's own
popups, and puts the address through the same scheme and Strict Network
Access checks as any other navigation. Setting it as the start page stays a
separate step, so a one-time address with a login token in it is not saved by
accident.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mod.rs was 1626 lines, over the 1500 CLAUDE.md sets; most of it was tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot break

A profile was never removed with its block, so on a cache that outlives the
process every HTML source that ever ran left a directory behind, holding the
cookies of whatever it was logged in to. Deleting a flow now removes its
sources' own profiles, and startup removes every block, element and named
profile no stored flow uses. Startup is the only point where removing one is
safe for a block taken out of a flow that keeps running.

Chromium locks its cache root with a SingletonLock naming host and pid, and
treats a lock from another host as held. After a hard kill under a different
hostname - a laptop changing networks is enough - every cefsrc failed with a
bare "Failed to start". The cache root is per data directory, so a lock from
another host is stale; Strom clears it before CEF starts. A lock from this
host is left to Chromium, which checks the pid itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its URL

Remote control found a block's page by matching URLs, because a URL is all
Chromium says about a page. That broke in both directions: a page that had
logged in or redirected to another site was reported as "not rendering a
page yet" while it was on air, and two blocks on the same URL - two HTML
Input blocks left on the default address, say - were refused as ambiguous.

Chromium's target id, unlike the URL, stays the same for the whole life of
a page, across navigations to any site (measured against CEF 144). So with
the debug port open, every cefsrc now starts on about:blank#strom-<token>,
a token nothing else knows. Once Chromium lists a page on that URL, Strom
records its target id against the block or element and points the element
at its real URL. The blank start is Chromium's initial empty document, which
the first navigation replaces, so it leaves nothing in the page's history.
A URL set on air while the page is being born wins.

This needs nothing from gstcefsrc beyond its url property, so it works with
the upstream plugin as well as Strom's build. Without a debug port nothing
changes: the element loads its URL straight away.

The URL matching in placement.rs goes, and with it the 409 for two blocks
on one URL. A link is minted for the page the block was born with, and the
target endpoint traces a page back to the block it was born for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CEF build comes from Spotify's binaries, which carry no proprietary
codecs, so a page has no H.264: Teams gives audio but no video, while
YouTube and Meet work. GPU mode does not give a page hardware video on
Linux either, since Chromium needs VA-API and NVIDIA has none in the
images.

The HTML Input block and remote control run on an upstream gstcefsrc,
so list what each of Strom's four patches buys, the popup-close and
offscreen-handler gaps first.

Note audio and video inputs for the block as a feature idea.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@srperens srperens mentioned this pull request Oct 2, 2026
3 of 9 tasks
srperens and others added 11 commits October 2, 2026 10:27
…one soon

A URL written on air between the element starting and its page being named
could be overwritten by the URL the element was built with: the birth task
read the element's url and then set it, with the live write in between.
Both now go through one lock, and a live write to a page still being born
replaces the URL it will load instead of reaching the element.

The wait for a page was 60 s from build, so a debug port that never
answered left the source blank on air for a minute. The clock now starts
when the element has started, which cefsrc does only once its browser
exists, and Strom gives up after 5 s and loads the URL anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A link was bound to the page it was minted against, so after a flow
restart it opened onto a page that no longer existed and the operator saw
a dead screen with no word why. A link now names the block: a session opens
on whatever page the block renders when it starts, and says so when there
is none (Strom.unavailable). The listing names links by flow and block
instead of the URL they were minted on.

In a session:
- the start page cannot be set while a popup is on show, which put the
  popup's one-time login address on air and saved it;
- the source's page closing ends the session wherever it is looking, and
  the 15 s check now also ends it when the flow is no longer running;
- the home address and Strict Network Access are read from the block when
  the session opens, on every check and on Home, instead of being frozen
  when the link was minted;
- answers to Strom's own commands survive a switch of window, so loading a
  popup into the source is confirmed.

The remote control page names the source and nothing else: no product name
and no flow name, so it can be handed on or put behind another product.

The UI shows the warning the server returns with a link instead of a fixed
sentence that was wrong in filtered mode, and keeps the link in memory: it
went through local storage, which is on disk in a browser.

Live property writes were debounced per block id, which repeats across
flows, so typing in two flows could drop one of the values. The key now
includes the flow.

Deleting a flow that would not stop no longer removes its profiles under a
browser that may still be in them. Stopped flows' pages are forgotten.

While a page is being born, reading the element's url gives the URL it is
about to load, never the marker, so a client cannot save the marker back.

CEF setup moves out of main.rs into cef_flags. On upstream gstcefsrc, which
keeps only the last enable-features, Strom's network checks now come first
with the navigation check last, and an enable-features the operator set
comes after them all and is the one kept. The fake-device switch is passed
even when Strom cannot write its silent devices, so a page gets Chromium's
test tone rather than the server's real camera and microphone.

Stale text: OpenAPI responses no longer say a link opens DevTools; the
devtools module doc and the strict-network property no longer claim WebRTC
is restricted or that there is one cookie jar; HTML_RENDER.md documents
the block endpoint and the block's audio output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…safe to drive

Strom's gstcefsrc patches fix what an offscreen browser does wrong, but a
Strom on upstream gstcefsrc had none of that: a click on a file input
through remote control built a GTK dialog in-process and could abort
Strom, print() froze the page, a download landed on the server's disk, and
once a popup closed the element ignored URL changes and left its page
running after stop. Strom cannot tell from the plugin which patches it has.

With the debug port open, every named page now gets a guard: a DevTools
session of Strom's own for the page's life that intercepts file choosers,
denies downloads, makes print() a no-op and dismisses JavaScript dialogs.
These hold only while a session is attached, so it stays attached. The
guard is in place before the page leaves its blank start.

On an element without isolated-context, which every Strom build has and
upstream lacks, an on-air URL goes to the page over DevTools instead of
through the element, and property reads report it. Stopping a flow closes
any of its pages Chromium still lists.

The guard attaching to the blank start made Chromium keep it as a history
entry, so Back put a blank page on air. The guard clears the history once
the page has left the start and finished loading, retrying if Chromium is
mid-swap.

Measured in strom-full on both upstream gstcefsrc (latest) and Strom's
build (dev-e19ffe7), with a page that prints, alerts, confirms and
downloads on load, a file input clicked through remote control, and a
popup opened and closed: Strom stays up, the page keeps rendering, nothing
is written to disk, an on-air URL change after the popup lands on both,
and on upstream the leftover page is closed on stop. History is clean on
first start and after a restart on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The debug port was opt-in through cef.debug_port, but nothing outside Strom
connects to it: links go through Strom's proxy, and the page guards need it
for every flow, remote control or not. With authentication configured Strom
now opens it on a free loopback port, and cef.debug_port only pins the
number. Whether a block hands out links is its Remote Control switch, as
before. Without authentication the port stays shut, and the first HTML
source built says once that its page is not guarded.

Pages' console output also stays out of the log on upstream gstcefsrc and
on native runs: Chromium logs every console message at INFO, and Strom now
sets GST_CEF_LOG_SEVERITY to warning unless it is set, as the strom-full
entrypoint does. Measured on upstream gstcefsrc with a page logging on all
four console levels for three seconds: 1200 lines per level with the old
default, none with this one. In the same run, with no cef.debug_port set,
print() returned on the page, so the guard was on the port Strom picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gstcefsrc runs Chromium with enable-media-stream, which grants media
requests without asking the element, so the permission handler in Strom's
patch never ran. Measured on upstream and on Strom's build alike:
getDisplayMedia was granted and returned the display. In strom-full that is
an empty Xvfb screen; on a desktop it is the operator's real one.

The guard now replaces getDisplayMedia, and print, on the prototype before
any page script runs, so the page cannot take them back. A frame from
another site is a target of its own that the page's commands do not reach,
so the guard auto-attaches to them, holds each until it has the same
script, and lets it run.

Measured on upstream gstcefsrc: getDisplayMedia is refused with
NotAllowedError on the page and in a cross-site iframe allowed
display-capture, print is the no-op in the frame, camera and microphone are
still the fake devices, and the earlier guard checks still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…entication too

Measured what each gstcefsrc patch still buys now that Strom guards every
page over DevTools, by building gstcefsrc locally with fewer patches and
running the same checks in strom-full against each build.

offscreen-handlers goes. The guard does what it did: file choosers, downloads,
print and JavaScript dialogs. Its media handling never ran, because
gstcefsrc's enable-media-stream grants requests without asking the element:
getDisplayMedia was granted on every build, which the guard now refuses. A
right click through remote control does not take Strom down without it.
Console output stays out of the log through CEF's log severity.

popup-close stays. Without it, with isolated-context, the next flow start
after a popup had closed aborted Strom with a corrupted heap. Closing the
page the element had lost runs the element's close handler on freed
memory, so Strom no longer closes such a page on stop; it says it is still
running. Live URL changes go to the page over DevTools on every build,
since the element ignores them once a popup has closed.

strict-network stays. Without it a page's request to a local address hangs
instead of being refused, a loose source cannot let a public page reach
local addresses, and upstream keeps one enable-features, so WebSocket and
WebTransport are not checked.

The debug port now opens whether or not authentication is configured:
authentication gates minting links, and does not change who can reach a
loopback port. Every page is guarded either way.

A deleted flow's profile that Chromium is still writing is retried in the
background instead of being left with a warning.

Measured on a local build with popup-close, isolated-context and
strict-network: three rounds of the remote-control regression without a
crash, isolation between two sources, screen capture refused in the page
and in a cross-site frame, strict refusing loopback and loose reaching it,
a loose source loading a local page, and the guard without authentication.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spotify's web player will not play in an HTML source: it needs Widevine,
and requestMediaKeySystemAccess('com.widevine.alpha') is refused. libcef
has Widevine support built in (it looks for a WidevineCdm directory), but
the CDM is downloaded at runtime by Chromium's component updater, and the
entrypoint passes disable-component-update and
disable-background-networking in every mode.

STROM_CEF_WIDEVINE=1 leaves those two flags out, so Chromium can fetch the
CDM. Opt-in until it is measured: on arm64 nothing arrived in 90 seconds,
as expected, since Google ships the Linux CDM for x86_64 only. Whether
Spotify then plays on amd64 is not known yet; this CEF build has no AAC
either, which depends on what Spotify serves Chromium.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gstcefsrc-144.0.21-strom5 (build run 37004818856) carries popup-close,
isolated-context and strict-network. Checked before switching: the arm64
archive's sha256 matches the release digest, version.txt lists the three
patches, and in strom-full with the published plugin the remote-control
regression ran twice without a crash, two sources stayed isolated, screen
capture was refused, and strict refused loopback while loose reached it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…play

Spotify's web player needs Widevine and AAC. With STROM_CEF_WIDEVINE=1
the CDM is fetched, but the page reports audio/mp4 mp4a.40.2 as
unsupported, the same proprietary-codec gap as H.264.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This reverts e0050f0. The CDM did download, but Chromium on Linux
registers a component-updated Widevine only at its next start, and the
entrypoint clears the CEF cache on every start, so the CDM was never
usable: requestMediaKeySystemAccess refused com.widevine.alpha even for
codecs the page supports. Spotify would not play anyway, since the CEF
build has no AAC. The docs no longer name the variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review: one issue I'd fix before merge (the shown_url comment on properties.rs), plus three smaller ones. Inline comments below.

Comment thread backend/src/gst/pipeline/properties.rs Outdated
let v = obj.property::<Option<String>>(name);
v.map(PropertyValue::String)
// A cefsrc whose page is being born holds a marker, not its URL.
v.map(|url| crate::cef_pages::shown_url(obj, url))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every string property of a steered cefsrc reads back as the page URL.

shown_url is applied to every gchararray property, not just url. Once a page has loaded, Strom steers its cefsrc (every HTML Input block, now the debug port is always open), and from then on shown_url returns the steered URL whatever property it is asked about. name, context-cache-path and every other string property of that element therefore report the page URL. A client that reads properties and writes them back stores the URL into all of them.

It also takes the steered() mutex for every string property of every element in every pipeline.

Suggested fix: only call it when name == "url".

strom_types::PropertyValue::String(url.clone()),
)]);
let result = app
.update_block_properties(&source.flow_id, &source.block_id, properties, None, None)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Set start page" reloads the page that is on air.

set_home goes through update_block_properties → try_apply_live_url → cef_pages::load_url, which sends Page.navigate to the URL the page is already showing. Pinning the page the operator just reached therefore reloads it live. That loses SPA or form state, and can break a page reached through a one-time redirect.

Storing the start page should not navigate when the page is already on that URL.

Comment thread backend/src/cef_flags.rs
let mut flags = network_checks(&existing);
flags.extend(fake_media_switches());

let port = debug_port(&mut flags, setup.debug_port);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default-on widening of the attack surface. Please call it out in the PR body.

Before this PR, nothing opened Chromium's debug port unless an operator configured it. Now every instance that runs an HTML source opens the full DevTools protocol on loopback, even with no authentication and no cef.debug_port. Any other local user or process sharing the network namespace (e.g. host-network containers) can then run arbitrary JS in the page, read file:// and read every cookie, with no credentials.

The design needs it (it is how every page gets guarded), so I'm not asking to drop it. But it is a behaviour change operators should know about, and the PR description should state it plainly.

Comment thread backend/src/cef_flags.rs
// process could take a free port in between; then pages go
// unguarded and each says so when it starts.
let port = match configured {
Some(port) => port,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A configured port 0 is accepted.

If STROM_CEF_DEBUG_PORT or cef.debug_port is 0, configured is Some(0) and this pushes remote-debugging-port=0. Chromium then picks a random port while Strom records and dials 0. Page guards never attach and remote control always reports "not answering". The branch that parses the port from the flags already rejects 0; this branch (or config validation in config.rs) should do the same, with a clear error.

srperens and others added 2 commits October 2, 2026 15:52
…tart page

Every string property of a steered cefsrc read back as the page URL, not
just url: name, context-cache-path and the rest. A client reading the
element's properties and writing them back would have written that URL
into all of them. shown_string_property maps url only.

Remote control's Set start page wrote the URL through the live path,
which sent Page.navigate to the address the page was already on and so
reloaded the page on air: a single-page app's state or a form was lost,
and a page reached through a one-time redirect could break. set_home now
notes that the page is already showing the URL; the next live write of
exactly that URL records it without navigating, and the note is cleared
after the write either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With STROM_CEF_DEBUG_PORT or cef.debug_port at 0, Strom pushed
remote-debugging-port=0: Chromium picked a random port while Strom dialled
0, so no page was guarded and remote control always reported not
answering. It is now refused with a warning, like the bare flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@srperens srperens added the ci:windows Run Build (Windows) on this pull request label Oct 2, 2026
@srperens
srperens merged commit a8d2975 into main Oct 2, 2026
17 of 18 checks passed
@srperens
srperens deleted the feat/cef-devtools-proxy branch October 2, 2026 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:windows Run Build (Windows) on this pull request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant