>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Pavel Sanda Date: Tue, 7 Jul 2026 00:18:37 +0200 Subject: [PATCH] Hardening case 00de - consent gate for code-capable bib/index processors Older biber, xindy and xindex run document-embedded code even with lyx's default/whitelisted command (Tier EXT) - the residual the 00d/00e command-string checks cannot reach. Tier 00 hotfix: Interim gate - before running such a processor on a not-yet-trusted document during export, LyX now prompts for consent, reusing the existing converter-needauth trust machinery (no new translatable strings). - makeindex/upmendex are safe-listed. - biber auto-relaxes at version >= 2.22 via a --version probe. - xindex probe will be delivered separately and won't be backported. Tier 02 strings will land in later 2.5.x (prompt adjustment). Tier EXT (real) fixes: upstream (biber 2.22, xindex 1.07, xindy - TL update ~2026/08) Assisted-by: Claude Opus 4.8 --- src/Converter.cpp | 1 + src/LaTeX.cpp | 152 +++++++++++++++++++++++++++++++++++++++++++- src/LaTeX.h | 5 ++ 3 files changed, 155 insertions(+), 3 deletions(-) diff --git a/src/Converter.cpp b/src/Converter.cpp index 03172ac2ae..0101e4de0b 100644 --- a/src/Converter.cpp +++ b/src/Converter.cpp @@ -903,6 +903,7 @@ Converters::RetVal Converters::runLaTeX(Buffer const & buffer, string const & co string const name = buffer.latexName(); LaTeX latex(command, runparams, makeAbsPath(name), buffer.filePath(), buffer.layoutPos(), + buffer.absFileName(), buffer.isClone(), buffer.freshStartRequired()); TeXErrors terr; // The connection closes itself at the end of the scope when latex is diff --git a/src/LaTeX.cpp b/src/LaTeX.cpp index c1a855c40c..549d150ded 100644 --- a/src/LaTeX.cpp +++ b/src/LaTeX.cpp @@ -27,6 +27,9 @@ #include "Encoding.h" #include "Language.h" #include "LaTeXFeatures.h" +#include "Session.h" + +#include "frontends/alert.h" #include "support/debug.h" #include "support/docstring.h" @@ -39,6 +42,7 @@ #include "support/os.h" #include +#include #include #include @@ -65,6 +69,10 @@ docstring runMessage(unsigned int count) return bformat(_("Waiting for LaTeX run number %1$d"), count); } +bool isProcessorGated(std::string const & command); +bool checkProcessorAuth(std::string const & doc_fname, + std::string const & command); + } // namespace /* @@ -123,10 +131,11 @@ bool operator!=(AuxInfo const & a, AuxInfo const & o) */ LaTeX::LaTeX(string const & latex, OutputParams const & rp, - FileName const & f, string const & p, string const & lp, + FileName const & f, string const & p, string const & lp, + string const & dfname, bool allow_cancellation, bool const clean_start) - : cmd(latex), file(f), path(p), lpath(lp), runparams(rp), biber(false), - allow_cancel(allow_cancellation) + : cmd(latex), file(f), path(p), lpath(lp), doc_fname(dfname), + runparams(rp), biber(false), allow_cancel(allow_cancellation) { num_errors = 0; // lualatex can still produce a DVI with --output-format=dvi. However, @@ -598,6 +607,12 @@ int LaTeX::runMakeIndex(string const & f, OutputParams const & rp, if (!rp.index_command.empty()) tmp = rp.index_command; + // Gate the resolved index processor `tmp` that will actually run: + // meant for xindy/texindy/xindex, override or default. + if (isProcessorGated(tmp) + && !checkProcessorAuth(doc_fname, tmp)) + return Systemcall::KILLED; + Language const * doc_lang = languages.getLanguage(rp.document_language); if (contains(tmp, "$$x")) { @@ -803,11 +818,142 @@ void LaTeX::updateBibtexDependencies(DepTable & dep, } +namespace { + +// One row per processor we can clear without gating: either a non-interpreter +// tool that is never dangerous (safe = true, no probe), or a code-capable tool +// at/above a version whose sinks are fixed (safe = false + version probe). +// +// A processor *absent* from this table is always gated (the default both for +// code-capable tools with no acceptable version yet - xindy, xindex - and as a +// fail-safe). +struct RequiredProcessor { + char const * prog; // first-token basename to match + bool safe; // true = not code-capable (makeindex-class): + // never gate, skip the version probe + char const * version_arg; // argument that prints the version + char const * version_re; // regex capturing (major)(minor) + int min_major; // minimum version not requiring the gate + int min_minor; +}; + +RequiredProcessor const required_processors[] = { + // Non-interpreter index processors (makeindex-class): they cannot execute + // document-controlled code, so never gate them and skip the probe. + { "makeindex", true, nullptr, nullptr, 0, 0 }, + { "upmendex", true, nullptr, nullptr, 0, 0 }, + // biber: code-capable; fixed upstream at 2.22 + { "biber", false, "--version", "version:\\s*([0-9]+)\\.([0-9]+)", 2, 22 }, +}; + +// False only when >= required version. +// True for an unknown tool, an unparseable version, or a failed probe a +// Caches one `--version` probe per processor per session. +bool isProcessorGated(string const & command) +{ + string prog; + split(command, prog, ' '); // first whitespace token only + prog = onlyFileName(prog); // strip any directory part + if (prog.empty()) + return true; + + static map cache; + map::const_iterator const it = cache.find(prog); + if (it != cache.end()) + return it->second; + + bool gated = true; // fail-safe default + for (RequiredProcessor const & p : required_processors) { + if (prog != p.prog) + continue; + if (p.safe) { // non-interpreter: never gate, no probe + gated = false; + break; + } + //safe because prog was matched against the table + cmd_ret const r = + runCommand(quoteName(prog) + ' ' + p.version_arg); + smatch m; + regex const re(p.version_re); + if (r.valid && regex_search(r.result, m, re)) { + int const maj = convert(m.str(1)); + int const min = convert(m.str(2)); + gated = maj < p.min_major + || (maj == p.min_major && min < p.min_minor); + } + break; // matched the table row + } + cache[prog] = gated; + return gated; +} + +// Per-document trust gate; this only handles consent. +// +// Deliberately reuses Converters::checkAuth's machinery so the trust +// decision is shared: the same per-document authorization set +// (theSession().authFiles()), the same global prompt switch +// (lyxrc.use_converter_needauth), and the same persisted "Always run for this +// document". A document trusted for a needauth converter is therefore also +// trusted here, and vice versa - one "do you trust this document?" decision. +// +// Unlike checkAuth it does NOT honour use_converter_needauth_forbidden: that +// pref defaults to "forbid", which is correct for the rare hand-flagged +// needauth converters but would block biber on *every* biblatex document and +// xindy/xindex on every indexed one. Gating here is consent, not a +// hard-deny master switch. +// +// Returns true if the processor may run. +bool checkProcessorAuth(string const & doc_fname, string const & command) +{ + if (!lyxrc.use_converter_needauth) + return true; + + docstring const title = + _("A LaTeX backend requires your authorization"); + docstring const warning = bformat( + _("

The following LaTeX backend has been requested " + "to allow execution of external programs:

" + "

%1$s

" + "

The external programs can execute arbitrary commands on " + "your system, including dangerous ones, if instructed to do " + "so by a maliciously crafted LyX document.

"), + from_utf8("" + command + "")) + + _("

Should LaTeX backends be allowed to run external " + "programs?

Allow them only if you trust the " + "origin/sender of the LyX document!

"); + + // No document identity (preview, clone, import): cannot persist a + // per-document decision, so prompt without the "Always" option. + if (doc_fname.empty()) + return frontend::Alert::prompt(title, warning, 0, 0, + _("Do ¬ allow"), _("A&llow")) != 0; + + if (theSession().authFiles().find(doc_fname)) + return true; + + int const choice = frontend::Alert::prompt(title, warning, 0, 0, + _("Do ¬ allow"), _("A&llow"), + _("&Always allow for this document")); + if (choice == 2) + theSession().authFiles().insert(doc_fname); + return choice != 0; +} + +} // namespace + + bool LaTeX::runBibTeX(vector const & bibtex_info, OutputParams const & rp, int & exit_code) { bool result = false; exit_code = 0; + + // Old biber is not safe. Plain bibtex is safe. + if (biber && isProcessorGated(rp.bibtex_command) + && !checkProcessorAuth(doc_fname, rp.bibtex_command)) { + exit_code = Systemcall::KILLED; + return false; + } for (vector::const_iterator it = bibtex_info.begin(); it != bibtex_info.end(); ++it) { if (!biber && it->databases.empty()) diff --git a/src/LaTeX.h b/src/LaTeX.h index 185b9ebdd9..d9a2e00acd 100644 --- a/src/LaTeX.h +++ b/src/LaTeX.h @@ -177,6 +177,7 @@ public: support::FileName const & file, std::string const & path = empty_string(), std::string const & lpath = empty_string(), + std::string const & doc_fname = empty_string(), bool allow_cancellation = false, bool const clean_start = false); @@ -250,6 +251,10 @@ private: /// Extra path, possibly relative to the document directory path. std::string lpath; + /// Absolute name for unique cache record in the trust gate. + /// Shared with Converters::checkAuth. + std::string doc_fname; + /// used by scanLogFile int num_errors;