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 5bdbbbb
Show file tree
Hide file tree
Showing 5 changed files with 139 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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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)")

Check warning on line 113 in cmd/texd/main.go

View check run for this annotation

Codecov / codecov/patch

cmd/texd/main.go#L110-L113

Added lines #L110 - L113 were not covered by tests
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)
}

Check warning on line 181 in cmd/texd/main.go

View check run for this annotation

Codecov / codecov/patch

cmd/texd/main.go#L175-L181

Added lines #L175 - L181 were not covered by tests
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 }

Check warning on line 17 in tex/engine.go

View check run for this annotation

Codecov / codecov/patch

tex/engine.go#L16-L17

Added lines #L16 - L17 were not covered by tests
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")

Check warning on line 27 in tex/engine.go

View check run for this annotation

Codecov / codecov/patch

tex/engine.go#L27

Added line #L27 was not covered by tests
}

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 5bdbbbb

Please sign in to comment.