Short version: BrewShot hands your input to Chromium and reads back structured JSON and encoded images; it does not execute page content in Java. It parses CDP JSON and image header metadata, while Chromium remains the component that interprets HTML, CSS, and JavaScript. The real risks are the ordinary ones that come with driving a browser, listed at the end.
BrewShot launches your local Chrome, speaks the DevTools Protocol over a WebSocket, and consumes exactly three things back from the page:
| From the page | BrewShot does | Risk |
|---|---|---|
| screenshot | Base64.decode → ImageIO header/dimension read → atomic file write |
compressed bytes are allocated before the dimension check; still-image pixels are not fully decoded by BrewShot |
eval result |
MiniJson.parse → typed value |
the one place page data enters Java — see below |
| CDP events | routed by method name; console/errors kept as text | bounded by entry count and a retained-byte ceiling; CDP ingress itself is byte-bounded; never executed |
The Java code and the page's HTML/JS never touch. There is no place where
page markup or scripts are interpreted, eval'd, deserialized into objects, or
reflected into class loading on the Java side. A .html crafted to compromise
the Java process would first have to break Chromium's renderer sandbox —
a browser-exploit-chain problem entirely inside Chrome's threat model.
A page BrewShot drives can push a lot of bytes back — a firehose of console
output, a huge eval result, a giant screenshot, a long recording. Those paths
are bounded so a chatty or hostile page degrades loudly instead of OOMing the
harness:
- CDP ingress is bounded on three axes. Per message
(
brewshot.maxCdpMessageBytes, default 32 MiB): a message that would exceed the exact UTF-8 ceiling is dropped — its reassembly buffer released, never materialized as a giantString. Counting is incremental across callbacks, including a surrogate pair split at a callback boundary. Queued message count (brewshot.maxInboxMessages, default 4096): the ingress queue holds at most this many undrained regular messages. Queued encoded content (brewshot.maxInboxBytes, default 32 MiB): the prospective exact UTF-8 aggregate is checked before retaining each completed message, and dequeuing returns its reservation. A flood of individually-small messages therefore cannot multiply the per-message ceiling by the count ceiling; the byte bound applies to live undrained content, not lifetime traffic. The per-message and aggregate byte properties are independent; raising one does not implicitly raise the other. One queue slot is reserved for the typed socket close/error signal, so that signal is never lost to a full inbox (a stalled caller still fails fast). Every drop class is announced once and counted, never silent. The final cumulative total and its count-cap/byte-cap components are available frominboxDropped(),inboxCountDropped(), andinboxByteDropped(). Invalid/nonpositive byte settings and a message-count setting aboveInteger.MAX_VALUE - 1fail configuration before queue construction. - Console/error retention is bounded on two axes: entry count (1000)
and a retained-byte budget (
brewshot.maxConsoleBytes, default 1 MB), so a single multi-MB console entry can no longer be kept whole. Over-budget entries are truncated/dropped and the dropped count is exposed. - Screenshot capture first receives the CDP Base64 string and allocates its
decoded compressed byte array. BrewShot then parses image header metadata and
refuses dimensions above
brewshot.maxImageDimension(16384 px/axis) orbrewshot.maxImagePixels(64 MP) before writing the artifact or performing a downstream full-raster decode. The transport's per-message UTF-8 ceiling bounds the earlier CDP response allocation; the dimension ceiling does not precede it. - GIF assembly reads frame headers before its full decode and refuses above
brewshot.gif.maxFrames(1000),brewshot.gif.maxFrameDimension(4096 px/axis), orbrewshot.gif.maxDecodedBytes(512 MiB of decoded-raster accounting,Σ width * height * 4). That sum is an accounting ceiling for nominal raster bytes, not a complete encoder peak: compressed input frames, Java image objects, palette/histogram state, indexed frames, and encoder buffers are additional.
All limits are -D overridable. Each check runs before the allocation it claims
to guard, and a breach is loud (a thrown error or an announced+counted drop) —
never a silent truncation.
eval results arrive as JSON and are parsed by the hand-rolled MiniJson.
This is the only path where attacker-influenced data reaches Java, so it is
hardened accordingly:
- Depth-capped (
MAX_DEPTH = 200): a pathologically nested value fails the singleevalcall with a clearIllegalArgumentException, never aStackOverflowErrorthat takes down the harness. - Malformed input fails closed: truncated strings, bad
\uescapes, trailing garbage → a caughtIllegalArgumentException, not undefined behaviour. (Pinned byMiniJsonTest.) - No object mapping: it produces only
Map/List/String/Double/Boolean/null— no reflection, no type coercion, no gadget surface. There is nothing for a crafted payload to instantiate.
Worst case from a hostile eval result today: your eval/clip-js call
throws. No code execution, no process compromise.
These are inherent to any headless-browser tool. BrewShot is a test/CI/agent harness for TRUSTED pages driven by a trusted operator — calibrate to that.
-
eval/--fail-js/--clip-jsrun JavaScript you supply, in the page's context. That is the feature. But if you build that JS string by concatenating untrusted input, you have injected into your own probe. Keepevalexpressions static, or escape anything interpolated. -
SSRF-style reach. Point BrewShot at a URL and your machine/network fetches it — same as
curlor any headless browser. Safe for the intendedlocalhost/your-own-pages use; if a target URL could ever come from an untrusted source, treat it like any server-side fetch (allowlist hosts, run it network-isolated). -
--no-sandboxin containers. The provided Docker image sets--no-sandboxbecause Chrome's sandbox needs privileges containers don't grant by default. That removes the layer that would contain a malicious renderer, so only use the sandboxless container to render pages you trust — never point a sandboxless browser at untrusted URLs. (The image also runs as a non-root user, which limits the blast radius if the renderer is compromised.) -
header()is not host-scoped. An extra header (e.g.Authorization) is sent on every request the page makes, including cross-origin subresources — a credential can leak off-host if the page pulls from elsewhere. For host-scoped credentials prefercookie(), which the browser applies under its own same-domain rules.
This is a small, single-maintainer utility provided under Apache-2.0 on an "AS IS" basis (see LICENSE). If you find a genuine Java-side memory-safety or code-execution issue (as opposed to a Chromium bug — report those upstream to Chrome), open an issue describing the input and observed behaviour.