Skip to content

Make the export sandbox contract truthful and automate dependency vulnerability scans #449

Description

@Alek99

Summary

The public security policy says browser export is sandboxed by default and disabling it is an explicit caller opt-out. Both Chromium paths actually retry unsandboxed automatically and silently when a sandboxed launch fails. The repository's own audit records that mismatch and separately records missing Cargo/Bun advisory scanning, while make check-security only runs source-level export tests.

Evidence

  • SECURITY.md describes sandbox=False as the explicit opt-out:

    xy/SECURITY.md

    Lines 23 to 35 in 99eda6d

    ## Scope notes for triage
    - **Standalone HTML export** (`Figure.to_html` / `Chart.to_html`) is the most
    security-sensitive surface: user strings (titles, labels, legends, series
    names, categories) are escaped before entering inline JSON or `<title>`, the
    export ships a defensive `Content-Security-Policy` meta tag, and non-finite
    JSON metadata is rejected. Escaping regressions here are in scope and
    treated as high severity — see `tests/test_static_client_security.py` and
    `make check-security`.
    - `Figure.to_png` launches local Chromium with the browser sandbox enabled by
    default; `sandbox=False` is an explicit caller opt-out for trusted HTML.
    - The native core is a local in-process C-ABI library; it processes only data
    already in the caller's process and performs no I/O or network access.
  • html_to_png(..., sandbox=True) automatically inserts --no-sandbox on retry:

    xy/python/xy/export.py

    Lines 548 to 625 in 99eda6d

    def html_to_png(
    html: str,
    width: int,
    height: int,
    *,
    scale: float = 2.0,
    time_budget_ms: int = 4000,
    timeout_s: float = 120.0,
    sandbox: bool = True,
    gl: str = "software",
    ) -> bytes:
    """Rasterize standalone chart HTML to PNG with an installed headless browser.
    The current adapter supports the Chromium family
    (Chrome, Chromium, Edge, and chrome-headless-shell). Pure mechanism (no
    Figure), so it is testable without numpy. `scale` is the device-pixel
    ratio (2 = retina-crisp).
    `gl` picks the WebGL backend: "software" (default) pins SwiftShader for
    deterministic pixels on any machine (including GPU-less CI); "hardware"
    lets Chromium use the real GPU — much faster on large direct-mode payloads,
    at the cost of driver-dependent rasterization."""
    width = _positive_pixel_count(width, "PNG width")
    height = _positive_pixel_count(height, "PNG height")
    scale = _positive_finite_float(scale, "PNG scale")
    time_budget_ms = _positive_pixel_count(time_budget_ms, "PNG time_budget_ms")
    timeout_s = _positive_finite_float(timeout_s, "PNG timeout_s")
    sandbox = _bool_option(sandbox, "PNG sandbox")
    gl = _gl_option(gl)
    exe = find_browser()
    if exe is None:
    raise RuntimeError(
    "browser PNG export needs a supported Chrome/Chromium/Edge executable "
    f"and none was found. Set ${_BROWSER_ENV} to its executable path "
    "or install a supported browser. HTML export (to_html) needs nothing extra."
    )
    with tempfile.TemporaryDirectory() as td:
    page = Path(td) / "chart.html"
    page.write_text(html, encoding="utf-8")
    shot = Path(td) / "out.png"
    gl_flags = (
    ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] if gl == "software" else []
    )
    args = [
    exe,
    "--headless=new",
    "--disable-dev-shm-usage",
    "--hide-scrollbars",
    *gl_flags,
    f"--force-device-scale-factor={scale}",
    f"--window-size={int(width)},{int(height)}",
    f"--virtual-time-budget={int(time_budget_ms)}",
    f"--screenshot={shot}",
    page.as_uri(),
    ]
    if not sandbox:
    args.insert(2, "--no-sandbox")
    proc = subprocess.run(
    args,
    capture_output=True,
    text=True,
    timeout=timeout_s,
    )
    if not shot.exists():
    first_tail = (proc.stderr or "")[-500:]
    if sandbox:
    retry_args = list(args)
    retry_args.insert(2, "--no-sandbox")
    proc = subprocess.run(
    retry_args,
    capture_output=True,
    text=True,
    timeout=timeout_s,
    )
    if not shot.exists():
    tail = (proc.stderr or "")[-500:]
    if sandbox:
    tail = f"sandboxed launch failed: {first_tail}\nno-sandbox retry failed: {tail}"
  • The persistent browser session also silently retries with sandbox=False:

    xy/python/xy/export.py

    Lines 1034 to 1051 in 99eda6d

    def _browser_session(*, gl: str, sandbox: bool) -> "Any":
    """One launched ChromiumSession, mirroring `html_to_png`'s sandbox retry."""
    exe = find_browser()
    if exe is None:
    raise RuntimeError(
    "browser image export needs a supported Chrome/Chromium/Edge executable "
    f"and none was found. Set ${_BROWSER_ENV} to its executable path "
    "or install a supported browser. Native export (engine=Engine.default) "
    "and HTML export need nothing extra."
    )
    from ._chromium import ChromiumError, ChromiumSession
    try:
    return ChromiumSession(exe, gl=gl, sandbox=sandbox)
    except ChromiumError:
    if not sandbox:
    raise
    return ChromiumSession(exe, gl=gl, sandbox=False)
  • The audit acknowledges the stale guarantee and says isolation—not the flag—is currently load-bearing:
    #### Status as of 2026-07-20 (Residual Risks)
    The first two bullets above understate the current exposure. "Keep Chromium's
    sandbox enabled" is caller-side advice that the library does not enforce end to
    end, and unsandboxed launches are not confined to CI smoke scripts: since commit
    8cda831 the public export path downgrades itself automatically. `html_to_png`
    re-runs with `--no-sandbox` inserted when the sandboxed launch produces no
    screenshot (`python/xy/export.py:509-526`, the retry itself at `:511-519` and
    the two-attempt error assembly through `:526`), and `_browser_session` retries
    `ChromiumSession(..., sandbox=False)` on `ChromiumError`
    (`python/xy/export.py:926-931`). Neither path emits a warning, so the downgrade
    is silent. See [the 2026-07-20 status note under
    XY-SEC-2026-03](#status-as-of-2026-07-20-xy-sec-2026-03) above and
    `spec/api/export.md` §7.
    Read that way, `sandbox=True` is a preference, not a guarantee: rendering
    untrusted HTML through `to_png()` can execute unsandboxed on a host where the
    sandbox cannot initialize. Container/worker isolation is therefore the load-
    bearing control, not the sandbox flag. Follow-up pending (same item as
    XY-SEC-2026-03): make the fallback opt-in, or at minimum warn on the downgrade,
    so a sandbox loss is observable.
  • The same audit says Rust now has third-party dependencies and cargo audit/cargo deny is pending:
    #### Status as of 2026-07-20 (Confirmed Controls)
    The Rust core is no longer std-only, so the "Cargo dependency tree is empty"
    control above and the `cargo tree --locked` evidence line below are both
    superseded. Commit c3c867b, landed 2026-07-11, added one direct dependency to
    `Cargo.toml``png = "0.18.1"`, for the native raster encoder's fdeflate fast
    path — which pulls in eight transitive crates: `bitflags`, `crc32fast`,
    `cfg-if`, `fdeflate`, `simd-adler32`, `flate2`, `miniz_oxide`, and `adler2`.
    `Cargo.lock` therefore holds nine third-party packages plus `xy-core`.
    The tree is still shallow and single-rooted, but "no third-party Rust crates"
    is no longer an accurate standing control. Follow-up pending: add `cargo audit`
    (or `cargo deny`) to CI now that a third-party tree exists, matching the
    `pip-audit` coverage already run on the Python side.
    . It records one-time pip-audit evidence and an unrun Bun audit, not continuous gates:
    ## Tooling Evidence
    - `uv tool run pip-audit --progress-spinner off .`: no known vulnerabilities.
    - `uv tool run pip-audit --progress-spinner off -r requirements.txt` from
    `examples/reflex/`: no known vulnerabilities; local editable
    `xy` is skipped because it is not a PyPI package.
    - `cargo tree --locked`: only `xy-core`, no third-party Rust crates. (True on
    the audit date only; superseded by the 2026-07-20 status note above, which
    records `png` plus eight transitive crates.)
    - `node js/build.mjs --check`: static JS bundles fresh.
    - `make check-security`: passed.
    and
    - Bun was not installed locally and the Reflex-generated frontend uses
    `bun.lock`, not an npm lockfile, so the JS dependency advisory audit for the
    demo app could not be run faithfully here. Run `bun audit` in an environment
    with Bun installed.
  • make check-security invokes only the HTML/client test group:

    xy/Makefile

    Lines 85 to 86 in 99eda6d

    check-security:
    $(PYTHON) scripts/verify_local.py --only security_export

Acceptance criteria

  • sandbox=True fails closed; any no-sandbox fallback requires an explicit caller option and is observable in logs/warnings.
  • Public API docs and SECURITY.md state the exact enforced behavior and isolation requirements.
  • CI/scheduled automation scans the committed Python, Rust, npm, and docs/Bun dependency locks with a documented severity/allowlist policy.
  • New lockfiles/dependency ecosystems cannot silently fall outside the scanning inventory.
  • make check-security (or a clearly named companion) exposes the repeatable local checks; point-in-time audit results are labeled as historical evidence.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions