Skip to content
Back to all writing

Generated Charts Are Untrusted Code

Why a model-generated Mermaid diagram or ECharts spec is a program, not data, and the two-tier validation and bounded repair pipeline I built around it.

Generated chart forms passing through a metal validation gate and glass render chamber while unsafe fragments are diverted to quarantine

Ask a model for a bar chart and it will hand you back something that looks like data. A JSON object. Axes, a series, some numbers. Completely inert — as long as it stays JSON all the way to the renderer.

It isn't. Here is a chart spec I could plausibly have received:

{
  "option": {
    "xAxis": { "type": "category", "data": ["Q1", "Q2", "Q3"] },
    "yAxis": { "type": "value" },
    "tooltip": {
      "formatter": "function (params) { return params.value + ' units'; }"
    },
    "series": [{ "type": "bar", "data": [120, 200, 150] }]
  }
}

That is valid JSON, and the formatter is still only a string. ECharts treats a string formatter as a template; it does not evaluate function-shaped text. The risk appears if an adapter revives that string into a JavaScript callback before setOption(), or if the model is allowed to produce a JavaScript option object with real functions. ECharts accepts those callbacks, and they execute in the browser. My pipeline does neither: it rejects callback-shaped strings at the boundary.

The model isn't being clever or hostile when it emits one. It has read thousands of chart configs during training, many of them JavaScript examples with formatter callbacks, and it is producing something statistically ordinary.

Mermaid is the same story wearing different clothes. A diagram label is a string that ends up inside an SVG in your DOM. KaTeX has macros. Every one of these formats is a small declarative language, and the moment you let a model write one and then render it, you have built a pipeline that interprets untrusted input. That is not automatically arbitrary code execution, but it is not plain inert data either: renderer-specific HTML, URL, regular-expression and callback surfaces need explicit limits.

I ran into this building Research-AI, my open-source deep research system. Reports there aren't plain markdown — they contain Mermaid diagrams, ECharts charts and KaTeX equations, because a research document with no visuals is a worse research document. Which meant I had a model writing three languages I had no way of checking, into documents that then rendered in a browser.

This is what I built instead, and the things I got wrong on the way.

Who is actually being attacked here

I want to get the threat model right before the engineering, because “LLM output is untrusted” gets repeated a lot without anyone saying untrusted by whom.

For a single-user tool, the honest answer is mostly nobody. If you ask my system for a chart and it generates something that breaks your own browser tab, that is a bug, not a breach. You attacked yourself.

Three things change that.

The first is that most of the failures aren't attacks at all. They're just broken output, and they are constant. Unbalanced brackets, a stray newline inside a quoted Mermaid label, a series type that doesn't exist, an axis referenced by a series that never got declared. This is the everyday case, it happens far more often than anything malicious, and it ruins the product just as effectively. A research report that renders four charts and one red error box looks broken, and the user is right to think so.

The second is where the content comes from. Research-AI reads the open web — it searches, scrapes pages, extracts PDFs, and feeds all of that to the model that writes the sections and their diagrams. So the material influencing what gets generated is arbitrary text from strangers. That's indirect prompt injection with a rendering surface at the end of it, and it is a genuinely different situation from a user prompting their own tool.

The third is sharing. Research-AI lets you share a session. The instant a generated document can render in somebody else's browser, a diagram label that survives to the DOM stops being your problem and becomes stored XSS.

So: mostly a reliability problem, with a real security tail. That combination is worth being precise about, because it decides how you build. A pure security problem you solve with a sandbox. A pure reliability problem you solve with a retry. This one needs both, and it needs to fail gracefully, because the everyday case is a slightly malformed diagram and not an attacker.

Tier 1: what a parser can prove for free

The first layer is static. No browser, no model, no network — just parsing the block and deciding whether it is structurally sane. It runs on every visual block in every section.

The rule I settled on for charts is an allowlist, everywhere, with no exceptions. Blocklists lose this game. You cannot enumerate the bad ECharts configurations, but you can enumerate the good ones, because you know exactly which chart types your frontend supports.

