[LyX/master] Hardening case 00de - consent gate for code-capable bib/index processors

Pavel Sanda <[email protected]>
Newsgroups gmane.editors.lyx.cvs
Message-ID <[email protected]>
commit 383a09f9e137f95e9ef55c04c62874a2654ff4c3
Author: Pavel Sanda <[email protected]>
Date:   Tue Jul 7 00:18:37 2026 +0200

    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 6b352553da..3a885b07a3 100644
--- a/src/Converter.cpp
+++ b/src/Converter.cpp
@@ -934,6 +934,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 065bf20cb3..77cd2d0d30 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 <fstream>
+#include <map>
 #include <regex>
 #include <stack>
 
@@ -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<string, bool> cache;
+	map<string, bool>::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<int>(m.str(1));
+			int const min = convert<int>(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(
+		_("<p>The following LaTeX backend has been requested "
+		  "to allow execution of external programs:</p>"
+		  "<center><p>%1$s</p></center>"
+		  "<p>The external programs can execute arbitrary commands on "
+		  "your system, including dangerous ones, if instructed to do "
+		  "so by a maliciously crafted LyX document.</p>"),
+		from_utf8("<tt>" + command + "</tt>"))
+		+ _("<p>Should LaTeX backends be allowed to run external "
+		    "programs?</p><p><b>Allow them only if you trust the "
+		    "origin/sender of the LyX document!</b></p>");
+
+	// 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 &not allow"), _("A&llow")) != 0;
+
+	if (theSession().authFiles().find(doc_fname))
+		return true;
+
+	int const choice = frontend::Alert::prompt(title, warning, 0, 0,
+			_("Do &not allow"), _("A&llow"),
+			_("&Always allow for this document"));
+	if (choice == 2)
+		theSession().authFiles().insert(doc_fname);
+	return choice != 0;
+}
+
+} // namespace
+
+
 bool LaTeX::runBibTeX(vector<AuxInfo> 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<AuxInfo>::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;
 
-- 
lyx-cvs mailing list
[email protected]
https://lists.lyx.org/mailman/listinfo/lyx-cvs
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.