commit e1086ed96c483fd1463fb884a92bd20653821498
Author: Pavel Sanda <[email protected]>
Date: Wed Jul 15 13:53:39 2026 +0200
Security hardening backport for LyX 2.3.x
Accumulated backport of the coordinated LyX security release, encompassing
Tier 00 variants. Folded hardening cases (per-case detail in the advisory):
00a kpsewhich filename -> shell command open/export -> exec
00b lyx2lyx invocation filename open -> exec
00c graphics filename extension -> os.system() open -> exec
00d \bibtex_command (preview + export) open/export -> exec
00e \index_command (whitelist + <> redirection) export -> exec
00g mangled graphics filename extension export -> exec
00h document basename -> conversion helpers open/import -> exec
[DiD]
00i document basename backtick in "..." export -> exec
00k \paperwidth/\paperheight -> parsecmd redirect export -> file
write
00de processing consent gate (biber/xindy/xindex) authorization guard
00h ships as defence-in-depth on 2.3: reviewed and applied, but the pre-2.4
proof-of-concept does not reproduce here (a working exploit is nonetheless
likely - cold analysis found further sinks of the same class).
The authorization gate is LyX's guard for tools that run document-embedded
code under their default command; the real fixes are upstream (biber 2.22,
xindex 1.07, coordinated TeX Live xindy update).
The gate relaxes for backported biber 2.22.
Assisted-by: Claude Opus 4.8
---
src/Buffer.cpp | 18 ++++-
src/BufferParams.cpp | 26 ++++++-
src/Converter.cpp | 32 +++++++-
src/LaTeX.cpp | 150 ++++++++++++++++++++++++++++++++++++-
src/LaTeX.h | 5 ++
src/graphics/GraphicsConverter.cpp | 9 ++-
src/graphics/PreviewLoader.cpp | 7 +-
src/support/FileName.cpp | 12 ++-
src/support/filetools.cpp | 19 ++++-
9 files changed, 262 insertions(+), 16 deletions(-)
diff --git a/src/Buffer.cpp b/src/Buffer.cpp
index ec480f7584..877af07496 100644
--- a/src/Buffer.cpp
+++ b/src/Buffer.cpp
@@ -1333,12 +1333,22 @@ Buffer::ReadStatus Buffer::convertLyXFormat(FileName
const & fn,
// Run lyx2lyx:
// $python$ "$lyx2lyx$" -t $LYX_FORMAT$ -o "$tempfile$" "$filetoread$"
+
+ // guard against command expansion in filename strings on linux,
+ // keep " on windows
+ auto sh_quote = [](string const & s) -> string {
+#ifdef _WIN32
+ return quoteName(s);
+#else
+ return '\'' + subst(s, "'", "'\\''") + '\'';
+#endif
+ };
ostringstream command;
command << os::python()
- << ' ' << quoteName(lyx2lyx.toFilesystemEncoding())
+ << ' ' << sh_quote(lyx2lyx.toFilesystemEncoding())
<< " -t " << convert<string>(LYX_FORMAT)
- << " -o " << quoteName(tmpfile.toSafeFilesystemEncoding())
- << ' ' << quoteName(fn.toSafeFilesystemEncoding());
+ << " -o " << sh_quote(tmpfile.toSafeFilesystemEncoding())
+ << ' ' << sh_quote(fn.toSafeFilesystemEncoding());
string const command_str = command.str();
LYXERR(Debug::INFO, "Running '" << command_str << '\'');
@@ -4512,6 +4522,8 @@ Buffer::ExportStatus Buffer::doExport(string const &
target, bool put_in_tempdir
theFormats().extension(backend_format));
LYXERR(Debug::FILES, "filename=" << filename);
+ // (00i-wide) makeLatexName keep-set is now shell-safe; no re-sanitize
here.
+
// Plain text backend
if (backend_format == "text") {
runparams.flavor = OutputParams::TEXT;
diff --git a/src/BufferParams.cpp b/src/BufferParams.cpp
index 51aab82aeb..adae2ee530 100644
--- a/src/BufferParams.cpp
+++ b/src/BufferParams.cpp
@@ -1021,8 +1021,18 @@ string BufferParams::readToken(Lexer & lex, string const
& token,
lcolor.setColor("boxbgcolor", color);
} else if (token == "\\paperwidth") {
lex >> paperwidth;
+ if (!paperwidth.empty() && !isValidLength(paperwidth)) {
+ lyxerr << "Rejecting non-Length \\paperwidth value: "
+ << paperwidth << endl;
+ paperwidth.clear();
+ }
} else if (token == "\\paperheight") {
lex >> paperheight;
+ if (!paperheight.empty() && !isValidLength(paperheight)) {
+ lyxerr << "Rejecting non-Length \\paperheight value: "
+ << paperheight << endl;
+ paperheight.clear();
+ }
} else if (token == "\\leftmargin") {
lex >> leftmargin;
} else if (token == "\\topmargin") {
@@ -3628,8 +3638,20 @@ string const BufferParams::getBibtexCommand(string const
cmd, bool const warn) c
string const BufferParams::bibtexCommand(bool const warn) const
{
// Return document-specific setting if available
- if (bibtex_command != "default")
- return getBibtexCommand(bibtex_command, warn);
+ if (bibtex_command != "default") {
+
+ // Block redirection on the export bibtex call.
+ // Temporary hotfix, longterm solution needs structural
+ // split between program and options.
+ static char const * const SUSPECT_CHARS = "<>\"\\\t\n";
+ if (bibtex_command.find_first_of(SUSPECT_CHARS) == string::npos)
+ return getBibtexCommand(bibtex_command, warn);
+ if (warn)
+ frontend::Alert::warning(
+ _("Requested bibliography command rejected"),
+ _("The bibliography processor command contains
prohibited characters."));
+ // fall through to the lyxrc-driven selection below
+ }
// If we have "default" in document settings, consult the prefs
// 1. Japanese (uses a specific processor)
diff --git a/src/Converter.cpp b/src/Converter.cpp
index 666e61c30b..6d754533d4 100644
--- a/src/Converter.cpp
+++ b/src/Converter.cpp
@@ -468,8 +468,35 @@ bool Converters::convert(Buffer const * buffer,
&& buffer->params().encoding().package() ==
Encoding::japanese;
runparams.use_indices = buffer->params().use_indices;
runparams.bibtex_command = buffer->params().bibtexCommand(true);
- runparams.index_command = (buffer->params().index_command ==
"default") ?
- string() : buffer->params().index_command;
+
+ // Accept only programs from fixed known list
+ string accepted_index_cmd;
+ if (buffer->params().index_command != "default"
+ && !buffer->params().index_command.empty()) {
+
+ // Do not allow redirection in index commands
+ bool const has_redirect =
+
buffer->params().index_command.find_first_of("<>") != string::npos;
+ if (!has_redirect) {
+ string supplied_prog;
+ split(buffer->params().index_command,
supplied_prog, ' ');
+ for (auto const & alt :
lyxrc.index_alternatives) {
+ string alt_prog;
+ split(alt, alt_prog, ' ');
+ if (!supplied_prog.empty()
+ && supplied_prog == alt_prog) {
+ accepted_index_cmd =
buffer->params().index_command;
+ break;
+ }
+ }
+ }
+ if (accepted_index_cmd.empty())
+ LYXERR0("Document-supplied index command '"
+ << buffer->params().index_command << "'
is not a recognised "
+ "index processor; falling back to
default.");
+ }
+
+ runparams.index_command = accepted_index_cmd;
runparams.document_language =
buffer->params().language->babel();
runparams.only_childbibs = !buffer->params().useBiblatex()
&& !buffer->params().useBibtopic()
@@ -810,6 +837,7 @@ bool Converters::runLaTeX(Buffer const & buffer, string
const & command,
string const name = buffer.latexName();
LaTeX latex(command, runparams, FileName(makeAbsPath(name)),
buffer.filePath(), buffer.layoutPos(),
+ buffer.absFileName(),
buffer.lastPreviewError());
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 4be0dad4ab..3febcb1df1 100644
--- a/src/LaTeX.cpp
+++ b/src/LaTeX.cpp
@@ -30,9 +30,14 @@
#include "support/Systemcall.h"
#include "support/os.h"
+#include "Session.h"
+
+#include "frontends/alert.h"
+
#include "support/regex.h"
#include <fstream>
+#include <map>
#include <stack>
@@ -58,6 +63,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
/*
@@ -94,8 +103,9 @@ 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,
- bool const clean_start)
- : cmd(latex), file(f), path(p), lpath(lp), runparams(rp), biber(false)
+ string const & dfname, bool const clean_start)
+ : cmd(latex), file(f), path(p), lpath(lp), doc_fname(dfname),
+ runparams(rp), biber(false)
{
num_errors = 0;
// lualatex can still produce a DVI with --output-format=dvi. However,
@@ -455,6 +465,12 @@ bool LaTeX::runMakeIndex(string const & f, OutputParams
const & runparams,
if (!runparams.index_command.empty())
tmp = runparams.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 false;
+
LYXERR(Debug::LATEX,
"idx file has been made, running index processor ("
<< tmp << ") on file " << f);
@@ -619,10 +635,140 @@ 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.first == 0 && regex_search(r.second, 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 ¬ 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<AuxInfo> const & bibtex_info,
OutputParams const & runparams)
{
bool result = false;
+
+ // Old biber is not safe. Plain bibtex is safe.
+ if (biber && isProcessorGated(runparams.bibtex_command)
+ && !checkProcessorAuth(doc_fname, runparams.bibtex_command))
+ 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 0b46c607af..012c5a0759 100644
--- a/src/LaTeX.h
+++ b/src/LaTeX.h
@@ -162,6 +162,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 const clean_start = false);
/// runs LaTeX several times
@@ -231,6 +232,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;
diff --git a/src/graphics/GraphicsConverter.cpp
b/src/graphics/GraphicsConverter.cpp
index 598e108b73..b7a59b97ac 100644
--- a/src/graphics/GraphicsConverter.cpp
+++ b/src/graphics/GraphicsConverter.cpp
@@ -30,6 +30,8 @@
#include "support/TempFile.h"
#include <sstream>
+#include <algorithm>
+#include <cctype>
#include <fstream>
using namespace std;
@@ -302,7 +304,12 @@ static void build_script(string const & doc_fname,
theConverters().getPath(from_format, to_format);
// Create a temporary base file-name for all intermediate steps.
- string const from_ext = getExtension(from_file);
+ // The extension string is user-controlled. Avoid metacharacters
+ // to prevent havoc down the pipeline.
+ string from_ext = getExtension(from_file);
+ from_ext.erase(remove_if(from_ext.begin(), from_ext.end(),
+ [](unsigned char c){ return !(isalnum(c) || c == '_' || c ==
'-'); }),
+ from_ext.end());
TempFile tempfile(addExtension("gconvertXXXXXX", from_ext));
tempfile.setAutoRemove(false);
string outfile = tempfile.name().toFilesystemEncoding();
diff --git a/src/graphics/PreviewLoader.cpp b/src/graphics/PreviewLoader.cpp
index 0d65ccf507..2714ed9254 100644
--- a/src/graphics/PreviewLoader.cpp
+++ b/src/graphics/PreviewLoader.cpp
@@ -718,7 +718,12 @@ void PreviewLoader::Impl::startLoading(bool wait)
}
cs << latexparam;
- cs << " --bibtex=" << quoteName(buffer_.params().bibtexCommand());
+
+ // --bibtex= allows document-controlled arbitrary code
+ // execution in lyxpreview_tools.py. Tradeoff when disabling
+ // it is unresolved citations inside math/ERT preview.
+ //cs << " --bibtex=" << quoteName(buffer_.params().bibtexCommand());
+
if (buffer_.params().bufferFormat() == "lilypond-book")
cs << " --lilypond";
diff --git a/src/support/FileName.cpp b/src/support/FileName.cpp
index 482bb93e21..8c3b2e8dfc 100644
--- a/src/support/FileName.cpp
+++ b/src/support/FileName.cpp
@@ -972,14 +972,20 @@ string DocFileName::mangledFileName(string const & dir)
const
// are forbidden: '/', '.', ' ', and ':'.
// On windows it is not possible to create files with '<', '>' or '?'
// in the name.
+ // We forbid ';', '=' as they coudl become active in shell.
static string const keep = "abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
- "+-0123456789;=";
+ "+-0123456789";
string::size_type pos = 0;
while ((pos = mname.find_first_not_of(keep, pos)) != string::npos)
mname[pos++] = '_';
- // Add the extension back on
- mname = support::changeExtension(mname, getExtension(name));
+ // Add the extension back on, but sanitize from metachars,
+ // it's user-controlled string.
+ string ext = getExtension(name);
+ pos = 0;
+ while ((pos = ext.find_first_not_of(keep, pos)) != string::npos)
+ ext[pos++] = '_';
+ mname = support::changeExtension(mname, ext);
// Prepend a counter to the filename. This is necessary to make
// the mangled name unique.
diff --git a/src/support/filetools.cpp b/src/support/filetools.cpp
index f2697f784b..073d0511f8 100644
--- a/src/support/filetools.cpp
+++ b/src/support/filetools.cpp
@@ -206,7 +206,7 @@ FileName const makeLatexName(FileName const & file)
// a non-latin world out there...
string const keep = "abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
- "@!'()*+,-./0123456789:;<=>?[]`|";
+ "0123456789+-._,@";
string::size_type pos = 0;
while ((pos = name.find_first_not_of(keep, pos)) != string::npos)
@@ -1164,7 +1164,22 @@ FileName const findtexfile(string const & fil, string
const & /*format*/,
// tfm - TFMFONTS, TEXFONTS
// This means that to use kpsewhich in the best possible way we
// should help it by setting additional path in the approp. envir.var.
- string const kpsecmd = "kpsewhich " + fil;
+
+ if (fil.empty())
+ return FileName();
+
+ // Wrap fil in the shell's quoting form that disables the relevant
+ // metacharacter set.
+#ifdef _WIN32
+ // Reject '"' in filename, can't be backslashed & forbidden by NTFS
anyway
+ if (fil.find('"') != string::npos)
+ return FileName();
+ // disable metacharacters
+ string const kpsecmd = "kpsewhich -- \"" + fil + "\"";
+#else
+ // disable metacharacters & escape existing '
+ string const kpsecmd = "kpsewhich -- '" + subst(fil, "'", "'\\''") +
"'";
+#endif
cmd_ret const c = runCommand(kpsecmd);
--
lyx-cvs mailing list
[email protected]
https://lists.lyx.org/mailman/listinfo/lyx-cvs