Scope and safety. This is third-party analysis of a fixed, publicly disclosed vulnerability, written from the vendor’s own advisory, CVE record, patch commit and published source. It describes the defective data flow and the remediation. The upstream advisory publishes a minimal proof of concept; this article deliberately does not reproduce a weaponised document, target path list, or exploitation procedure. The TeX primitives named below are named in the CVE description and in the vendor’s own fix, and are documented features of the TeX language.

Executive summary

CVE-2026-105744 affects Docling — IBM’s document-parsing library, and one of the most widely used ingestion front-ends for RAG and LLM pipelines — in versions from 2.94.0 until 2.132.0, for both the docling and docling-slim distributions.

When a caller opts into TikZ rendering with LatexBackendOptions(tikz_engine="tectonic"), Docling takes the tikzpicture body and the document preamble verbatim out of the input .tex file, wraps them in a standalone document, and compiles that document with the tectonic binary. Nothing restricts what TeX may do with its own file primitives while that compilation runs.

A crafted .tex file can therefore:

  • read any file the converter process can read, via \input, \verbatiminput or \openin — and the content can be typeset into the rendered image that lands in the conversion output, which makes this an exfiltration channel, not just a local read;
  • create or overwrite any file the process can write, via \immediate\openout and \write. Neither requires shell escape;
  • run shell commands through \write18, if the caller additionally set tikz_engine_allow_shell_escape=True.

The weaknesses are CWE-22 (path traversal), CWE-73 (external control of file name or path) and CWE-1188 (insecure default initialization). GitHub, as CNA, scores it CVSS 3.1 7.5 High — CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H.

The detail that makes this worth reading is not the bug class. It is that Docling already had a correct path-traversal guard, and the guard was irrelevant. _resolve_local_dependency() rejects absolute paths, resolves candidates, calls is_relative_to(source_root), and logs "Path traversal attempt blocked". All of that is real, and all of it governs only which files Docling copies into the temporary build directory. It says nothing about what TeX may open once Docling invokes it. The boundary was enforced one layer too early.

Fixed in docling 2.132.0.

Evidence labels used below:

  • Verified means stated by the CVE record, the GitHub Security Advisory, the patch commit, or the project’s published source and release material.
  • Inferred means a deployment consequence derived from those facts and clearly marked as such.

Key facts

Item Finding Confidence
Identifier CVE-2026-105744 Verified
Advisory GHSA-x3q2-h9hx-4r4j Verified
CNA GitHub (GitHub_M) Verified
Product docling and docling-slim (pkg:pypi/docling) Verified
Affected versions >= 2.94.0, < 2.132.0 Verified
Fixed version 2.132.0 Verified
Weaknesses CWE-22, CWE-73, CWE-1188 Verified
Vulnerable component docling/backend/latex/engines/tectonic.py Verified
Entry point tikzpicture body and preamble of an input .tex file Verified
Dangerous sink The tectonic TeX engine, invoked on an attacker-composed document Verified
Precondition Caller sets tikz_engine="tectonic" and a tectonic binary is available Verified
Default configuration tikz_engine=None — not affected Verified
CLI exposure None; the docling CLI has no option to enable it Verified
Read primitive \input, \verbatiminput, \openin Verified
Write primitive \immediate\openout + \write — no shell escape needed Verified
Command execution \write18, only with tikz_engine_allow_shell_escape=True Verified
Insecure default TectonicEngine.__init__(allow_shell_escape=True) Verified
CVSS 3.1 7.5 High (AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H) Verified
Reporters Credited but not publicly named in the advisory Verified
Exposure window 2026-05-18 to 2026-10-01, about 4.5 months Verified

Why this matters for document-ingestion pipelines

Docling’s entire job is to accept documents from somewhere else. That is the product. A parser that only handles files you authored is not a parser, it is a renderer — so for this class of library, “the input is untrusted” is not an edge case, it is the default operating condition.

