From 5bdbbbb9a114744cf14fcf5e076e0b632db72dd9 Mon Sep 17 00:00:00 2001 From: Dominik Menke Date: Tue, 5 Nov 2024 22:29:26 +0100 Subject: [PATCH] feat: add --[no-]shell-escape flag This allows or prohibits shell command execution from within TeX files. The use case here is to allow users to download images from within the build process, e.g. compiling something like this with `lualatex`: \documentclass{article} \usepackage{luacode} \usepackage{graphicx} \begin{document} \begin{luacode*} os.execute("curl -o apod.jpg https://apod.nasa.gov/apod/") \end{luacode*} \includegraphics{apod.jpeg} \end{document} To this end, I've included `curl` into the base image. Fixes: #152 --- .github/Dockerfile.base | 1 + README.md | 13 ++++++++ cmd/texd/main.go | 27 +++++++++++----- tex/engine.go | 65 ++++++++++++++++++++++++++++++++++----- tex/engine_test.go | 68 ++++++++++++++++++++++++++++------------- 5 files changed, 139 insertions(+), 35 deletions(-) diff --git a/.github/Dockerfile.base b/.github/Dockerfile.base index 317275f..fd387bf 100644 --- a/.github/Dockerfile.base +++ b/.github/Dockerfile.base @@ -21,6 +21,7 @@ RUN <<-eot chktex \ cm-super \ context \ + curl \ dvidvi \ dvipng \ feynmf \ diff --git a/README.md b/README.md index 19c86bc..668fd61 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,19 @@ $ docker run --rm -t ghcr.io/digineo/texd:latest -h This has no effect when no image tags are given to the command line. +- `--shell-escape` and `--no-shell-escape` (Default: both omitted) + + By default, (La)TeX allows some "trusted" binaries, e.g. `bibtex` and `kpsewhich`, to be executed + during the compilation process, since these are sometimes required for packages to work. + + If you want to prohibit the execution of these programs, pass `--no-shell-escape` to `texd`. Note + that, as mentioned, some packages will stop working. + + On the other hand, if you want to allow arbitrary command execution (!), for example with + `os.execute` in `lualatex`, you may pass `--shell-escape`. Be careful, here be dragons. + + Also note that `--shell-escape` and `--no-shell-escape` are mutually exclusive. + > Note: This option listing might be outdated. Run `texd --help` to get the up-to-date listing. ## HTTP API diff --git a/cmd/texd/main.go b/cmd/texd/main.go index 609f15f..d855c0a 100644 --- a/cmd/texd/main.go +++ b/cmd/texd/main.go @@ -50,13 +50,15 @@ var opts = service.Options{ } var ( - engine = tex.DefaultEngine.Name() - jobdir = "" - pull = false - logLevel = zapcore.InfoLevel.String() - maxJobSize = units.BytesSize(float64(opts.MaxJobSize)) - storageDSN = "" - showVersion = false + engine = tex.DefaultEngine.Name() + shellEscape = false + noShellEscape = false + jobdir = "" + pull = false + logLevel = zapcore.InfoLevel.String() + maxJobSize = units.BytesSize(float64(opts.MaxJobSize)) + storageDSN = "" + showVersion = false keepJobValues = map[int][]string{ service.KeepJobsNever: {"never"}, @@ -105,6 +107,10 @@ func parseFlags(progname string, args ...string) []string { "bind `address` for the HTTP API") fs.StringVarP(&engine, "tex-engine", "X", engine, fmt.Sprintf("`name` of default TeX engine, acceptable values are: %v", tex.SupportedEngines())) + fs.BoolVarP(&shellEscape, "shell-escape", "", shellEscape, + "enable shell escaping to arbitrary commands (mutually exclusive with --no-shell-escape)") + fs.BoolVarP(&noShellEscape, "no-shell-escape", "", noShellEscape, + "enable shell escaping to arbitrary commands (mutually exclusive with --shell-escape)") fs.DurationVarP(&opts.CompileTimeout, "compile-timeout", "t", opts.CompileTimeout, "maximum rendering time") fs.IntVarP(&opts.QueueLength, "parallel-jobs", "P", opts.QueueLength, @@ -166,6 +172,13 @@ func main() { //nolint:funlen zap.String("flag", "--tex-engine"), zap.Error(err)) } + if shellEscape && noShellEscape { + log.Fatal("flags --shell-escape and --no-shell-escape are mutually exclusive") + } else if shellEscape { + _ = tex.SetShellEscaping(tex.AllowedShellEscape) + } else if noShellEscape { + _ = tex.SetShellEscaping(tex.ForbiddenShellEscape) + } if maxsz, err := units.FromHumanSize(maxJobSize); err != nil { log.Fatal("error parsing maximum job size", zap.String("flag", "--max-job-size"), diff --git a/tex/engine.go b/tex/engine.go index 40799e5..dfc12d3 100644 --- a/tex/engine.go +++ b/tex/engine.go @@ -1,6 +1,8 @@ package tex -import "fmt" +import ( + "fmt" +) type Engine struct { name string @@ -8,12 +10,22 @@ type Engine struct { } func NewEngine(name string, latexmkFlags ...string) Engine { - return Engine{name, latexmkFlags} + return Engine{name: name, flags: latexmkFlags} } -func (e Engine) Name() string { return e.name } -func (e Engine) String() string { return e.name } -func (e Engine) Flags() []string { return e.flags } +func (e Engine) Name() string { return e.name } +func (e Engine) String() string { return e.name } +func (e Engine) Flags() []string { + switch shellEscaping { + case RestrictedShellEscape: + return e.flags + case AllowedShellEscape: + return append([]string{"-shell-escape"}, e.flags...) + case ForbiddenShellEscape: + return append([]string{"-no-shell-escape"}, e.flags...) + } + panic("not reached") +} var ( engines = []Engine{ @@ -62,9 +74,9 @@ var LatexmkDefaultFlags = []string{ } // LatexmkCmd builds a command line for latexmk invocation. -func (engine Engine) LatexmkCmd(main string) []string { +func (e Engine) LatexmkCmd(main string) []string { lenDefaults := len(LatexmkDefaultFlags) - flags := engine.Flags() + flags := e.Flags() lenFlags := len(flags) cmd := make([]string, 1+lenDefaults+lenFlags+1) @@ -75,3 +87,42 @@ func (engine Engine) LatexmkCmd(main string) []string { return cmd } + +type ShellEscape int + +const ( + RestrictedShellEscape ShellEscape = iota // allows restricted command execution (e.g. bibtex) + AllowedShellEscape // allow arbitraty command execution + ForbiddenShellEscape // prohibit execution of any commands + maxShellEscape // must be last +) + +type ErrUnexpectedShellEscape ShellEscape + +func (err ErrUnexpectedShellEscape) Error() string { + return fmt.Sprintf("unexpected shell escaping value: %d", int(err)) +} + +var shellEscaping = RestrictedShellEscape + +// SetShellEscaping globally configures which external programs the TeX compiler +// is allowd to execute. By default, only a restricted set of external programs +// are allowed, such as bibtex, kpsewhich, etc. +// +// When set to [ShellEscapeAllowed], the `-shell-escape` flag is passed to +// `latexmk`. Note that this enables arbitrary command execution, and consider +// the security implications. +// +// To disable any external command execution, use [ShellEscapeForbidden]. This +// is equivalent to passing `-no-shell-escape` to `latexmk`. + +// Use [RestrictedShellEscape] to reset to the default value. +// +// Calling this with an unexpected value will return an error. +func SetShellEscaping(value ShellEscape) error { + if value < 0 || value >= maxShellEscape { + return ErrUnexpectedShellEscape(value) + } + shellEscaping = value + return nil +} diff --git a/tex/engine_test.go b/tex/engine_test.go index 8f34865..a676b61 100644 --- a/tex/engine_test.go +++ b/tex/engine_test.go @@ -4,32 +4,58 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestEngine_LatexmkCmd(t *testing.T) { - t.Parallel() + t.Cleanup(func() { shellEscaping = 0 }) const mainInput = "test.tex" - for _, tc := range []struct { - flags []string - expected []string - }{ - { - flags: nil, - expected: []string{"latexmk", "-cd", "-silent", "-pv-", "-pvc-", mainInput}, - }, { - flags: []string{}, - expected: []string{"latexmk", "-cd", "-silent", "-pv-", "-pvc-", mainInput}, - }, { - flags: []string{"-single"}, - expected: []string{"latexmk", "-cd", "-silent", "-pv-", "-pvc-", "-single", mainInput}, - }, { - flags: []string{"-multiple", "-flags"}, - expected: []string{"latexmk", "-cd", "-silent", "-pv-", "-pvc-", "-multiple", "-flags", mainInput}, - }, - } { - cmd := NewEngine("noname", tc.flags...).LatexmkCmd(mainInput) - assert.EqualValues(t, tc.expected, cmd) + for _, esc := range []ShellEscape{RestrictedShellEscape, AllowedShellEscape, ForbiddenShellEscape} { + require.NoError(t, SetShellEscaping(esc)) + + latexmk := []string{"latexmk", "-cd", "-silent", "-pv-", "-pvc-"} + shell := "restricted" + switch esc { + case RestrictedShellEscape: + // nothing to do + case AllowedShellEscape: + latexmk = append(latexmk, "-shell-escape") + shell = "allowed" + case ForbiddenShellEscape: + latexmk = append(latexmk, "-no-shell-escape") + shell = "forbidden" + } + + for name, flags := range map[string][]string{ + "nil": nil, + "empty": {}, + "single": {"-single-flag"}, + "multi": {"-multiple", "-flags"}, + } { + t.Run(shell+"_"+name, func(t *testing.T) { + expected := make([]string, 0, len(latexmk)+len(flags)+1) + expected = append(expected, latexmk...) + expected = append(expected, flags...) + expected = append(expected, mainInput) + + cmd := NewEngine("noname", flags...).LatexmkCmd(mainInput) + assert.EqualValues(t, expected, cmd) + }) + } } } + +func TestSetShellEscape(t *testing.T) { + require := require.New(t) + t.Cleanup(func() { shellEscaping = 0 }) + + require.NoError(SetShellEscaping(RestrictedShellEscape)) + require.NoError(SetShellEscaping(AllowedShellEscape)) + require.NoError(SetShellEscaping(ForbiddenShellEscape)) + + require.EqualError(SetShellEscaping(-1), "unexpected shell escaping value: -1") + require.EqualError(SetShellEscaping(maxShellEscape), "unexpected shell escaping value: 3") + require.EqualError(SetShellEscaping(maxShellEscape+1), "unexpected shell escaping value: 4") +}