Skip to content

Security: supsup/BrewShot

Security

SECURITY.md

Security model

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.

What actually happens to page content

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.

Resource bounds (memory)

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 giant String. 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 from inboxDropped(), inboxCountDropped(), and inboxByteDropped(). Invalid/nonpositive byte settings and a message-count setting above Integer.MAX_VALUE - 1 fail 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) or brewshot.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), or brewshot.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.

The one ingestion point: MiniJson

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 single eval call with a clear IllegalArgumentException, never a StackOverflowError that takes down the harness.
  • Malformed input fails closed: truncated strings, bad \u escapes, trailing garbage → a caught IllegalArgumentException, not undefined behaviour. (Pinned by MiniJsonTest.)
  • 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.

The real risks (they are about what you point it at, not the parser)

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.

  1. eval / --fail-js / --clip-js run 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. Keep eval expressions static, or escape anything interpolated.

  2. SSRF-style reach. Point BrewShot at a URL and your machine/network fetches it — same as curl or any headless browser. Safe for the intended localhost/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).

  3. --no-sandbox in containers. The provided Docker image sets --no-sandbox because 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.)

  4. 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 prefer cookie(), which the browser applies under its own same-domain rules.

Reporting

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.

There aren't any published security advisories