That matters because of where Docling sits. It is the ingestion stage in front of a vector store, a chunker, an LLM. The files reaching it are uploads, email attachments, arXiv tarballs, scraped corpora, CI artefacts, documents a user dropped into a chat window. The process doing the parsing typically runs with ambient credentials that have nothing to do with the document: API keys in the environment, a cloud metadata endpoint one hop away, a writable model cache, a shared scratch volume.

Three consequences follow, and they are what separates this from a generic file-read bug:

  1. The read is an exfiltration channel, not just a read. The advisory is explicit that file content “can be typeset into the rendered picture that ends up in the conversion output.” So the attacker does not need a callback, an egress path, or a second request. They submit a document, and the secret comes back to them inside the rendered figure of the parsed result — through whatever interface normally returns parsed documents to users.

  2. An arbitrary write in an ingestion worker is a code-execution primitive. The advisory notes that “writing to startup or configuration files can lead to code execution.” Inferred: in a long-lived worker, a Python path, a shell profile, a sitecustomize.py, a cron fragment or a config file read on next start are all plausible targets. The attacker does not need the write to be immediate — ingestion workers restart.

  3. Nothing about this requires authentication to Docling. Docling is a library. PR:N in the vector is accurate because the privilege that matters is “can submit a document to the pipeline,” and that is frequently the least-privileged action a product offers. The UI:R reflects that someone must convert the file — which, in an automated ingestion pipeline, is what the pipeline exists to do.

This is the fourth entry on this site in the same pattern: an AI-infrastructure component treats a configuration-shaped field as data when it is really control. CVE-2026-57572 did it with Chromium launch arguments. CVE-2026-86439 did it with a filesystem path. This one does it with a TeX document — which is worse in kind, because TeX is not a format, it is a programming language.

Attack surface and trust boundary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Untrusted .tex document  (upload, attachment, scraped corpus, CI artefact)
|
| whole file
v
LatexDocumentBackend
|
+-- preamble = everything before \begin{document}, verbatim
+-- tikz_raw = tikzpicture body, verbatim
|
| if options.tikz_engine == "tectonic"
v
TectonicEngine.render(tikz_raw, preamble, source_root)
|
+-- _stage_local_dependencies() <-- the guard lives here
| path checks: absolute rejected, is_relative_to(source_root)
| governs: WHICH FILES GET COPIED into the temp dir
|
+-- compose standalone document, write diagram.tex
|
v
subprocess.run([tectonic, "--print", "diagram.tex"]) ---- language boundary ---->
|
v
TeX interpreter: \input \openin \openout \write \write18
|
v
Everything the converter process can read or write

The guard and the sink are separated by a boundary that the guard cannot see across. _stage_local_dependencies operates on Python strings and pathlib objects, before the compile. The TeX interpreter operates on its own file primitives, during the compile, with the full authority of the process. There is no relationship between the two other than the fact that both happen to involve files.

Root-cause analysis

The guard that exists

From docling/backend/latex/engines/tectonic.py at v2.131.0:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@classmethod
def _resolve_local_dependency(
cls, source_root: Path, raw_path: str, *, is_tex: bool
) -> Path | None:
raw_path = raw_path.strip()
if not raw_path:
return None

candidate = Path(raw_path)
if candidate.is_absolute():
_log.warning("Absolute TikZ dependency paths are not staged: %s", raw_path)
return None

resolved = (source_root / candidate).resolve()
try:
if not resolved.is_relative_to(source_root):
_log.warning(
"Path traversal attempt blocked for TikZ dependency: %s", raw_path
)
return None
except ValueError:
_log.warning("Invalid TikZ dependency path: %s", raw_path)
return None
...

Read in isolation, this is good code. It rejects absolute paths. It calls .resolve() before the containment check, so symlinks and .. segments are collapsed first — which is the ordering most path-traversal bugs get wrong. It fails closed. It even logs the attempt.

