Skip to content

fix(node): discover the browser's libraries instead of hardcoding them - #20

Open
dviejokfs wants to merge 5 commits into
mainfrom
fix/chromium-on-demand
Open

fix(node): discover the browser's libraries instead of hardcoding them#20
dviejokfs wants to merge 5 commits into
mainfrom
fix/chromium-on-demand

Conversation

@dviejokfs

Copy link
Copy Markdown
Contributor

Closes #15. Stacked on #19 — it uses the discovery mechanism that PR fixes, so it should land after it. The diff below is only this commit.

What changes

CHROMIUM_RUNTIME was fifteen bookworm package names maintained by hand. It goes away. The install step points ldd at the browser it just downloaded, and the runtime image installs whatever that reports.

Why the hardcoded list had to go

It was wrong in both directions, measured against Chrome for Testing 151:

packages
ldd finds, list omits libatspi2.0-0 libdbus-1-3 libexpat1 libglib2.0-0 libx11-6 libxcb1 libxext6
list has, headless shell does not link libcups2 libpango-1.0-0 libcairo2

It worked only because apt pulled the first group in transitively. And the names are bookworm's — libasound2 and libatk1.0-0 were renamed by the t64 transition, so a trixie base image fails at apt-get install, which is #15.

Discovery is exact per binary, per version and per Debian release, and costs nothing when there is no browser.

The one thing that stays declared

Fonts, and they are the entire residue:

A: discovered set only          ldd not-found: 3   dump-dom FAILED  screenshot FAILED  pdf FAILED
B: + libcups2 libpango libcairo ldd not-found: 0   dump-dom OK      screenshot OK      pdf OK (%PDF)
C: + fonts-liberation           ldd not-found: 0   OK, output differs (PDF 24.8KB -> 30.4KB)

Row A is the full chrome binary — the one modern Puppeteer launches by default — where those three are DT_NEEDED. My earlier measurement used chrome-headless-shell and I wrongly concluded they were unnecessary; they are found by ldd, I was inspecting the wrong artefact. So the dlopen residue I went looking for does not exist for Chromium: everything except fonts is discoverable.

Fonts are not. A browser opens them through fontconfig, so nothing about the binary reveals them, and without them Chromium draws text as empty boxes rather than failing in a way anyone would notice — row C changes the output without changing the pass/fail.

Test plan

  • ./scripts/conformance.sh playwright-app8/8, including serves expected content, which is rendered by Chromium inside the container and read back out of the DOM. Passing it with zero hardcoded library names is the whole claim of this PR.
  • No regression: cobol-app, crystal-server (same helper) and node-express all pass
  • Image size unchanged at 482MB — discovery is not pulling in more than the list did
  • Unit tests updated: the runtime image gets fonts-liberation and not libnss3; the install step inspects the browser; an app with no browser gets neither
  • cargo test --workspace → 233 passed; fmt and clippy clean

Reviewer notes

  • The glob is layout knowledge. playwright-core/.local-browsers/*/chrome-linux*/chrome* and .cache/puppeteer/*/*/chrome-linux*/chrome* encode where each tool puts its browser. If either changes, ldd matches nothing, the recorded list is empty, and the image ships without libraries — failing at run time, not build time. The playwright-app conformance job catches that for Playwright on every CI run; Puppeteer has no equivalent example, so that gap is real and worth a follow-up.
  • Cost. A build with a browser now fetches apt-file's ~90MB index once, in the install layer, because the browser's libraries are genuinely absent from the build image. It is cached with that layer and skipped entirely for every app without a browser.

record_runtime_libraries filtered ldd output with /=> \//, which only
matches lines where the loader resolved the library to a path. A missing
one looks like

    libatomic.so.1 => not found

with no path, so it was silently dropped: the recorded list came out
short and the runtime image was built without the package, with no error
anywhere. The helper exists precisely to avoid hardcoding package names,
and this is the case where it quietly stopped doing that.

Resolve those with apt-file, which maps a filename to the package
providing it without needing the file present. The query is anchored to
the multiarch library directory deliberately — a bare basename search for
libatomic.so.1 also matches lib32atomic1 and the -cross packages, and
libnss3.so matches firefox-esr and thunderbird, so an unanchored head -1
installs something wildly wrong. A library nothing provides now fails the
build with an explanation rather than producing an image that dies with
a loader error.

apt-file and its ~90MB index are only fetched when something is actually
missing.

Refs #14
Five problems a security review found in the previous commit.

A soname reaches this from a binary the app produced, and it was
interpolated raw into an extended regex. `|` has the lowest precedence,
so DT_NEEDED of `x|usr/sbin/sshd` escaped the path anchor entirely,
resolved to openssh-server, and had it installed as root in the runtime
image with the build exiting 0. Validate the soname first.

The anchor only covered /usr/lib/<triplet>/. On Debian the essential
libraries are still recorded unmerged, so libz.so.1, libc.so.6,
libgcc_s.so.1 and libtinfo.so.6 all resolved to nothing and failed the
build — the opposite of the point. It also missed non-gnu triplets like
arm-linux-gnueabihf.

`for lib in $missing` was unquoted, so a soname of `*` expanded against
the build directory. `set -f`.

`head -1` was locale-dependent: the same source picked libavcodec-extra59
under LC_ALL=C and libavcodec59 under en_US.UTF-8. Pin the collation, and
stop guessing — 24 sonames in bookworm have several providers, and for
libc++.so.1 the first-sorted answer is the oldest ABI. Fail with the
candidates and tell the user to pick one.

