>From f0c149e5926c157fb1befc83526eb518d4892af0 Mon Sep 17 00:00:00 2001 From: Pavel Sanda Date: Wed, 15 Jul 2026 13:51:00 +0200 Subject: [PATCH] Security hardening backport for LyX 2.4.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 00i document basename backtick in "..." export -> exec 00k \paperwidth/\paperheight -> parsecmd redirect export -> file write 00de processing consent gate (biber/xindy/xindex) authorization guard 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 biber reporting version >= 2.22. Assisted-by: Claude Opus 4.8 --- src/Buffer.cpp | 19 ++++- src/BufferParams.cpp | 26 ++++++- src/Converter.cpp | 31 +++++++- src/LaTeX.cpp | 152 ++++++++++++++++++++++++++++++++++++- src/LaTeX.h | 5 ++ src/graphics/GraphicsConverter.cpp | 9 ++- src/graphics/PreviewLoader.cpp | 7 +- src/support/FileName.cpp | 12 +-- src/support/filetools.cpp | 22 +++++- 9 files changed, 263 insertions(+), 20 deletions(-) diff --git a/src/Buffer.cpp b/src/Buffer.cpp index 73de572eb0..bca4a9beef 100644 --- a/src/Buffer.cpp +++ b/src/Buffer.cpp @@ -1398,12 +1398,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(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 << '\''); @@ -4595,6 +4605,9 @@ Buffer::ExportStatus Buffer::doExport(string const & target, bool put_in_tempdir filename = addName(temppath(), filename); filename = changeExtension(filename, theFormats().extension(backend_format)); + + // (00i-wide) makeLatexName keep-set is now shell-safe; no re-sanitize here. + LYXERR(Debug::FILES, "filename=" << filename); // Plain text backend diff --git a/src/BufferParams.cpp b/src/BufferParams.cpp index dcd539dfd1..04ae90c97e 100644 --- a/src/BufferParams.cpp +++ b/src/BufferParams.cpp @@ -1114,8 +1114,18 @@ string BufferParams::readToken(Lexer & lex, string const & token, isboxbgcolor = true; } 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") { @@ -3828,8 +3838,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 41bd8ce1a0..3c4130ebf1 100644 --- a/src/Converter.cpp +++ b/src/Converter.cpp @@ -497,8 +497,34 @@ Converters::RetVal Converters::convert(Buffer const * buffer, && bp.encoding().package() == Encoding::japanese; runparams.use_indices = bp.use_indices; runparams.bibtex_command = bp.bibtexCommand(true); - runparams.index_command = (bp.index_command == "default") ? - string() : bp.index_command; + + // Accept only programs from fixed known list + string accepted_index_cmd; + if (bp.index_command != "default" && !bp.index_command.empty()) { + + // Do not allow redirection in index commands + bool const has_redirect = + bp.index_command.find_first_of("<>") != string::npos; + if (!has_redirect) { + string supplied_prog; + split(bp.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 = bp.index_command; + break; + } + } + } + if (accepted_index_cmd.empty()) + LYXERR0("Document-supplied index command '" + << bp.index_command << "' is not a recognised " + "index processor; falling back to default."); + } + + runparams.index_command = accepted_index_cmd; runparams.document_language = bp.language->lang(); // Some macros rely on font encoding runparams.main_fontenc = bp.main_font_encoding(); @@ -901,6 +927,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 3225867ac2..9ff5bf8a02 100644 --- a/src/LaTeX.cpp +++ b/src/LaTeX.cpp @@ -26,6 +26,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" @@ -38,6 +41,7 @@ #include "support/os.h" #include +#include #include #include @@ -64,6 +68,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 /* @@ -108,10 +116,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, @@ -548,6 +557,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")) { @@ -753,11 +768,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 569114d369..e973300d12 100644 --- a/src/LaTeX.h +++ b/src/LaTeX.h @@ -178,6 +178,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); @@ -251,6 +252,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 34d37a4066..31e62e89c7 100644 --- a/src/graphics/GraphicsConverter.cpp +++ b/src/graphics/GraphicsConverter.cpp @@ -26,6 +26,8 @@ #include "support/TempFile.h" #include +#include +#include #include 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 be76444df5..a8b9d229cf 100644 --- a/src/graphics/PreviewLoader.cpp +++ b/src/graphics/PreviewLoader.cpp @@ -686,7 +686,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 864f38770c..7549a92283 100644 --- a/src/support/FileName.cpp +++ b/src/support/FileName.cpp @@ -996,11 +996,11 @@ string DocFileName::mangledFileName(string const & dir, bool encrypt_path) const // xHTML route // we use hash instead of counter to get stable filenames in export directory if (encrypt_path) { - // sanitization probably not neccessary for xhtml, but won't harm string sanfn = support::changeExtension(onlyFileName(), string()); sanfn = sanitizeFileName(sanfn); - // Add the extension back on - sanfn = support::changeExtension(sanfn, getExtension(onlyFileName())); + // extension is user-controlled string, suppress metacharacters + sanfn = support::changeExtension(sanfn, + sanitizeFileName(getExtension(onlyFileName()))); //various filesystems have filename limit around 2^8 if (sanfn.length() > 230) @@ -1020,8 +1020,10 @@ string DocFileName::mangledFileName(string const & dir, bool encrypt_path) const mname = support::changeExtension(name, string()); // The mangled name must be a valid LaTeX name. mname = sanitizeFileName(mname); - // 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 + mname = support::changeExtension(mname, + sanitizeFileName(getExtension(name))); // Prepend a counter to the filename. This is necessary to make // the mangled name unique, see truncation below. diff --git a/src/support/filetools.cpp b/src/support/filetools.cpp index 954e68d04b..7b6d189218 100644 --- a/src/support/filetools.cpp +++ b/src/support/filetools.cpp @@ -209,7 +209,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) @@ -1230,7 +1230,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); @@ -1366,9 +1381,10 @@ std::string sanitizeFileName(const std::string & str) // are forbidden: '/', '.', ' ', and ':'. // On windows it is not possible to create files with '<', '>' or '?' // in the name. + // We forbid ';', '=' as they could become active in shell. static std::string const keep = "abcdefghijklmnopqrstuvwxyz" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "+-0123456789;="; + "+-0123456789"; std::string name = str; string::size_type pos = 0;