Site Tools


meta:texrender

**This is an old revision of the document!**

texrender Plugin Plan

texrender is a proposed DokuWiki syntax plugin that renders \documentclass{standalone} ... \end{document} blocks pasted directly into wiki source through a real LaTeX engine and embeds the result as inline SVG. This page is the design, the results of verifying the toolchain, and two problems found while verifying it — one of them a security hole that changes the shape of the plugin.

Verdict up front

Feasible, everything needed is installed, and the pipeline works — I ran it end to end by hand while writing this. The id clash you flagged is real and confirmed. But testing turned up a second, worse problem: dvisvgm will happily emit attacker-supplied raw markup, including <script>, into the SVG. Any plugin that embeds that output inline is an XSS vector. The fix folds neatly into the same pass that fixes the id clash, so it costs little — but it moves “sanitize the SVG” from a nice-to-have to the load-bearing part of the design.

Threat model

This wiki has no untrusted users: registration is disabled, and everyone who can edit a page is already trusted with the server. Nothing below is a live risk here.

The reason to take it seriously anyway is that this plugin is intended to be publishable, and a published LaTeX-rendering plugin will be installed on wikis that do have untrusted editors. “Runs arbitrary LaTeX on your server” is the kind of feature people reasonably expect to have been thought about. So the security work is scoped as build it right the first time, not harden it later — the sanitizer and the environment scrubbing are cheap now and awkward to retrofit, and the parts that are genuinely expensive (real container isolation) are deferred behind an interface rather than either built prematurely or designed out.

Concretely, that means: v1 runs on the host's own binaries, with the cheap protections in place, and is honest in its documentation that host execution assumes trusted editors. The strong isolation story is a second backend, sketched below, that can land later without touching the rest of the plugin.

Toolchain: verified present

Tool Path Version
latex / pdflatex /usr/bin/latex pdfTeX 3.141592653-2.6-1.40.26 (TeX Live 2025/dev/Debian)
dvisvgm /usr/bin/dvisvgm 3.4.4
texfot /usr/bin/texfot rev 1.53
timeout, nice, prlimit /usr/bin/ present
standalone.cls, tikz.sty, amsmath.sty texmf-dist present

dvipng is not installed, and inkscape + convert are — irrelevant here, since the plan goes DVI → SVG directly and never produces a raster.

PHP has proc_open available with an empty disable_functions. Apache runs as www-data; there is no passwordless sudo, so bin/texrender's --runas option cannot be used from PHP. LaTeX will run as www-data, the same user as the web server. That constrains the sandboxing options below.

I confirmed the pipeline by hand:

latex -interaction=nonstopmode --output-format=dvi fig.tex
dvisvgm --no-fonts --exact --scale=1.2 fig.dvi -o fig.svg

A TikZ arrow with an inline $Ax$ label produced a clean 3.5 KB SVG in about 0.1 s of dvisvgm time.

Syntax component

Mirror the existing markdowku/syntax/latexfigure.php, which already claims this pattern, but widen it and actually render:

$this->Lexer->addSpecialPattern(
    '\\\\documentclass(?:\[[^\]]*\])?\{standalone\}.+?\\\\end\{document\}',
    $mode,
    'plugin_texrender_figure'
);

Two notes on the existing pattern. It hardcodes \documentclass{standalone} with no optional argument, so the very common \documentclass[border=2pt]{standalone} and \documentclass[tikz]{standalone} forms do not match today — worth fixing on the way past. And the lazy .+? does span newlines: DokuWiki's ParallelRegex::getPerlMatchingFlags() returns msSi, so s is on (inc/Parsing/Lexer/ParallelRegex.php:197).

getType() stays 'protected' and getPType() stays 'block', so the pattern is inert inside code blocks. getSort() can keep 88; nothing competes for this pattern.

This plugin supersedes markdowku's latexfigure.php, which currently just dumps the source into a latex-highlighted code block. Both must not be enabled at once — whichever sorts lower wins and the other silently does nothing. Deleting latexfigure.php from markdowku is part of shipping this, and it lines up with the plan in DokuWiki Consolidation Feasibility to return markdowku to pristine upstream.

Pipeline: PHP, not the shell script