So the payload gets three permitted top-level keys — title, caption, option — and anything else is a rejection. Series types are checked against an explicit set of the twenty-odd ECharts types the renderer handles. Axis types are one of category, value, time, log. If a series is one of the axis-based types, both axes have to actually exist, because a bar chart with no yAxis is a runtime error waiting to happen and I would rather find it here.

Then the parts that exist purely because the output is machine-generated:

FUNCTION_LIKE_PATTERN = re.compile(r"^\s*(?:function\s*\(|\(?\s*[\w$,\s]+\)?\s*=>)")
UNSAFE_KEYS = {"__proto__", "prototype", "constructor"}
CHART_MAX_JSON_NODES = 15_000
CHART_MAX_JSON_DEPTH = 64

The first one walks every string in the payload looking for anything shaped like a function, which is how the tooltip formatter at the top of this article dies. ECharts would not execute that string by itself; rejecting it keeps the contract strictly JSON-only and prevents a later adapter from quietly turning it into code. The second blocks the prototype-pollution keys, which no legitimate chart config has ever needed. The last two exist because a model that gets confused can emit a genuinely enormous nested object, and I would rather reject it than have the validator itself become the thing that falls over. There's also a check that numbers are finite and aren't booleans, because NaN, Infinity and true all have opinions about what they mean once they reach a charting library.

Mermaid gets a different shape of check, since it isn't JSON:

UNSAFE_MERMAID_PATTERN = re.compile(
    r"<script|onerror\s*=|onload\s*=|javascript:",
    re.IGNORECASE,
)