It is also, for the purposes of this CVE, decoration. Note what it returns: a Path that _stage_local_dependencies will shutil.copy2 into the temporary directory. Its only output is a copy decision. The advisory states the same thing in one sentence: _resolve_local_dependency “only decides which files are staged into the temporary directory. It does not limit what TeX opens at compile time.”

So \input{../../../../etc/passwd} is correctly refused staging. And then the string \input{../../../../etc/passwd} is written into diagram.tex unchanged, and tectonic is asked to compile it. TeX resolves that path itself, relative to its own working directory, using its own file-opening machinery, and never consults Docling about it.

The document that gets compiled

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
latex_doc = (
"\\documentclass[border=20pt]{standalone}\n"
+ preamble
+ "\n"
+ "\\begin{document}\n"
+ tikz_code
+ "\n"
+ "\\end{document}\n"
)

with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
self._stage_local_dependencies(temp_path, preamble, tikz_code, source_root)
tex_file = temp_path / "diagram.tex"
tex_file.write_text(latex_doc, encoding="utf-8")

cmd = [str(self.binary_path)]
if self.allow_shell_escape:
cmd.extend(["-Z", "shell-escape"])
cmd.append("--print")
cmd.append(str(tex_file))

Two attacker-controlled regions are concatenated into that document, and it is worth being precise about both, because one of them is easy to miss.

tikz_code is the tikzpicture body. That one is obvious.

preamble is everything in the input file before \begin{document}, with only the \documentclass line removed. From docling/backend/latex/backend.py:

1
2
3
4
5
6
7
8
preamble_match = re.search(
r"^(.*?)\\begin\{document\}", preprocessed_text, re.DOTALL
)
if preamble_match:
raw_preamble = preamble_match.group(1)
self.latex_preamble = re.sub(
r"\\documentclass(?:\[[^\]]*\])?\s*\{[^}]*\}", "", raw_preamble
)

The only filtering applied to it before compilation is _sanitize_preamble_for_tectonic(), which comments out three pdfTeX assignment primitives (\pdfcompresslevel, \pdfminorversion, \pdfobjcompresslevel) because Tectonic does not implement them. That is a compatibility shim, not a security control, and the code says so in its own docstring: “Drop assignment-only pdfTeX primitives Tectonic/XeTeX does not provide.”

So the attacker controls a full LaTeX preamble — the part of a document where you are supposed to put \usepackage, macro definitions, and \immediate\write calls. The preamble is not a constrained region. It is the configuration section of a programming language.

The sink is an interpreter

This is the part that makes the bug class interesting rather than routine. The sink is not open(). It is not os.path.join. It is:

1
2
3
4
5
6
7
subprocess.run(
cmd,
cwd=temp_dir,
capture_output=True,
check=True,
timeout=self.timeout,
)

tectonic is a TeX engine, and TeX is Turing-complete with first-class file I/O. \openin and \openout are not obscure corners; they are the primitives that \include, auxiliary files, .toc generation and every cross-referencing package are built on. Removing them would break LaTeX.

Which means there is no flag you can pass to a TeX engine that says “be a renderer, not a program.” The capability is the language. Once Docling decided to hand attacker-composed source to tectonic, the only remaining question was what the operating system would permit — and cwd=temp_dir sets the working directory, which is not a jail. An absolute path ignores it, and .. walks out of it.

The insecure default

Separately, from the same file at v2.131.0:

1
2
3
4
5
def __init__(
self,
timeout: float = 60.0,
allow_shell_escape: bool = True,
):

The constructor defaulted shell escape on — that is the CWE-1188 component. In practice the LaTeX backend always passes the value explicitly:

1
2
3
4
self._tectonic_engine = TectonicEngine(
timeout=opts.tikz_engine_timeout,
allow_shell_escape=opts.tikz_engine_allow_shell_escape,
)