Recommend mirroring bin/texrender in PHP rather than shelling out to it, for the same reason the man plugin does its own proc_open: bin/texrender builds its commands as shell strings and runs them through sh -c, which means every input path is a quoting question. A PHP helper using proc_open with an argv array has no shell in the loop at all.

Sketch of helper.php:

  1. Make a private temp dir. data/tmp/ is drwxrws--- iv:www-data, so data/tmp/texrender/<nonce>/ works and inherits the group via setgid. Register a shutdown/finally cleanup.
  2. Write the matched source verbatim to fig.tex. No preamble injection, no wrapping — the whole point of the syntax is that pasted standalone LaTeX is already a complete document.
  3. Run latex with argv ['latex', '-interaction=nonstopmode', '-no-shell-escape', '--output-format=dvi', 'fig.tex'], cwd set to the temp dir, and a scrubbed environment (see below).
  4. Run dvisvgm with argv ['dvisvgm', '--no-fonts', '--exact', '--scale=<conf>', 'fig.dvi', '-o', 'fig.svg'].
  5. Post-process the SVG (sanitize + re-id, one DOM pass).
  6. On any failure, produce the texfot-shortened error instead.

Environment scrubbing

Pass an explicit env array, not the inherited one:

openin_any=p          # LaTeX may not read outside the working tree
openout_any=p         # ...nor write outside it
shell_escape=p        # no \write18
HOME=<tmpdir>
TEXMFVAR=<tmpdir>/texmf
PATH=/usr/bin:/bin

openin_any=p is not decoration. I tested it — with default settings, \input{/etc/hostname} inside a standalone document reads the file and typesets its contents into the output:

--- default ---
(/etc/hostname)
--- openin_any=p ---
! LaTeX Error: File `/etc/hostname.tex' not found.

So without openin_any=p, anyone who can edit a page can exfiltrate any file readable by www-dataconf/local.php, conf/acl.auth.php, the user database — by rendering it into a picture. This single variable is the most important line in the plugin.

Note bin/texrender has a typo here: it sets shell_escpe=p, not shell_escape=p (line 89). The protection it looks like it provides is not being applied. In practice -disable-write18 on the same line does cover it, and TeX Live defaults to restricted shell escape anyway, but the variable as written does nothing.

Resource limits

timeout and nice are straightforward. For memory, PHP has no ulimit, so either wrap in sh -c 'ulimit -Sv N; exec latex ...' (reintroduces a shell, but with no user data in the command) or use prlimit --as=<bytes> -- latex ..., which is installed and keeps the argv-array property. Recommend prlimit.

Limits worth having, all configurable: wall-clock timeout (default 5 s, matching bin/texrender), address-space cap (~250 MB, matching), nice 5, a max input length (say 64 KB), and a cap on figures rendered per page to stop one page from pinning the CPU for a minute. TeX's own main_memory will stop most runaway macro expansion before the timeout does, but neither alone is sufficient — keep both.

bwrap and firejail are not installed; systemd-run is, but creating a scope as www-data with no user session isn't practical. So the isolation story is: unprivileged user, scrubbed env, no shell escape, no filesystem reads outside the temp dir, capped time and memory. That is a reasonable posture for a wiki where editing is already restricted, and it is not sufficient for a wiki with open registration. Registration is currently disabled ($conf['disableactions'] = 'register'), which is what makes this acceptable.

Problem 1: colliding `id` attributes (confirmed)

Real, exactly as you described. dvisvgm --no-fonts turns each glyph into a <path> in <defs> with an id derived from font index and character code, then references it with <use xlink:href='#...'>. Two unrelated figures on the same page collide whenever they share a glyph. Two different figures I rendered:

fig1 ids: id='g0-120' id='g0-65' id='page1'
fig2 ids: id='g0-121' id='g0-65' id='page1'

Both define g0-65 (the letter A) and both define page1. In one HTML document, every #g0-65 reference resolves to whichever came first, and the second figure silently renders the first figure's glyph — at the first figure's font and size. page1 collides on every single figure unconditionally.

dvisvgm has no --id-prefix option (I checked; it errors out), so this has to be fixed in post-processing.

The fix: a per-figure nonce suffixed onto every id and every reference to one. References appear in three forms — xlink:href='#x', href='#x', and url(#x) inside presentation attributes (clip-path, fill, mask, filter, marker-*) — so a naive id= rewrite alone would break the figure rather than fix it.

On nonce choice: prefer substr(md5($source . $pos), 0, 8) over a random string. It is unique per figure instance on a page (position differs even for two identical figures), but stable across re-renders, so the XHTML cache and any page diffing don't churn on every rebuild.

Problem 2: `dvisvgm` will emit raw `<script>` (found while testing)

This one wasn't on the list and matters more than the ids. dvisvgm implements a dvisvgm:raw special that injects arbitrary markup into the output verbatim. It is reachable straight from document source:

\documentclass{standalone}
\begin{document}
\special{dvisvgm:raw <script>alert(1)</script>}X
\end{document}

The resulting SVG contains, verbatim:

<script>alert(1)</script>

Embedded inline, that executes in the context of the wiki, with the reader's session. Every page view, for everyone.

The obvious mitigation — disabling the specials with dvisvgm -S dvisvgmdoes not work: PGF/TikZ's DVI-to-SVG driver is itself built on dvisvgm:raw, and with specials off the run produced no output file at all. Losing TikZ defeats the purpose of the plugin.

So the SVG must be sanitized before embedding, with an allowlist, not a blocklist:

  • Elements: svg, g, defs, use, path, rect, circle, ellipse, line, polyline, polygon, text, tspan, clipPath, mask, pattern, linearGradient, radialGradient, stop, symbol, marker, title, desc, image. Everything else is dropped, <script> and <foreignObject> most of all.
  • Attributes: geometry and presentation only. Drop every on* handler, style (or parse it), and any href/xlink:href that isn't a local #fragment — with a narrow exception for image where a data:image/... URI may be allowed, if embedded bitmaps are wanted at all. Simplest is to disallow image entirely in v1.
  • Drop the XML declaration and the <!-- generated by dvisvgm --> comment; <?xml ... ?> is invalid inside an HTML body.

Do this with DOMDocument, not regex, and do it in the same traversal as the id rewriting — one pass that walks every element, drops disallowed nodes, drops disallowed attributes, and rewrites ids and #/url(#) references. Regex over untrusted markup is how sanitizers get bypassed, and here the DOM walk is needed for the ids anyway.

Note that core's inlineSVG() (inc/common.php:1989) is not a sanitizer and must not be borrowed for this — it strips comments, the XML header and the doctype, and nothing else. <script> passes straight through it. It's fine for the template's own trusted icon files, which is all it was written for.

Execution backends: local now, containerised later

Everything above describes running LaTeX on the host. That is what v1 should do, and the binary paths should be configurable exactly like the man plugin does it$conf['pandoc'] = '/usr/bin/pandoc' with an is_executable() guard at the call site (lib/plugins/man/helper.php:538), which degrades to a fallback instead of exploding when the tool isn't there. Same shape here: $conf['latex'], $conf['dvisvgm'], $conf['texfot'], each checked before use, with a missing binary producing the error block rather than a PHP warning.

But the host route has a ceiling. No matter how well the environment is scrubbed, LaTeX still runs as www-data, in the web server's own process tree, with read access to everything www-data can read — which on a DokuWiki install is the entire wiki including conf/ and the user database. openin_any=p closes the door that matters, and it is a door in a wall we didn't build and don't fully control. TeX is a Turing-complete macro language with decades of accumulated file-touching surface; a confident claim that no path out exists is not one worth making.

The delegated service

The way out is to stop running LaTeX in the web server's trust domain at all: a small HTTP service on localhost:3333, backed by a container with TeX Live, dvisvgm and friends installed, that accepts LaTeX source and returns SVG.

Contract, roughly:

POST /render
Content-Type: application/json
{ "source": "\\documentclass{standalone}...", "scale": 1.2, "timeout": 5 }

200 { "svg": "<svg …>" }
422 { "error": "! Undefined control sequence …" }

The interesting properties are all on the container side, and they're things the host route simply cannot offer:

  • No filesystem worth reading. The container holds TeX Live and nothing else. \input{/etc/passwd} returns the container's inert /etc/passwd, not the wiki's config. File disclosure stops being a vulnerability and becomes a curiosity.
  • No network. --network=none on the render container. Nothing to exfiltrate to even if something is read.
  • Real resource limits. --memory, --cpus, --pids-limit are enforced by the kernel, not by a soft prlimit the process might raise back.
  • Disposable filesystem. --read-only with a tmpfs scratch mount, one container per render, or a pool that resets. No persistence between renders means no cross-figure contamination.
  • The blast radius of a TeX escape is a container with nothing in it, rather than the wiki.
  • Deployment stops depending on the host having TeX Live. That is a genuine practical win independent of security — a ~5 GB dependency becomes someone else's problem, and it's the same argument already made in DokuWiki Docker Feasibility about texrender's toolchain being the dominant size driver of a wiki image. If the wiki itself ends up containerised, this service is a second compose service and the two arguments collapse into one.

Where sanitization happens is a real design question. Tempting to say the service returns “sanitized SVGs” and be done. Better: sanitize on both sides, and treat the service's output as untrusted anyway. The plugin's DOM pass runs regardless of backend. Reasons: the id-rewriting pass has to run plugin-side no matter what (the nonce depends on the figure's position in the page, which the service doesn't know); a service that is only reachable on loopback is still a network dependency whose output the plugin shouldn't blindly trust; and keeping the sanitizer in one place means the two backends can't drift into different security properties. The service sanitizing too is defence in depth, not a reason for the plugin to skip it.

SSRF applies in the small. The service URL is admin-configured, not user-supplied, so this isn't the classic case — but the plugin still shouldn't follow redirects, should require an explicit host and port with no DNS surprises, should cap the response size before parsing it, and should have its own client-side timeout shorter than the service's. Loopback-by-default with a documented warning about pointing it anywhere else.

What this means for the design now

Nothing structural — but it does mean the pipeline should sit behind a narrow interface from the start:

interface texrender_backend {
    /** @return array{svg: ?string, log: ?string} */
    public function render(string $source, array $opts): array;
}

texrender_backend_local (procopen, v1) and texrender_backend_service (HTTP, later) both implement it; a $conf['backend'] setting picks one. Caching, sanitizing, id-rewriting and the error block all live above this line and are written once. That is a handful of lines of extra structure in v1 and it removes the “rewrite the plugin to add containers” problem entirely. ## Rendering: inline SVG vs <img> Worth stating the trade-off explicitly, because one option makes both problems above disappear. Serving the SVG as a file and referencing it with <img src="..."> would mean no id collisions at all (each document is a separate DOM) and no XSS (browsers do not execute scripts in SVGs loaded via <img>). The costs: an extra HTTP request per figure, and the SVG can't inherit page CSS — no currentColor, no dark-mode adaptation, no styling hooks. Inline embedding, as specified, is the better end result and is what this plan assumes. But it is the option that requires the sanitizer to be correct, whereas <img> is safe by construction. If the sanitizer ever feels like more risk than it's worth, <img> via a fetch.php-style handler is the fallback, and it is a small change late in the process rather than a rewrite. Separately: dvisvgm emits stroke='#000' literally. The site is light-themed so this is fine today, but a future dark mode would render figures nearly invisible. Rewriting #000 to currentColor during the same DOM pass is cheap insurance and only possible on the inline route. ## Caching Follow the man plugin's model, which already works well here. Cache the raw, sanitized, un-nonced SVG keyed on md5($source . $engineOptions . $pluginVersion), via getCacheName($key, '.texsvg') so it lands under data/cache/. Apply the id nonce at render time, after the cache read — that keeps a figure that appears twice on one page from colliding with itself, and keeps the cached artifact reusable across pages. Cache failures too, keyed the same way, with a short TTL. Otherwise a page with a broken figure re-runs LaTeX on every view, which is exactly the situation where you least want to be running LaTeX. Include __FILE__ and conf/local.php in the cache dependency list, as the man plugin does, so editing the plugin or its settings invalidates. ## Error path On non-zero exit from either stage, or a missing/empty output file, render a code block instead of a figure. Use texfot to shorten it, with the same ignore list bin/texrender already tuned: texfot --quiet --no-stderr \ --ignore='^Overfull' --ignore='^Output written' \ --ignore='^LaTeX Warning' --ignore='^This is' \ latex ... Two things to add on top of bin/texrender's behaviour: - Strip the temp directory path from the log before displaying it. Raw LaTeX output is full of absolute paths, and this block is shown to every reader of the page, not just the editor. Replace the temp path with a neutral placeholder. - Cap the length. A misplaced \begin can produce hundreds of lines even after texfot. Truncate to a configurable number of lines with an explicit “… truncated” marker. Render it via the standard code instruction with language text — the log is not LaTeX and highlighting it as LaTeX makes it harder to read, which is the one thing the existing latexfigure.php behaviour gets backwards. ## Proposed layout lib/plugins/texrender/ plugin.info.txt syntax/figure.php \documentclass{standalone}...\end{document} helper.php pipeline, sanitizer, id-rewriter, cache conf/default.php conf/metadata.php lang/en/settings.php style.css figure alignment, overflow, centering helper.php is where essentially all of this lives; syntax/figure.php should be thin. Splitting the sanitizer into its own class is worthwhile if it grows — it is the part most deserving of unit tests, and it is testable in isolation without invoking LaTeX at all. Give it its own git repo, like man and socialicons, since lib/plugins/ is gitignored by the main repo. ## Configuration | Setting | Default | Notes | | — | — | — | | backend | local | local or service | | latex | /usr/bin/latex | is_executable()-guarded, man-plugin style | | dvisvgm | /usr/bin/dvisvgm | as above | | texfot | /usr/bin/texfot | as above; skipped, not fatal, if absent | | prlimit | /usr/bin/prlimit | as above; limits simply unenforced if absent | | serviceurl | http://127.0.0.1:3333/render | service backend only | | servicetimeout | 10 | client-side, must exceed timeout | | engine | latex | DVI route; pdflatex would need a PDF→SVG path instead | | scale | 1.2 | passed to dvisvgm --scale | | timeout | 5 | seconds, per LaTeX run | | memory | 250000 | KB, via prlimit --as | | niceness | 5 | | | maxinput | 65536 | bytes of source accepted | | maxfigures | 16 | per page | | errorlines | 20 | truncation cap on the error block | | align | center | | | currentcolor | 1 | rewrite #000currentColor | ## Suggested order 1. Syntax component + texrender_backend_local behind the backend interface, embedding unsanitized output behind a config flag defaulting off — gets the pipeline working end to end on host binaries. 2. DOM pass: sanitizer and id-rewriter together. Test with the \special{dvisvgm:raw <script>} case above as a fixture; it should render an empty-but-valid figure, not a script tag. 3. Caching, including negative caching. 4. Error path with texfot, path scrubbing, truncation. 5. Resource limits and env scrubbing. Verify with a \def\x{\x}\x expansion bomb and an \input{/etc/hostname} disclosure attempt, both of which should fail cleanly and produce an error block. 6. Remove latexfigure.php from markdowku; enable this instead. Steps 1–6 are the whole plugin as a working thing. texrender_backend_service and its container are a separate piece of work afterwards, gated on whether the plugin actually gets published — there's no reason to build it for a single-user wiki, and the interface from step 1 is what keeps that deferral cheap. ## Open questions - Preamble policy. The plan assumes arbitrary \usepackage is allowed, with openin_any=p and the resource limits as the real controls. A package allowlist is possible but brittle and would break the copy-paste-anything property that motivates the syntax. Recommend no allowlist, but say if you'd rather have one. - bin/texrender's fate. The plugin doesn't need it. Keep it as a standalone CLI tool, or retire it once the PHP path is proven? - Does the service backend ever get built? Only worth it if the plugin is published, or if the wiki itself moves into containers and TeX Live's size becomes the deciding factor. Until one of those is true, the interface is the deliverable and the implementation is a stub. - Cache lifetime. Successful renders can cache indefinitely (they're content-keyed). Negative caching needs a TTL — an hour is a reasonable starting point, but that's a judgement call about how quickly you want a fixed figure to reappear after an edit elsewhere.

meta/texrender.1786642144.md.gz · Last modified: by 127.0.0.1