Hello,
Multiple vulnerabilities were identified in the LyX document processor
(www.lyx.org)
and associated tools from the TeXLive ecosystem (biber/xindy/xindex).
Quick Summary:
1) If you are on a rolling-type of package distribution it is best to
adopt newly released LyX 2.5.2, containing all hotfixes listed below.
Although safe without it, upgrading biber to version 2.22 will avoid
newly implemented authorization prompts.
2) If you are on more conservative distro/channel we are offering backporting
patchsets for older 2.5/2.4/2.3/2.2 stable series (but we are not going to
offer new binaries except for 2.5.2). See 2.x-backport.patch files.
Although safe without it, we suggest backporting the biber dev-branch fixes
47ccd83 / 30c2a10 / 584a1e3 + 74252e6 into your biber (2.XX) AND relaxing the
version check in src/LaTeX.cpp (the last two fields are the required
major, minor version):
- { "biber", false, "--version", "version:\\s*([0-9]+)\\.([0-9]+)", 2, 22
},
+ { "biber", false, "--version", "version:\\s*([0-9]+)\\.([0-9]+)", 2, XX
},
That will avoid newly implemented authorization prompts, which would hit
large
fraction of userbase otherwise.
In the section below there is a list of so far identified issues and their
fixes.
I have exploits available upon request for distro responders if testing is
needed, but do not plan to publicly release them. I can share more detailed
notes on each issue in case someone needs it for backporting work.
I did not request any CVEs. If anyone finds it useful to assign, a single
coordinated set noted back in this thread would let everyone reference the
same identifiers.
2.5.2 tarballs are now on ftp.lyx.org. The patchsets are attached to this email;
they will also land on their git.lyx.org branches shortly.
The issues
----------
All are triggered by opening/importing/exporting a maliciously crafted LyX
document (network-deliverable: a zip, an email attachment, a shared folder).
Unless noted, the result is arbitrary command execution with the user's
privileges. No shell-escape / \write18 is required.
Load-triggered (fire on document open/import -- worst case):
-----------------------------------------------------------
00a kpsewhich a shell-metacharacter filename (e.g. a bibliography
database name) is concatenated into a
"kpsewhich <name>" shell command; also on export.
00b lyx2lyx filenames are spliced as a shell string into the
lyx2lyx converter invocation run on open.
00c graphics-extension a graphics inset's filename extension carries shell
metacharacters into an os.system() in LyX's
generated image-conversion script (the file must
exist on disk).
00h import-path the document's own filename reaches shipped
conversion
helpers; the name enters on open/import and
fires on a later export. Fix folded into 00i.
Export / View-PDF triggered (fire on a user export action):
----------------------------------------------------------
00d bibtex_command the document sets \bibtex_command to an arbitrary
program or with shell metacharacters; reaches the
preview pipeline (shell=True) and the export bibtex
call.
00e index_command same, for \index_command (arbitrary index processor
or "<" ">" redirection).
00g mangled-filename a hostile graphics filename's bytes survive
mangledFileName into re-shelling helper scripts.
00i convert-pdf a hostile .lyx basename with backticks hits command
substitution inside "..." quoting in buffer-basename
helpers (convert_pdf.py, ...).
00k paperdim raw \paperwidth / \paperheight bytes reach a
parsecmd
">" redirection -> arbitrary file truncate/overwrite
(file write, not command execution).
Layer-2 mitigation (a new consent authorization gate for unpatched external
tools):
----------------------------------------------------------------------------------
00de processing gate prompts before running a code-capable bib/index tool
(biber/xindy/xindex) on an untrusted document,
because those tools execute document-embedded code
even with the default command (biber sourcemap /ee,
xindy -M backtick, xindex CWD .lua). The real fixes
are upstream (biber 2.22, xindex 1.07); the gate is
LyX's interim guard.
00de-2 xindex check xindex-only add-on (version gate + --restricted +
CWD jail) that stacks on the gate.
Scheduled for LyX 2.5.2.
Per-branch fix status (Tier 00)
=============================================
For each fixed issue, this grid shows which LyX release lines carry the fix.
Legend
------
Y fixed; exploit and fix manually confirmed on this line
DiD shipped as defence-in-depth: the fix applies and was reviewed,
but there is no confirmed proof-of-concept on this line.
WONTFIX not fixed on this line (residual exposure noted below)
Trigger: "open" = fires on document open / import
"export" = fires on export or View-PDF (a user action)
"exec" = arbitrary command execution
"write" = arbitrary file write / truncate
master = development line (next major future release 2.6);
Tier >=01 DiD fixes land there after the maintenance-release
rollout.
Item Trigger master 2.5.2 2.5.x 2.4.x
2.3.x 2.2.x
--------------------------- ------------------- ------ ----- ----- -----
----- -----
00a kpsewhich RCE open/export -> exec Y Y Y Y
Y Y
00b lyx2lyx RCE open -> exec Y Y Y Y
Y Y [1]
00c graphics-extension RCE open -> exec Y Y Y Y
Y Y
00d bibtex_command RCE open/export -> exec Y Y [7] Y Y
Y WONTFIX [2]
00e index_command RCE export -> exec Y Y [7] Y Y
Y WONTFIX [3]
00g mangled-filename RCE export -> exec Y Y Y Y
Y Y
00h import-path RCE open/import -> exec Y Y Y Y
DiD DiD [4] [8]
00i convert-pdf RCE export -> exec Y Y Y Y
Y Y
00k paperdim file-truncate export -> write Y Y Y Y
Y Y
00de processing consent gate export -> exec Y [5] Y Y Y
Y WONTFIX [6]
00de-2 xindex version check export -> exec Y Y WONTFIX
WONTFIX WONTFIX WONTFIX
Notes
-----
[1] 2.2.x uses a slightly adapted form of the same fix.
[2] 2.2.x: the bibtex_command whitelist fix is not backported. The consent
prompt (below) that covers the underlying external-processor class is
infeasible on 2.2.x, so that class stays open there regardless; the
residual direct vector is a limited, attacker-constrained
output-redirection primitive.
[3] 2.2.x: the index_command whitelist fix is not backported; the
index-processor export RCE remains on 2.2.x.
[4] 2.3.x / 2.2.x: fix reviewed and applied defensively. The exact pre-2.4
exploit does not reproduce, so it ships without a confirmed
proof-of-concept on those lines -- but a working exploit is likely
to exist there (cold code analysis found additional live sinks of
the same class). Treat these lines as vulnerable, not clear.
[5] On master (next feature release) the consent prompt will be present but
OFF
by default -- that line targets a fixed toolchain; an opt-in preference
is retained.
[6] 2.2.x lacks the session-trust framework the consent prompt hooks into,
so the prompt is not feasible there.
[7] 2.5.2 additionally carries the Windows metacharacter widen for 00d/00e.
The backported lines (2.5 / 2.4 / 2.3) ship the POSIX filter
only -- we deliver no Windows binaries for those.
[8] 00h and 00i share a single fix: hardening makeLatexName()'s allowed
character set (00i-wide) strips the shell/cmd metacharacters at the
source, closing both the convert-pdf (00i) and import-path (00h)
sinks. There is therefore no separate 00h patch.
Note: the shell-escape hardening was intentionally not changed -- LyX follows
the host TeX configuration (restricted by default on all mainstream
distributions), which already refuses unconstrained \write18.
External tools (Tier EXT) - biber / xindy / xindex
==================================================
The processors behind the 00de consent gate run document-influenced code
with their default/whitelisted command. The gate is LyX's interim guard; the
real fixes are upstream, shipped through the tools' own channels.
There won't be separate upstream announcements for the issues below. They gain
their leverage mainly from the automatic processing pipeline of LyX, which runs
these tools without user watching the details.
biber (Perl)
------------
Issue Biber runs document-controlled Perl on the default biblatex path,
via three sinks: sourcemap /ee (ireplace), eval "require $package"
and a sortfield eval. The sourcemap payload rides in the .bcf that
biblatex writes from a \DeclareSourcemap preamble (no planted file),
or in a CWD biber.conf biber auto-loads.
Note Not strictly a .lyx issue - a crafted .tex compiled through biber
triggers it (true of xindy/xindex too).
Fixes Upstream was contacted and shipped fixes in biber 2.22 (all three
sinks).
Commits github.com/plk/biber, branch dev (landed 2026-06-27..29):
sourcemap /ee 47ccd83187 Utils.pm
require 30c2a10fd1 Biber.pm, Constants.pm
sortfield eval 584a1e3729 + 74252e608e Internals.pm (both needed)
Backport The three sink functions are byte-identical across v2.14-v2.21, so
the commits graft onto any shipped version with only line offsets.
LyX 2.5.2 and backports shipping the 00de gate relax the authorization
prompts if biber >= 2.22 detected.
If the biber fix is backported, please lower LyX's version threshold,
see the diff in TL;DR.
xindy / texindy (Perl)
----------------------
Issue The -M module value is interpolated into a Perl backtick
(`kpsewhich ... $module`) unquoted -> shell injection.
Note Absent from the frozen CTAN upstream; added downstream by a
kpsewhich texmf-tree lookup.
TeX Live utils/xindy .../xindy.in (TLpatches/patch-01-xindy-script).
Guarded form - only separator-free names reach the shell.
Fixes TL upstream was contacted and shipped the fix in TL's repo as r79990.
LyX no version-based relaxation, as no reliable version feedback exists.
xindex (texlua)
---------------
Issue xindex requires a CWD xindex-cfg.lua, executing arbitrary Lua.
This is part of xindex's intended design, but opens a hole in
LyX automatic processing.
Fixes Upstream was contacted and agreed to implement --restricted
mode of processing (chdir-away jail on require / kpse.find_file).
It shipped in xindex 1.07 via normal TeX Live / CTAN update.
LyX 2.5.2 shipping the 00de gate relaxes the authorization prompts
via 00de-2 if xindex >= 1.07 detected. 2.5/4/3 gates keep prompting.
>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Thu, 4 Jun 2026 13:20:02 +0200
Subject: [PATCH] Hardening case 00a - arbitrary command execution via
kpsewhich filename
findtexfile() runs ("kpsewhich " + fil) directly through shell without
sanitization of fil. That leaves it open to arbitrary commands injected
as filenames inside .lyx file.
Fires on .lyx load + export.
Tier 00 hotfix: pass fil quoted and after a "--" separator.
Tier 01 DiD will land in master (argv-form kpsewhich).
Assisted-by: Claude Opus 4.7
---
src/support/filetools.cpp | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/src/support/filetools.cpp b/src/support/filetools.cpp
index e0dc4e7654..823ff78728 100644
--- a/src/support/filetools.cpp
+++ b/src/support/filetools.cpp
@@ -1227,7 +1227,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);
--
2.39.5
>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Thu, 4 Jun 2026 13:20:02 +0200
Subject: [PATCH] Hardening case 00b - arbitrary command execution via lyx2lyx invocation
convertLyXFormat() builds the lyx2lyx command as a shell string and passes
the filenames through quoteName(), which leaves active characters alive.
Crafted filename on an old-format .lyx injects arbitrary commands.
Fires on .lyx load (old-format file needing conversion).
Tier 00 hotfix: single-quote the filename args on POSIX, keep quoteName on Windows.
Tier 01 DiD will land in master (argv-form lyx2lyx).
Assisted-by: Claude Opus 4.7
---
src/Buffer.cpp | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/src/Buffer.cpp b/src/Buffer.cpp
index 5471f89cfa..4466b5d131 100644
--- a/src/Buffer.cpp
+++ b/src/Buffer.cpp
@@ -1402,12 +1402,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 << '\'');
>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Mon, 8 Jun 2026 15:43:34 +0200
Subject: [PATCH] Hardening case 00c - arbitrary command execution via graphics filename extension
build_script() in GraphicsConverter takes the extension from the user-supplied
graphics filename and embeds it in temp paths interpolated into an os.system()
call in the generated Python conversion script. A hostile extension carrying
shell metacharacters injects arbitrary commands.
Fires on .lyx load (the referenced graphics file must exist on disk).
Tier 00 hotfix: strip everything but [A-Za-z0-9_-] from the extension.
Tier 01 DiD will land in master (argv-form Python).
Assisted-by: Claude Opus 4.7
---
src/graphics/GraphicsConverter.cpp | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/graphics/GraphicsConverter.cpp b/src/graphics/GraphicsConverter.cpp
index 0436f4c634..47c1445a0d 100644
--- a/src/graphics/GraphicsConverter.cpp
+++ b/src/graphics/GraphicsConverter.cpp
@@ -25,6 +25,8 @@
#include "support/TempFile.h"
#include <sstream>
+#include <algorithm>
+#include <cctype>
#include <fstream>
using namespace std;
@@ -301,7 +303,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();
>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Thu, 16 Jul 2026 19:06:25 +0200
Subject: [PATCH] Hardening case 00d - arbitrary command execution via bibtex_command
A document-set \bibtex_command reaches two sinks:
- the preview pipeline, where --bibtex= feeds lyxpreview_tools.py,
- the export bibtex call, where parsecmd extracts <> redirection (file overwrite)
(and/or injects code via metachars - win only).
Fires on export and on instant preview of a citation (possibly just load).
Tier 00 hotfix: drop --bibtex= from the preview; reject the redirection/metas.
Tier 01 DiD will land in master (canonical split).
Tier 02 strings will land in later 2.5.x (prefs hint).
Assisted-by: Claude Opus 4.8
---
src/BufferParams.cpp | 22 ++++++++++++++++++++--
src/graphics/PreviewLoader.cpp | 7 ++++++-
2 files changed, 26 insertions(+), 3 deletions(-)
diff --git a/src/BufferParams.cpp b/src/BufferParams.cpp
index 6e8abdf2ac..f98b9cdbf7 100644
--- a/src/BufferParams.cpp
+++ b/src/BufferParams.cpp
@@ -4498,8 +4498,26 @@ string const BufferParams::getBibtexCommand(string const & cmd, bool const warn)
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.
+ //
+ // Windows need wider set as it sinks to shell (unlike linux/mac).
+#if defined(_WIN32)
+ static char const * const SUSPECT_CHARS = "<>\"\\\t\n" "&|()^%;";
+#else
+ static char const * const SUSPECT_CHARS = "<>\"\\\t\n";
+#endif
+ 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/graphics/PreviewLoader.cpp b/src/graphics/PreviewLoader.cpp
index 0d185b4350..3c52bcbf79 100644
--- a/src/graphics/PreviewLoader.cpp
+++ b/src/graphics/PreviewLoader.cpp
@@ -685,7 +685,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";
>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Thu, 16 Jul 2026 19:06:25 +0200
Subject: [PATCH] Hardening case 00e - arbitrary command execution via index_command
A document-set \index_command was copied verbatim into the index-processor
call on export, giving program substitution and <> redirection (file overwrite,
same primitive as 00k), and/or code injection via metachars on windows.
Fires on export.
Tier 00 hotfix: accept only known index cmd, reject <> (+metas on win)
Tier 01 DiD will land in master (canonical split, shared with 00d).
Assisted-by: Claude Opus 4.8
---
src/Converter.cpp | 35 +++++++++++++++++++++++++++++++--
1 file changed, 33 insertions(+), 2 deletions(-)
diff --git a/src/Converter.cpp b/src/Converter.cpp
index 03172ac2ae..98f029376b 100644
--- a/src/Converter.cpp
+++ b/src/Converter.cpp
@@ -498,8 +498,39 @@ 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()) {
+
+ // Block redirection (+ metas on Windows where it sinks to shell)
+#if defined(_WIN32)
+ static char const * const SUSPECT_CHARS = "<>" "&|()^%;";
+#else
+ static char const * const SUSPECT_CHARS = "<>";
+#endif
+ bool const has_suspect =
+ bp.index_command.find_first_of(SUSPECT_CHARS) != string::npos;
+ if (!has_suspect) {
+ 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();
>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Thu, 11 Jun 2026 02:37:55 +0200
Subject: [PATCH] Hardening case 00g - arbitrary command execution via mangled filename extension
mangledFileName() sanitized the base of a copied file's name but re-attached
its extension unsanitized; the mangled name reaches shell command lines in
shipped conversion helpers on export, so a hostile graphics extension injects
arbitrary commands.
Fires on export.
Tier 00 fix: sanitize the extension too in mangledFileName(), drop ;=
Assisted-by: Claude Opus 4.7
---
src/support/FileName.cpp | 12 +++++++-----
src/support/filetools.cpp | 3 ++-
2 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/src/support/FileName.cpp b/src/support/FileName.cpp
index e49f76e90c..2b8e0243f3 100644
--- a/src/support/FileName.cpp
+++ b/src/support/FileName.cpp
@@ -990,11 +990,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)
@@ -1014,8 +1014,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 e0dc4e7654..abb4d05840 100644
--- a/src/support/filetools.cpp
+++ b/src/support/filetools.cpp
@@ -1363,9 +1363,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;
>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Sat, 13 Jun 2026 23:18:52 +0200
Subject: [PATCH] Hardening case 00i+00h - shell-safe document basename via makeLatexName
makeLatexName() left shell metacharacters in the document's own filename
(enters via Open/Import). That explodes in various converters (00h) and
a subset is alive even inside double quoting (00i).
Fires on export / View-PDF.
Tier 00 fix: Tighten the keep-set to shell-safe alphanumerics + "+-._,@"
Tier 04 fix for Unicode will land in master.
Assisted-by: Claude Opus 4.7
---
src/support/filetools.cpp | 32 +++++++++++++++++++++++++++++---
1 file changed, 29 insertions(+), 3 deletions(-)
diff --git a/src/support/filetools.cpp b/src/support/filetools.cpp
index e0dc4e7654..f6c316f5a3 100644
--- a/src/support/filetools.cpp
+++ b/src/support/filetools.cpp
@@ -195,18 +195,41 @@ string const latex_path(string const & original_path,
}
-// Substitutes spaces with underscores in filename (and path)
+// Sanitises filename for downstream conversion / helper consumers.
+//
+// CONTRACT: the basename of the returned FileName is shell-safe by
+// construction. Every output byte is in the strict `keep` allow-list
+// below (alphanumerics + "+-._,@"), so the result can flow into any
+// downstream helper script in lib/scripts/ without re-escaping, even
+// when the helper invokes a subprocess with shell=True and substitutes
+// the filename unquoted. The function is idempotent: every output byte
+// is in `keep`, so a second pass is a no-op.
+//
+// Bytes excluded from the keep-set are chosen to leave NO live POSIX
+// shell metacharacter in any quoting context:
+// - $ ` \ " special inside "..."
+// - ; | & < > ( ) { } command separators / redirections / groups
+// - * ? [ ] globs
+// - ! ~ # ' history / tilde / comment / quote
+// - whitespace (space, tab, newline)
+// Bytes preserved beyond [A-Za-z0-9]: "+-._,@" - all POSIX shell-safe
+// in every context and common in legitimate filenames. ':' is excluded
+// because it is illegal on NTFS (Windows drive separator / Alternate
+// Data Stream sigil), so preserving it would paper over a
+// cross-platform portability bug rather than helping.
+//
+// Do NOT relax this set without auditing every consumer of
+// Buffer::latexName() / makeLatexName() in lib/scripts/.
FileName const makeLatexName(FileName const & file)
{
string name = file.onlyFileName();
string const path = file.onlyPath().absFileName() + "/";
- // ok so we scan through the string twice, but who cares.
// FIXME: in Unicode time this will break for sure! There is
// 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)
--
2.39.5
>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Tue, 16 Jun 2026 16:43:40 +0200
Subject: [PATCH] Hardening case 00k - file truncation via paperwidth/paperheight
\paperwidth and \paperheight flow raw into the DVI converter/viewer command;
redirection is extracted there. That can lead to truncation of arbitrary file.
Fires on DVI export/view.
Tier 00 fix: reject invalid lengths at parse time (fail -> class default).
Assisted-by: Claude Opus 4.7
---
src/BufferParams.cpp | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/BufferParams.cpp b/src/BufferParams.cpp
--- a/src/BufferParams.cpp
+++ b/src/BufferParams.cpp
@@ -1362,10 +1362,20 @@ void BufferParams::readToken(Lexer & lex, string const & token,
}
if (token == "\\paperwidth") {
lex >> paperwidth;
+ if (!paperwidth.empty() && !isValidLength(paperwidth)) {
+ lyxerr << "Rejecting non-Length \\paperwidth value: "
+ << paperwidth << endl;
+ paperwidth.clear();
+ }
break;
}
if (token == "\\paperheight") {
lex >> paperheight;
+ if (!paperheight.empty() && !isValidLength(paperheight)) {
+ lyxerr << "Rejecting non-Length \\paperheight value: "
+ << paperheight << endl;
+ paperheight.clear();
+ }
break;
}
if (token == "\\leftmargin") {
>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
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 <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 ¬ 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 & 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;
>From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Wed, 8 Jul 2026 12:26:10 +0200
Subject: [PATCH] Hardening case 00de (xindex) - version check + --restricted jail
Stacks on the processing gate 00de: adds xindex to the processor table.
For >= 1.07 ungated + appends --restricted to the spawned command so
a hostile document cannot shadow xindex's own config/modules in CWD.
Assisted-by: Claude Opus 4.8
---
src/LaTeX.cpp | 35 +++++++++++++++++++++++++++++---
1 file changed, 33 insertions(+), 2 deletions(-)
diff --git a/src/LaTeX.cpp b/src/LaTeX.cpp
--- a/src/LaTeX.cpp
+++ b/src/LaTeX.cpp
@@ -613,6 +613,19 @@
&& !checkProcessorAuth(doc_fname, tmp))
return Systemcall::KILLED;
+ // A --restricted-capable xindex (not gated, i.e. version >= the table
+ // threshold) is run with --restricted so a document cannot shadow
+ // xindex's own modules/configs from the current directory and get code
+ // executed. This also covers a document-supplied \index_command, which
+ // replaces the whole command string. Older xindex would reject the
+ // unknown flag, hence the !isProcessorGated guard (cached above).
+ {
+ string iprog;
+ split(tmp, iprog, ' ');
+ if (onlyFileName(iprog) == "xindex" && !isProcessorGated(tmp))
+ tmp += " --restricted";
+ }
+
Language const * doc_lang = languages.getLanguage(rp.document_language);
if (contains(tmp, "$$x")) {
@@ -844,6 +857,11 @@
{ "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 },
+ // xindex: code-capable (texlua); the CWD-shadowing RCE is closed by
+ // running it with --restricted (see runMakeIndex). Versions that support
+ // that flag are not gated. PIN this threshold to the xindex release that
+ // actually ships --restricted before enabling (placeholder: 1.07).
+ { "xindex", false, "--version", "xindex version ([0-9]+)\\.([0-9]+)", 1, 7 },
};
// False only when >= required version.
@@ -871,8 +889,21 @@
break;
}
//safe because prog was matched against the table
- cmd_ret const r =
- runCommand(quoteName(prog) + ' ' + p.version_arg);
+ // Probe in a fresh empty directory, not the export dir: a
+ // texlua-based processor (xindex) executes CWD-first module loads
+ // before it prints its version, so probing where the document has
+ // written files would run planted code. createLyXTmpDir(FileName())
+ // makes a unique dir under the system temp; on failure we leave the
+ // fail-safe gated=true.
+ cmd_ret r{};
+ FileName const probe_dir = createLyXTmpDir(FileName());
+ if (!probe_dir.empty()) {
+ FileName const saved = FileName::getcwd();
+ probe_dir.chdir();
+ r = runCommand(quoteName(prog) + ' ' + p.version_arg);
+ saved.chdir();
+ probe_dir.destroyDirectory();
+ }
smatch m;
regex const re(p.version_re);
if (r.valid && regex_search(r.result, m, re)) {
>From 666758d5e16f6129ed26c023b1d6abf6351d8975 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Wed, 15 Jul 2026 14:10:29 +0200
Subject: [PATCH] Security hardening backport for LyX 2.2.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
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
00h ships as defence-in-depth on 2.2: reviewed and applied, but the 2.4 PoC
does not reproduce here (a similar PoC variant is nonetheless likely).
Assisted-by: Claude Opus 4.8
---
src/Buffer.cpp | 18 +++++++++++++++---
src/BufferParams.cpp | 10 ++++++++++
src/graphics/GraphicsConverter.cpp | 9 ++++++++-
src/support/FileName.cpp | 12 +++++++++---
src/support/filetools.cpp | 19 +++++++++++++++++--
5 files changed, 59 insertions(+), 9 deletions(-)
diff --git a/src/Buffer.cpp b/src/Buffer.cpp
index ce345bce38..f13a71fabc 100644
--- a/src/Buffer.cpp
+++ b/src/Buffer.cpp
@@ -1286,12 +1286,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.toFilesystemEncoding())
- << ' ' << quoteName(fn.toSafeFilesystemEncoding());
+ << " -o " << sh_quote(tmpfile.toFilesystemEncoding())
+ << ' ' << sh_quote(fn.toSafeFilesystemEncoding());
string const command_str = command.str();
LYXERR(Debug::INFO, "Running '" << command_str << '\'');
@@ -4182,6 +4192,8 @@ Buffer::ExportStatus Buffer::doExport(string const & target, bool put_in_tempdir
formats.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 a379e2740d..6b869fb7ab 100644
--- a/src/BufferParams.cpp
+++ b/src/BufferParams.cpp
@@ -951,8 +951,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") {
diff --git a/src/graphics/GraphicsConverter.cpp b/src/graphics/GraphicsConverter.cpp
index 4bd5cf1ef4..0ffe4840f3 100644
--- a/src/graphics/GraphicsConverter.cpp
+++ b/src/graphics/GraphicsConverter.cpp
@@ -28,6 +28,8 @@
#include "support/TempFile.h"
#include <sstream>
+#include <algorithm>
+#include <cctype>
#include <fstream>
using namespace std;
@@ -288,7 +290,12 @@ static void build_script(string const & from_file,
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/support/FileName.cpp b/src/support/FileName.cpp
index 6d40687a25..9b3620e643 100644
--- a/src/support/FileName.cpp
+++ b/src/support/FileName.cpp
@@ -944,14 +944,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 ff9ea8110a..044b3e77a2 100644
--- a/src/support/filetools.cpp
+++ b/src/support/filetools.cpp
@@ -202,7 +202,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)
@@ -1153,7 +1153,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);
>From 230dd7b32344612eada708ce462a487fb45021af Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Wed, 15 Jul 2026 13:53:39 +0200
Subject: [PATCH] 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);
>From f0c149e5926c157fb1befc83526eb518d4892af0 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
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<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 << '\'');
@@ -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 <fstream>
+#include <map>
#include <regex>
#include <stack>
@@ -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<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 ¬ 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 & 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 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 <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 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;
>From 7b2c712e942d418c06a75dc8a982975702aabce6 Mon Sep 17 00:00:00 2001
From: Pavel Sanda <[email protected]>
Date: Wed, 15 Jul 2026 13:52:29 +0200
Subject: [PATCH] Security hardening backport for LyX 2.5.x
Accumulated backport of the coordinated LyX security release for distros
pinned to an already-released 2.5.x that will not rebuild to 2.5.2. 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 backported biber 2.22.
Not included: the xindex version-check add-on (ships in the 2.5.2 release and
master only).
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 2ea6125e06..ea1934903a 100644
--- a/src/Buffer.cpp
+++ b/src/Buffer.cpp
@@ -1402,12 +1402,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 << '\'');
@@ -4641,6 +4651,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 cb14f20aba..ffbb3fc203 100644
--- a/src/BufferParams.cpp
+++ b/src/BufferParams.cpp
@@ -1362,10 +1362,20 @@ string BufferParams::readToken(Lexer & lex, string const & token,
}
if (token == "\\paperwidth") {
lex >> paperwidth;
+ if (!paperwidth.empty() && !isValidLength(paperwidth)) {
+ lyxerr << "Rejecting non-Length \\paperwidth value: "
+ << paperwidth << endl;
+ paperwidth.clear();
+ }
break;
}
if (token == "\\paperheight") {
lex >> paperheight;
+ if (!paperheight.empty() && !isValidLength(paperheight)) {
+ lyxerr << "Rejecting non-Length \\paperheight value: "
+ << paperheight << endl;
+ paperheight.clear();
+ }
break;
}
if (token == "\\leftmargin") {
@@ -4498,8 +4508,20 @@ string const BufferParams::getBibtexCommand(string const & cmd, bool const warn)
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 de2f955aa8..cdf52c027c 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();
@@ -902,6 +928,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 e22dd1eac8..1e42ca2d18 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 ¬ 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 & 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;
diff --git a/src/graphics/GraphicsConverter.cpp b/src/graphics/GraphicsConverter.cpp
index 0436f4c634..47c1445a0d 100644
--- a/src/graphics/GraphicsConverter.cpp
+++ b/src/graphics/GraphicsConverter.cpp
@@ -25,6 +25,8 @@
#include "support/TempFile.h"
#include <sstream>
+#include <algorithm>
+#include <cctype>
#include <fstream>
using namespace std;
@@ -301,7 +303,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 25dcb2fd61..7824270cce 100644
--- a/src/graphics/PreviewLoader.cpp
+++ b/src/graphics/PreviewLoader.cpp
@@ -685,7 +685,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 e49f76e90c..2b8e0243f3 100644
--- a/src/support/FileName.cpp
+++ b/src/support/FileName.cpp
@@ -990,11 +990,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)
@@ -1014,8 +1014,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 e0dc4e7654..e01d87cc00 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)
@@ -1227,7 +1227,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);
@@ -1363,9 +1378,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;