Plus a header allowlist — the first non-comment line has to declare a diagram type Mermaid actually supports — a control-character check, a quote-aware balanced-delimiter scan, and a rule that catches unquoted labels containing characters like /, (, : or anything non-ASCII.

That last one sounds fussy until you see what actually comes back. These are the everyday failures, not exotic ones:

flowchart TD
    A[Fetch data (v2)] --> B[Parse]        %% unquoted parens: parse error
    B --> C[Store in DB/cache]             %% unquoted slash: parse error
    C --> D["Validate
    and emit"]                             %% real newline in a label: dead

Each of those reads perfectly well to a human and none of them render. The fixes are boring:

flowchart TD
    A["Fetch data (v2)"] --> B[Parse]
    B --> C["Store in DB/cache"]
    C --> D["Validate<br/>and emit"]

The newline one is the case I added last, after watching it recur. A literal newline inside a double-quoted label terminates the lexer token and kills the diagram; the correct multi-line syntax is <br/>. The model gets this wrong constantly, and I don't think it's being careless — a newline is how you break a line absolutely everywhere else it has ever seen.

Tier 1 is cheap and it catches the overwhelming majority of problems. It is not sufficient, and I knew that while I was writing it.

Tier 2: render it in an actual browser

Static analysis can tell you a Mermaid block is well-formed. It cannot tell you Mermaid will render it. Those are different questions, and only one of them is the one your user cares about.

So the second tier does the only thing that actually settles it: it loads the real library in a real browser and renders the thing.

Each probe gets a fresh Playwright browser context with a 1280×720 viewport and a minimal HTML page. The library — Mermaid, ECharts or KaTeX — is injected from a local file on disk, not from a CDN, so validation doesn't depend on somebody else's uptime and doesn't change underneath me when they ship a release. The file is read once and cached in memory.

Then it renders, and it checks for an artefact rather than an absence of exceptions:

  • Mermaid calls render() and requires actual SVG back. Empty output is a failure even if nothing threw.
  • ECharts calls setOption(), resizes, then calls getDataURL() and requires a PNG data URI. If the chart can't produce an image, it isn't a chart.
  • KaTeX calls renderToString() with throwOnError: true and strict: "error".

The Mermaid probe initialises with securityLevel: "strict" and suppressErrorRendering: true — the second one matters more than it sounds, because Mermaid's default behaviour on a bad diagram is to render a friendly error graphic, which is technically successful rendering and would sail straight past a naive check.

Every probe runs under a 4 second timeout and a per-session semaphore of 2, and blocks over 30KB skip tier 2 entirely. Browsers are the expensive part of this system and a research report can contain a lot of diagrams.

The bug that taught me to match the renderer, not out-strict it

My first version of the Mermaid probe called mermaid.parse() before render(). That seemed obviously correct — validate, then render, in that order.

It was quietly deleting good diagrams.

parse() is stricter than render(). There are diagrams it rejects that render() handles perfectly well, and my frontend only ever calls render(). So the validator was enforcing a standard that the actual product did not enforce, and users were losing diagrams that would have displayed fine.

The fix was to delete the parse() call and use render() as the only test, because that is what the frontend does. It has been the most transferable lesson in this whole system:

A validator's job is to predict your renderer's behaviour, not to have opinions of its own. Every point where it's stricter than production is a false positive, and false positives here mean silently deleting good content.

Being too permissive gives you visible bugs, which you fix. Being too strict gives you invisible ones, which you don't.

Three answers, not two

This is the design decision I would keep if I had to throw away everything else in this article.

A validator that returns valid-or-invalid is lying to you, because it has collapsed two completely different situations into one answer. "This diagram is broken" and "I could not determine whether this diagram is broken" are not the same finding and must not produce the same action.

So every tier 2 probe returns one of three statuses:

  • valid — it rendered.
  • invalid — the library rejected it. Parse error, syntax error, unknown diagram type, empty SVG.
  • unavailablethe validator failed. Browser context wouldn't open, asset file missing, probe timed out, block too large, page threw something that isn't a parse error.

Sorting exceptions into the right bucket is most of the work inside the probe. It runs in the browser, and it has to decide whether the thing that just threw was the diagram's fault:

try {
  renderResult = await mermaid.render(renderId, diagram)
} catch (renderError) {
  const message = String(renderError?.message ?? renderError)

  // The library is telling us the diagram is wrong.
  const isParseError =
    /parse\s+error|syntax\s+error|unrecognized|invalid\s+diagram|lexer|token/i
      .test(message)

  if (isParseError) return { status: "invalid", reason: message }

  // Anything else — DOM fault, OOM, browser quirk — is OUR failure, not the
  // diagram's. Saying "invalid" here would delete a chart that was fine.
  return { status: "unavailable", reason: message }
}

Same shape for the others: a KaTeX ParseError is invalid, a DOM fault is unavailable.

Then the caller decides what an unavailable means, and the default is to keep the content:

status, reason = await tier2.validate_mermaid(block)

if status == "valid":
    return tier1_result
if status == "invalid":
    return ValidationResult(False, reason)

# unavailable — we learned nothing. Fall back to what tier 1 said
# rather than punishing the block for our own broken browser.
if fail_open:
    return tier1_result
return ValidationResult(False, reason)

Collapse those two statuses and here is what you have built: the day your Playwright install breaks, or a base image ships without the right shared libraries, or the browser gets OOM-killed under load, every probe fails, every failure reads as invalid, and your system silently strips every chart out of every report. No errors. No alerts. Reports just quietly get worse, and you find out weeks later from a user who mentions the charts seem to have stopped.

Fail-open has its own cost and I'll come back to it.

Repair is bounded, and repaired output is not trusted either

When a block fails, the system gets a chance to fix it before giving up. The failure reason from validation goes back to the model with the broken block, and it tries again. Two attempts, each under its own timeout.

Two properties of that loop matter more than the loop itself.

The repaired block goes back through the entire validation pipeline — tier 1 and tier 2 both. This sounds obvious written down, and it is exactly the step that is easy to skip, because the model has just been told precisely what was wrong and it is tempting to assume it complied. It is the same model that produced the broken block. A fix is a new candidate, not a correction, and it gets no more trust than the original did.

And the budget is small and hard. Two attempts, then stop. An unbounded repair loop against a model that has misunderstood the format is just a way to spend money slowly. If two rounds with an explicit error message haven't fixed it, a third won't.

The whole loop is about fifteen lines of intent:

for attempt in range(MAX_REPAIR_ATTEMPTS):        # 2
    candidate = await model.ainvoke(
        repair_prompt(block_type, block.content, reason),
        timeout=REPAIR_TIMEOUT,
    )
    body = extract_block_of_type(candidate, block.block_type)
    if not body:
        continue

    # The model was just told exactly what was wrong. Check anyway.
    result = await validate(body, block.block_type)   # tier 1 + tier 2
    if result.is_valid:
        content = replace_span(content, block.start, block.end, fence(body))
        break
else:
    # Budget spent. Cut the block, keep the section.
    content = remove_span(content, block.start, block.end)

The else on that loop is the part that makes it safe to run at all: when repair runs out of attempts, the outcome is a removed block, never a stuck loop and never a broken block left in the document.

There's a final sweep after all repairs complete that re-validates the whole section and removes anything still invalid. Belt and braces, and it has caught real bugs — mostly cases where repairing one block shifted the character offsets of another.

Fail the chart, not the report

When repair gives up, the block is cut out of the section. Not the section, not the report — the block. Its exact character span is removed, the prose around it closes up, and the citations are untouched.

That's span surgery rather than regeneration, and it's deliberate. The text around a failed chart is usually fine. Regenerating the whole section to fix one diagram would risk perfectly good research to salvage a bar chart.

Removals are applied in reverse document order, from the end of the section backwards, so that cutting one block doesn't invalidate the offsets of the ones before it. That's a small detail and it was a bug first.

There's also a crash handler: if the repair task itself dies, the section falls back to plain removal of the invalid blocks. The worst case is a report with fewer visuals. It is never a failed report.

That's the same principle I ended up at with durable job recovery: bounded partial success beats an all-or-nothing outcome, as long as the partial state is honest. A research report with four charts instead of five is a good outcome. A report that fails because chart five had an unbalanced bracket is not.

What this doesn't solve

Five things, roughly in the order they bother me.

Tier 1 is regex, not a grammar. The Mermaid checks are a stack of patterns approximating a parser. They work well in practice and they will never be complete, because that is the nature of validating a real language with regular expressions. The honest version would run Mermaid's own parser server-side. What I have is a fast, incomplete prefilter that happens to catch the failure modes I've actually observed — which is a reasonable thing to build, as long as I don't describe it as a parser.

Fail-open degrades silently. It is the right default, and it means a persistently broken tier 2 downgrades me to tier 1 forever without saying anything. The fix isn't to change the default, it's to alert on the unavailable rate. If more than a small fraction of probes come back unavailable, something is broken in the infrastructure and I should hear about it from monitoring rather than from output quality slowly drifting.

The frontend leans on Mermaid's sanitiser. The rendered SVG goes into the DOM through dangerouslySetInnerHTML, with securityLevel: 'strict' doing the load-bearing work. That is Mermaid's documented mechanism and it is a single point of failure. Defence in depth here means running the SVG through DOMPurify before insertion and putting a real CSP on the page, and I haven't done either yet. Given the sharing feature, this is the gap I'd close first.

There's no adversarial test corpus. Everything above was built against failures I observed — malformed output from real runs. I have never sat down and written a set of deliberately hostile diagrams and chart specs and run them through the pipeline. Every validator in this article deserves a red-team suite, and "it hasn't broken yet" is not evidence, it's the absence of evidence.

Rendering successfully doesn't mean the chart is right. This is the biggest one and none of this addresses it. A chart can pass every check here, render beautifully, and show numbers the model invented. Structural validation proves the diagram is a diagram. It proves nothing whatsoever about whether it's true.

The rule

Everything here comes down to one thing: the moment your product renders model output as anything other than text, you are running generated code, and the fact that it arrived in a JSON envelope changes nothing about that.

So parse it into something you control, validate it against an allowlist you can enumerate, execute it in the same runtime your users will, and be very clear with yourself about the difference between this is broken and I couldn't tell.

And when it fails — because it will, most days, harmlessly — drop the chart and keep the report.