Skip to content

Commit

Permalink
feat: add --[no-]shell-escape flag
Browse files Browse the repository at this point in the history
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
  • Loading branch information
dmke committed Nov 26, 2024
1 parent d0ecbbf commit c08a977
Show file tree
Hide file tree
Showing 4 changed files with 126 additions and 35 deletions.
1 change: 1 addition & 0 deletions .github/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ RUN <<-eot
chktex \
cm-super \
context \
curl \
dvidvi \
dvipng \
feynmf \
Expand Down
27 changes: 20 additions & 7 deletions cmd/texd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
65 changes: 58 additions & 7 deletions tex/engine.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
package tex

import "fmt"
import (
"fmt"
)

type Engine struct {
name string
flags []string
}

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{
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
68 changes: 47 additions & 21 deletions tex/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

0 comments on commit c08a977

Please sign in to comment.