Repository: benhoyt/goawk
Pull request #19 (2019-01-19 20:31:02)

Add config options for "safe mode" to prevent exec and file reads/writes
This is useful for running scripts from untrusted user input.


Patch:
diff --git a/TODO.txt b/TODO.txt
index 997fa45..e107d4b 100644
--- a/TODO.txt
+++ b/TODO.txt
@@ -1,5 +1,3 @@
-* should interp have a configurable "safe" mode that doesn't run
-  system(), redirection, etc?
 * support "getline lvalue"
 * fix how we handle numstr type when setting $0 directly, see TODO test:
   {`BEGIN { $0="0"; print !$0 }`, "", "0\n", "", ""},
diff --git a/goawk.go b/goawk.go
index eff9ea3..bb7b356 100644
--- a/goawk.go
+++ b/goawk.go
@@ -44,7 +44,7 @@ import (
 )
 
 const (
-	version = "v1.3.0"
+	version = "v1.4.0"
 )
 
 func main() {
diff --git a/interp/functions.go b/interp/functions.go
index ff8bf9a..a91ece9 100644
--- a/interp/functions.go
+++ b/interp/functions.go
@@ -188,6 +188,9 @@ func (p *interp) callBuiltin(op Token, argExprs []Expr) (value, error) {
 		return num(prevSeed), nil
 
 	case F_SYSTEM:
+		if p.noExec {
+			return null(), newError("can't call system() due to NoExec")
+		}
 		cmdline := p.toString(args[0])
 		cmd := exec.Command("sh", "-c", cmdline)
 		stdout, err := cmd.StdoutPipe()
diff --git a/interp/interp.go b/interp/interp.go
index fefb363..e1f6482 100644
--- a/interp/interp.go
+++ b/interp/interp.go
@@ -74,6 +74,9 @@ type interp struct {
 	inputStreams  map[string]io.ReadCloser
 	outputStreams map[string]io.WriteCloser
 	commands      map[string]*exec.Cmd
+	noExec        bool
+	noFileWrites  bool
+	noFileReads   bool
 
 	// Scalars, arrays, and function state
 	globals     []value
@@ -178,6 +181,17 @@ type Config struct {
 	// Functions defined with the "function" keyword in AWK code
 	// take precedence over functions in Funcs.
 	Funcs map[string]interface{}
+
+	// Set one or more of these to true to prevent unsafe behaviours,
+	// useful when executing untrusted scripts:
+	//
+	// * NoExec prevents system calls via system() or pipe operator
+	// * NoFileWrites prevents writing to files via '>' or '>>'
+	// * NoFileReads prevents reading from files via getline or the
+	//   filenames in Args
+	NoExec       bool
+	NoFileWrites bool
+	NoFileReads  bool
 }
 
 // ExecProgram executes the parsed program using the given interpreter
@@ -212,6 +226,9 @@ func ExecProgram(program *Program, config *Config) (int, error) {
 	p.outputFieldSep = " "
 	p.outputRecordSep = "\n"
 	p.subscriptSep = "\x1c"
+	p.noExec = config.NoExec
+	p.noFileWrites = config.NoFileWrites
+	p.noFileReads = config.NoFileReads
 	err := p.initNativeFuncs(config.Funcs)
 	if err != nil {
 		return 0, err
diff --git a/interp/io.go b/interp/io.go
index 25cb70f..a77416c 100644
--- a/interp/io.go
+++ b/interp/io.go
@@ -69,6 +69,9 @@ func (p *interp) getOutputStream(redirect Token, dest Expr) (io.Writer, error) {
 	switch redirect {
 	case GREATER, APPEND:
 		// Write or append to file
+		if p.noFileWrites {
+			return nil, newError("can't write to file due to NoFileWrites")
+		}
 		flags := os.O_CREATE | os.O_WRONLY
 		if redirect == GREATER {
 			flags |= os.O_TRUNC
@@ -85,6 +88,9 @@ func (p *interp) getOutputStream(redirect Token, dest Expr) (io.Writer, error) {
 
 	case PIPE:
 		// Pipe to command
+		if p.noExec {
+			return nil, newError("can't write to pipe due to NoExec")
+		}
 		cmd := exec.Command("sh", "-c", name)
 		w, err := cmd.StdinPipe()
 		if err != nil {
@@ -127,6 +133,9 @@ func (p *interp) getInputScannerFile(name string) (*bufio.Scanner, error) {
 	if _, ok := p.inputStreams[name]; ok {
 		return p.scanners[name], nil
 	}
+	if p.noFileReads {
+		return nil, newError("can't read from file due to NoFileReads")
+	}
 	r, err := os.Open(name)
 	if err != nil {
 		return nil, newError("input redirection error: %s", err)
@@ -145,6 +154,9 @@ func (p *interp) getInputScannerPipe(name string) (*bufio.Scanner, error) {
 	if _, ok := p.inputStreams[name]; ok {
 		return p.scanners[name], nil
 	}
+	if p.noExec {
+		return nil, newError("can't read from pipe due to NoExec")
+	}
 	cmd := exec.Command("sh", "-c", name)
 	stdin, err := cmd.StdinPipe()
 	if err != nil {
@@ -375,6 +387,9 @@ func (p *interp) nextLine() (string, error) {
 					p.setFile("")
 				} else {
 					// A regular file name, open it
+					if p.noFileReads {
+						return "", newError("can't read from file due to NoFileReads")
+					}
 					input, err := os.Open(filename)
 					if err != nil {
 						return "", err


Test patch:
diff --git a/interp/interp_test.go b/interp/interp_test.go
index 8c9acf2..776a5c5 100644
--- a/interp/interp_test.go
+++ b/interp/interp_test.go
@@ -650,13 +650,16 @@ BEGIN { foo(5); bar(10) }
 
 		// Then test it in GoAWK
 		t.Run(testName, func(t *testing.T) {
-			testGoAWK(t, test.src, test.in, test.out, test.err, nil)
+			testGoAWK(t, test.src, test.in, test.out, test.err, nil, nil)
 		})
 	}
 	_ = os.Remove("out")
 }
 
-func testGoAWK(t *testing.T, src, in, out, errStr string, funcs map[string]interface{}) {
+func testGoAWK(
+	t *testing.T, src, in, out, errStr string,
+	funcs map[string]interface{}, configure func(config *interp.Config),
+) {
 	parserConfig := &parser.ParserConfig{
 		Funcs: funcs,
 	}
@@ -679,6 +682,9 @@ func testGoAWK(t *testing.T, src, in, out, errStr string, funcs map[string]inter
 		Vars:   []string{"_var", "42"},
 		Funcs:  funcs,
 	}
+	if configure != nil {
+		configure(config)
+	}
 	_, err = interp.ExecProgram(prog, config)
 	if err != nil {
 		if errStr != "" {
@@ -932,7 +938,40 @@ BEGIN { x=4; y=5; print foo(x), bar(y) }
 			testName = testName[:70]
 		}
 		t.Run(testName, func(t *testing.T) {
-			testGoAWK(t, test.src, test.in, test.out, test.err, test.funcs)
+			testGoAWK(t, test.src, test.in, test.out, test.err, test.funcs, nil)
+		})
+	}
+}
+
+func TestSafeMode(t *testing.T) {
+	tests := []struct {
+		src  string
+		in   string
+		out  string
+		err  string
+		args []string
+	}{
+		{`BEGIN { print "hi" >"out" }`, "", "", "can't write to file due to NoFileWrites", nil},
+		{`BEGIN { print "hi" >>"out" }`, "", "", "can't write to file due to NoFileWrites", nil},
+		{`BEGIN { print "hi" |"sort" }`, "", "", "can't write to pipe due to NoExec", nil},
+		{`BEGIN { getline <"in" }`, "", "", "can't read from file due to NoFileReads", nil},
+		{`$0  # no files`, "1\n2\n", "1\n2\n", "", nil},
+		{`$0  # files`, "1\n2\n", "1\n2\n", "can't read from file due to NoFileReads", []string{"f1"}},
+		{`BEGIN { "echo foo" |getline }`, "", "", "can't read from pipe due to NoExec", nil},
+		{`BEGIN { system("echo foo") }`, "", "", "can't call system() due to NoExec", nil},
+	}
+	for _, test := range tests {
+		testName := test.src
+		if len(testName) > 70 {
+			testName = testName[:70]
+		}
+		t.Run(testName, func(t *testing.T) {
+			testGoAWK(t, test.src, test.in, test.out, test.err, nil, func(config *interp.Config) {
+				config.Args = test.args
+				config.NoExec = true
+				config.NoFileWrites = true
+				config.NoFileReads = true
+			})
 		})
 	}
 }