From 3dd317d06737915c413a03feb7fb59ea3d75169e 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 [wip] This allows or prohibits shell command execution from within TeX files. TODO: Verify whether `latexmk -showextraoptions` mentions direct support for passing either flag. latexmk(1) does not, but has examples passing it via the `-pdf{lua,xe,}latex="COMMAND"` options. This would complicate the construction of the latexmk command. Fixes: #152 --- cmd/texd/main.go | 27 +++++++++++++----- tex/engine.go | 65 +++++++++++++++++++++++++++++++++++++++----- tex/engine_test.go | 68 ++++++++++++++++++++++++++++++++-------------- 3 files changed, 125 insertions(+), 35 deletions(-) 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") +}