The test pinned the vulnerable pattern as a literal and would not have
caught any of this; it now asserts the guarantees instead.
set -f at the top of the script disabled globbing everywhere, including
the line whose argument is documented as a glob. No current caller passes
one — cobol and crystal name a single binary — but the PHP provider's own
copy globs an extension directory, and a caller that inspects several
binaries would have silently got no results.

Turn expansion off only around the loop over sonames, which is the one
place a crafted DT_NEEDED could expand against the build directory.
CHROMIUM_RUNTIME was fifteen bookworm package names maintained by hand,
and it was wrong in both directions. Measured against Chrome for Testing
151: ldd finds seven libraries the list omits, which apt happened to pull
in transitively, and the list carries libcups2, libpango-1.0-0 and
libcairo2 which the headless shell does not link. The names are also
bookworm's — libasound2 and libatk1.0-0 were renamed by the t64
transition, so a trixie base image would have failed at apt-get.

Point ldd at the browser the install step actually downloaded and install
what it says. That is exact per binary, per version and per Debian
release, and it costs nothing when no browser is present.

Fonts stay declared, and they are the whole residue: a browser opens them
through fontconfig rather than linking them, so nothing about the binary
reveals they are needed, and without them Chromium draws text as empty
boxes rather than failing in a way anyone would notice. Everything else
Chromium needs turned out to be a DT_NEEDED entry — the dlopen residue I
expected to find is not there.

Closes #15
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

📓 Changelog preview

This is what your commits will add to the generated CHANGELOG.md at release time (via git-cliff). Do not edit CHANGELOG.md by hand — it is generated from your Conventional Commit messages.

## [Unreleased]

### Fixed

- Resolve libraries ldd reports as missing instead of dropping them
- Validate sonames and widen the library anchor
- Keep pathname expansion on for the ldd argument
- **node:** Discover the browser's libraries instead of hardcoding them
- **node:** Find the browser instead of assuming npm's layout

The glob was written for npm's hoisted node_modules. pnpm does not hoist
playwright-core — the browsers land under
node_modules/.pnpm/playwright-core@1.62.1/node_modules/... — so it matched
nothing, and matching nothing was silent: ldd printed nothing, the
recorded list came out empty, the runtime stage skipped its install on
[ -s ], and the build exited 0. The image shipped with no browser
libraries and failed on first launch. Against the hardcoded list this was
a straight regression, and it hit every pnpm project.

It also missed chrome-headless-shell for both tools, which lives in
chrome-headless-shell-linux64 rather than chrome-linux64 — and that is
the binary Playwright launches by default. Bounded only by luck: the
shell's NEEDED set is currently a subset of the full browser's.

Use find, collect both binaries, and fail the build when the search comes
up empty rather than shipping an image that dies later.
@dviejokfs

Copy link
Copy Markdown
Contributor Author

An audit of this stack found two blockers, both reproduced and both now fixed in 581c163 (here) and b31409e (on #19).

The one that mattered most: pnpm shipped an image with no browser libraries

The glob was written for npm's hoisted node_modules. pnpm does not hoist playwright-core:

assumed:  /app/node_modules/playwright-core/.local-browsers        -> No such file or directory
actual:   /app/node_modules/.pnpm/playwright-core@1.62.1/node_modules/playwright-core/.local-browsers

Matching nothing was silent: ldd printed nothing, the recorded list came out empty, the runtime stage skipped its install on [ -s ], and the build exited 0. Against the hardcoded list this was a straight regression, and it hit every pnpm project.

It also missed chrome-headless-shell for both tools — that binary lives in chrome-headless-shell-linux64, not chrome-linux64 — and it is the one Playwright launches by default. Bounded only by luck today: the shell's NEEDED set is currently a subset of the full browser's.

Now uses find, collects both binaries, and fails the build when the search comes up empty rather than shipping an image that dies later.

Verified end to end on the case that was broken — a pnpm Playwright app:

response:            hello from autopack     <- rendered by Chromium
libnss3:             present
recorded packages:   21

Also fixed, on #19

+ and . are legal in a soname and are both regex metacharacters, and were spliced raw into the query. + quantifies, so libxml++-2.6.so.2 matched nothing and the build failed claiming no package provides a library that plainly exists — 238 sonames in bookworm carry a +. . matches /, so a crafted DT_NEEDED of gio.modules.libgioremote-volume-monitor.so reached gvfs two directories below the anchor and pulled 215 packages into the runtime image as root. A malicious transitive dependency can rewrite DT_NEEDED on a binary under /app, so that was reachable without the app author knowing. Both escaped now:

libxml++-2.6.so.2   exit=0 -> libxml++2.6-2v5
libFLAC++.so.10     exit=0 -> libflac++10
gio.modules.lib...  exit=1 -> no package provides
x|usr/sbin/sshd     exit=1 -> refusing to look up

Accepted, not fixed

  • apt-file's index is fetched on every browser build, not rarely — the build image has none of Chromium's X/GTK libraries, so every soname is missing. About 60s and 90MB, cached in the install layer. The doc comment overstated how rare that path is; worth its own change to cache /var/lib/apt/lists rather than papering over it here.
  • A static site with playwright in dependencies still pays for discovery and gets the libraries in a Caddy image. Same shape as the pre-existing case and outside this diff.
  • ldd runs on app-controlled ELFs. Bounded: the install step already runs npm install lifecycle scripts as root in the same stage, so it grants no execution an attacker lacks. Worth a code comment so nobody moves that call into a stage that does not run app code.

Also fixed a precedence bug the audit found in my own test — (A && B) || C short-circuited so the puppeteer arm was dead code, which is why the test caught neither blocker. Split and parenthesised.

235 workspace tests, conformance green on playwright-app and node-express, fmt and clippy clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CHROMIUM_RUNTIME hardcodes bookworm names and breaks on a trixie base image

1 participant