and LatexBackendOptions.tikz_engine_allow_shell_escape defaults to False. So the bad default is only reachable by code that instantiates TectonicEngine directly, which the advisory states plainly. It is still worth flagging, because “the only caller in this repository happens to pass the argument” is a property of today’s call graph, not of the API. Any downstream integration, notebook, or future call site that writes TectonicEngine() gets \write18 with no indication that it opted into anything.

The in-memory case removes even the staging

1
2
3
4
preamble = self.latex_preamble
source_root = None
if isinstance(self.path_or_stream, Path):
source_root = self.path_or_stream.parent

source_root is set only when the input is a filesystem Path. For a DocumentStream — the normal way to feed Docling bytes from an upload, a queue, or an HTTP handler — it stays None, and _stage_local_dependencies returns immediately:

1
2
if source_root is None:
return

No staging happens, so none of the path validation runs at all. But tex_file.write_text(latex_doc) and subprocess.run(cmd) happen regardless. The compile proceeds with the attacker’s document and zero path checks.

Inferred: this is the configuration most likely to be in production. A service accepting uploads holds bytes, not paths. So the deployment shape with the least input validation is also the one most exposed to untrusted documents — the staging guard is most active exactly where the input is most likely to be trusted (a local file you pointed the CLI at), and entirely absent where it is least likely to be trusted.

The data flow, end to end

Step Component Attacker control
1 Document submitted to the pipeline Full file contents
2 LatexDocumentBackend splits preamble / body Both regions, verbatim
3 _sanitize_preamble_for_tectonic Three pdfTeX assignments commented out; everything else survives
4 _stage_local_dependencies Path-checked, or skipped entirely for a stream
5 diagram.tex composed and written Preamble and TikZ body embedded unmodified
6 tectonic --print diagram.tex TeX executes attacker source with process privileges
7 PDF rasterised into the output document Read file content returns to the caller inside the image

Step 4 is the only step with a security check in it, and it is the only step that does not touch the data that reaches step 6.

Why --untrusted was not the answer on its own

The obvious reading of the fix is “pass Tectonic its hardening flag.” That is part of what the patch does, and it is not sufficient — and the advisory is unusually candid about why:

\immediate\openout / \write create or overwrite files outside the temporary directory. Neither needs shell escape. Tectonic’s --untrusted flag does not block them either.

--untrusted turns off shell escape and extra search paths. It does not turn off TeX’s own write primitives, because those are load-bearing for ordinary documents. So even a correctly hardened invocation leaves the arbitrary-write primitive intact. That is why 2.132.0 needs a source inspection step in addition to the flags — and why the source inspection is the weaker half of the fix.

Impact

Verified

  • Arbitrary file read, limited to what the converter process can read, via \input, \verbatiminput and \openin.
  • Content disclosure back to the attacker, because read content can be typeset into the rendered picture included in the conversion output.
  • Arbitrary file create and overwrite, limited to what the process can write, via \immediate\openout and \write, with no shell escape required.
  • Arbitrary command execution when tikz_engine_allow_shell_escape=True, via \write18.
  • The advisory states that writing to startup or configuration files can lead to code execution even without shell escape.

Deployment-dependent consequences (Inferred)

  • Credential disclosure. An ingestion worker’s environment, cloud credential files, .netrc, Kubernetes service-account token, or SSH keys are all ordinary files to a process that can read them.
  • Pivot to cloud metadata. Not directly — TeX’s primitives are file primitives, not network ones. But a read of a credential file on disk achieves the same end result as the metadata SSRF path, without needing a URL fetch.
  • Persistence across restarts. A write that lands in an interpreter startup path or a service config file executes on the next worker start. Ingestion workers restart often.
  • Poisoning the pipeline’s own output. A write into a shared scratch or output volume can alter what downstream chunking and embedding stages consume, which is a data-integrity problem the pipeline has no reason to check for.
  • Multi-tenant blast radius. A shared converter pool processes documents from many tenants with one identity. One tenant’s upload reads and writes as that identity.

What bounds the severity

