Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add --[no-]shell-escape flag #153

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 (
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 @@
"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 @@
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 @@
}

// 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 @@

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")
}