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

Reply via email to