Honesty about the limits matters as much as the impact list, and the AC:H / UI:R in the vector is doing real work here:

  • The default configuration is not affected. tikz_engine defaults to None.
  • Docling does not install tectonic. The binary must already be on PATH or in the Docling cache.
  • The CLI cannot enable it. Reaching this requires Python code that constructs LatexBackendOptions(tikz_engine="tectonic").
  • Shell escape is off by default in the backend path.

That is a genuinely narrow precondition set, and it is the right reason this is 7.5 and not 9-point-something. But “opt-in” describes who is exposed, not how exposed they are. A caller who enabled TikZ rendering did so to render diagrams, and had no way to know the option also handed every submitted document a file-I/O-capable interpreter. The option’s description, before 2.132.0, read: “Set to ‘tectonic’ to enable asynchronous image generation.”

Safe validation and detection

Configuration review — this is the fast answer

The exposure question is almost entirely answerable statically, without touching a running system:

  • Search your codebase for tikz_engine. If no call site sets it to "tectonic", you are not affected, regardless of installed version.
  • Search for tikz_engine_allow_shell_escape=True. Any hit is a finding on its own merit, at any version, including 2.132.0.
  • Search for direct TectonicEngine( instantiation. Those call sites inherited allow_shell_escape=True before 2.132.0.
  • Check whether a tectonic binary exists on PATH or at ~/.cache/docling/tectonic in the converter image.
  • Pin the installed version: pip show docling docling-slim. Affected range is >= 2.94.0, < 2.132.0.

Telemetry worth reviewing

If tikz_engine="tectonic" was enabled on an affected version and untrusted documents were processed, absence of an alert is not evidence of absence — none of this triggers a security control by default. What is reviewable:

  • Docling logs for "Path traversal attempt blocked for TikZ dependency" and "Absolute TikZ dependency paths are not staged". These indicate a document that named an outside path. They do not indicate the compile was prevented — and a document using \openout directly produces no log line at all. Treat a hit as a signal to investigate the document, and absence of hits as uninformative.
  • File-integrity monitoring on config, startup and interpreter paths writable by the converter user, over the window the affected version was deployed.
  • Process creation under the converter process. Relevant only if shell escape was enabled.
  • Unexplained files in scratch or output volumes, correlated with conversion timestamps.
  • Rendered figures in stored conversion output that contain text rather than a diagram. That is what a successful read looks like in the artefact.

Review the documents themselves if you retained them. A static scan of stored .tex inputs for \openout, \openin, \write18 and absolute-path \input is cheap and is the most direct evidence available.

Remediation and hardening

Required

Upgrade to docling >= 2.132.0 (or docling-slim >= 2.132.0). There is no configuration change that fully substitutes for it, because the write primitive survives correct flag hardening.

If you cannot upgrade immediately

Both of these come from the advisory’s own workarounds section:

  • Set tikz_engine=None (the default) for any pipeline that handles untrusted input. You lose rendered TikZ diagrams; you keep the TikZ source as code metadata, which is the same fallback 2.132.0 uses when it declines to render.
  • Never set tikz_engine_allow_shell_escape=True for untrusted input, on any version.

Regardless of version — and the vendor says this too

This is the part not to skip. The 2.132.0 documentation itself states:

The pre-check only reads the source text and does not cover every way TeX can access files. When rendering untrusted LaTeX with Tectonic, run the conversion in an isolated environment, for example a container without host mounts or network access.

So, for any pipeline that renders untrusted LaTeX:

  • Run conversion in a container with no host mounts, a read-only root filesystem, and a dedicated writable tmpfs for the build directory only.
  • Give it no network, which removes the metadata-endpoint and egress concerns wholesale.
  • Give it no credentials. An ingestion worker that parses documents does not need a cloud key in its environment; if it needs one to store results, put that on the other side of a queue.
  • Run as a non-root user with no write access to anything outside the scratch directory.
  • Enforce the tikz_engine_timeout, and treat a timeout as a signal rather than a retry.

The reason to do all of this even on 2.132.0 is that the fix is explicitly best-effort. The container is the control that actually holds.

What the patch does, and what it admits it cannot do

The fix is commit 38b6fa0 via PR #4419, released in 2.132.0. It has three parts.

1. The default flipped, and hardening flags were added.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _build_command(self, tex_file: Path) -> list[str]:
"""Build the Tectonic command line for compiling ``tex_file``."""
cmd = [str(self.binary_path)]
if self.allow_shell_escape:
# --untrusted would disable shell escape, so it is not added here.
cmd.extend(["-Z", "shell-escape"])
else:
# --untrusted disables shell escape and extra search paths;
# --only-cached prevents network fetches of bundle files.
cmd.append("--untrusted")
cmd.append("--only-cached")
cmd.append("--print")
cmd.append(str(tex_file))
return cmd

allow_shell_escape now defaults to False, closing the CWE-1188 gap for direct instantiation. --only-cached is a nice secondary hardening: it stops Tectonic fetching bundle files on demand, removing a network dependency from the compile.

2. A best-effort source pre-check.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
_PATH_ARGUMENT_PATTERN = re.compile(
r"\\(?P<command>input|include|includegraphics|InputIfFileExists|graphicspath)"
r"(?![A-Za-z])\*?(?:\s*\[[^\]]*\])?\s*\{*\s*(?P<path>[^{}\s]*)"
)
_FILE_PRIMITIVE_PATTERN = re.compile(
r"\\(?P<command>openin|openout|XeTeXpicfile|XeTeXpdffile)(?![A-Za-z])"
)

@classmethod
def _find_unsafe_construct(cls, text: str) -> str | None:
"""Return a description of the first outside file reference, if any."""
primitive = cls._FILE_PRIMITIVE_PATTERN.search(text)
if primitive is not None:
return f"\\{primitive.group('command')}"
for match in cls._PATH_ARGUMENT_PATTERN.finditer(text):
path = match.group("path")
if (
path.startswith(("/", "\\", "~"))
or re.match(r"[A-Za-z]:", path)
or ".." in re.split(r"[/\\]", path)
):
return f"\\{match.group('command')} with path {path!r}"
return None

Two things about this are well judged. It runs over every staged file and the generated diagram.tex itself, not just the TikZ body — so the attacker-controlled preamble is in scope. And when it matches, rendering is skipped and the diagram degrades to TikZ source metadata rather than failing the conversion, so the control is unlikely to be disabled for being disruptive.

3. The option description was rewritten to say what the option now implies, including that the check is best-effort.

The limits, stated by the vendor

The advisory does not oversell the fix:

This check is best-effort. It reads the source text, and TeX can open files in ways a text check cannot see, for example through package commands or names built by macros.

That is the correct characterisation. A regex over TeX source cannot be complete, because TeX constructs control sequences at runtime — \csname, \expandafter, catcode reassignment — and a name assembled during expansion is not present in the source text to be matched. The pre-check raises the cost of exploitation; it does not establish a boundary.

Two further observations, from reading the patch rather than the advisory:

  • The pre-check does not run when shell escape is enabled. The call site is if not self.allow_shell_escape and not self._precheck_staged_files(temp_path). This is deliberate and documented — the docs state that enabling shell escape “skips the source pre-check described below” — but the consequence is worth naming: setting tikz_engine_allow_shell_escape=True on 2.132.0 gives you the pre-2.132.0 exposure plus \write18. The option is not a performance toggle.
  • An earlier, stricter version of the check was deliberately simplified before merge. The PR’s commit messages record an intermediate implementation that also skipped rendering when the source “builds control sequences indirectly” and when it “uses package commands and options that read or write files.” The final commit, titled “fix(latex): simplify the Tectonic source pre-check,” states that it “replace[s] the broad control-word scan” with the narrower pattern pair shown above. That is a defensible trade — a broad control-word scan over real-world LaTeX will refuse to render a great many legitimate documents — but it means the shipped check knowingly does not cover the \csname class that the advisory then names as a limitation.

This is not a criticism of the patch so much as the reason the patch ships with a documentation note telling you to use a container. The maintainers fixed what is fixable in a text check and were explicit about the residual risk. That is the right way to handle a bug where the sink is an interpreter.

Timeline

Date (UTC) Event Source certainty
2026-05-18 docling 2.94.0 released, adding opt-in Tectonic TikZ rendering (PR #3369) — start of the affected range Release metadata
2026-09-29 Fix commit 38b6fa0 merged via PR #4419 Commit metadata
2026-10-01 09:44 docling 2.132.0 released Release metadata
2026-10-01 11:54 GHSA-x3q2-h9hx-4r4j published Advisory metadata
2026-10-05 19:11 CVE-2026-105744 reserved CVE record metadata
2026-10-05 21:27 CVE-2026-105744 published CVE record metadata
2026-10-06 This analysis published This site

The sequencing is worth noting as good practice: the fix was merged, then released, then the advisory was published two hours after the release, and the CVE identifier was attached four days later. Users who track releases had the patch available before the vulnerability was public.

Lessons for builders

A guard belongs at the sink, not at the stage before it

Docling’s path validation was correct, ordered properly, and fail-closed. It also could not possibly have prevented this, because it guarded the copy step while the damage happened in the compile step. When you write a containment check, name the operation it contains. If you cannot name one, you have written a filter, not a boundary.

This is the same lesson as the knowns set from the opposite direction. There, the defect was that filepath.Join composes and cleans but never constrains, and the fix was to move containment down into the storage layer where every caller inherits it. Here the project had containment logic but attached it to the wrong operation. Both failures are about layer placement, not about path parsing.

Handing untrusted input to an interpreter is a sandbox decision

\openout is not a vulnerability in TeX. It is a documented feature that LaTeX’s own cross-referencing depends on. There is no flag that makes TeX safe for untrusted input, which means the decision “compile this attacker-controlled document” is irreversibly a decision about what the surrounding process is allowed to touch.

The practical test: if your mitigation is a pattern list over the input language, ask whether that language can build names at runtime. If it can — TeX, shell, SQL with dynamic identifiers, any template engine with reflection — the pattern list is a cost increase, and the real control is process isolation. Write the pattern list anyway. Just do not let it be the only thing.

“Opt-in” bounds who is affected, not how badly

The narrow precondition set is the honest reason this scored 7.5. It is not a reason to deprioritise it, because the people who opted in are exactly the people processing LaTeX — which overwhelmingly means academic papers, arXiv sources and submitted documents. The population that enabled the feature correlates strongly with the population handling untrusted input.

And they could not have known. The option read “enable asynchronous image generation.” Nothing in that sentence suggests a file-I/O-capable interpreter. If an option changes the trust model, the option’s own description is where it has to say so — which is precisely what 2.132.0 changed it to do.

Check what happens when the optional guard is absent

The most exposed configuration here is the one where source_root is None, because a DocumentStream has no parent directory. The validation silently did not run, and the compile proceeded anyway. Whenever a security check is conditional on a value being present, enumerate the cases where it is not — and make sure the dangerous operation is gated on the same condition the check is. Here, return on a missing source_root skipped the guard; it should have been a decision about whether to render at all.

Read the vendor’s stated limitations as part of the fix

2.132.0’s documentation says the pre-check is best-effort and tells you to use an isolated environment. That note is not boilerplate — it is the maintainers accurately describing where the boundary now is. An upgrade that ignores it leaves you patched against the published proof of concept and exposed to the class. Treat “upgrade” and “the advisory’s hardening guidance” as one remediation, not two.

References

This is independent analysis of a vulnerability reported by others; the GitHub advisory credits reporters who are not publicly named. Recheck the primary sources above during remediation — public vulnerability records are frequently revised after publication.