diff --git a/common/lua/program.go b/common/lua/program.go index 4979b6f09..d58700e5f 100644 --- a/common/lua/program.go +++ b/common/lua/program.go @@ -77,7 +77,7 @@ func (p *Program) NewState(ctx context.Context, register func(*glua.LState), val register(L) } if !p.privileged { - // Host registration needs package.preload; scripts must not access the cache or loaders. + // Hide package internals to prevent data snooping and sandbox escape. L.SetGlobal(glua.LoadLibName, glua.LNil) } L.Push(L.NewFunctionFromProto(p.proto)) @@ -119,12 +119,9 @@ func newSandboxedState() *glua.LState { } { L.SetGlobal(name, glua.LNil) } - L.GetGlobal(glua.StringLibName).(*glua.LTable).RawSetString("dump", glua.LNil) - L.GetGlobal(glua.MathLibName).(*glua.LTable).RawSetString("randomseed", glua.LNil) - // Preserve native Stringer/error output without using Lua's address-based defaults. - L.SetGlobal("tostring", L.NewFunction(func(L *glua.LState) int { - value := L.CheckAny(1) + // Prevent printing memory addresses that reveal the memory layout. + toString := func(value glua.LValue) glua.LString { result := "" switch value.Type() { case glua.LTNil, glua.LTBool, glua.LTNumber, glua.LTString: @@ -137,11 +134,36 @@ func newSandboxedState() *glua.LState { result = native.Error() } } - L.Push(glua.LString(result)) + return glua.LString(result) + } + L.SetGlobal("tostring", L.NewFunction(func(L *glua.LState) int { + L.Push(toString(L.CheckAny(1))) return 1 })) - // Capture preload before package is hidden; require() reads loaders from the registry. + // Prevent printing memory addresses that reveal the memory layout. + stringLib := L.GetGlobal(glua.StringLibName).(*glua.LTable) + stringLib.RawSetString("dump", glua.LNil) + format := stringLib.RawGetString("format") + stringLib.RawSetString("format", L.NewFunction(func(L *glua.LState) int { + nargs := L.GetTop() + for i := 2; i <= nargs; i++ { + value := L.Get(i) + switch value.Type() { + case glua.LTBool, glua.LTNumber, glua.LTString: + continue + } + L.Replace(i, toString(value)) + } + L.Insert(format, 1) + L.Call(nargs, 1) + return 1 + })) + + // Prevent seed output from compromising cryptographic security. + L.GetGlobal(glua.MathLibName).(*glua.LTable).RawSetString("randomseed", glua.LNil) + + // Prevent module injection through the global package table. module := L.GetGlobal(glua.LoadLibName).(*glua.LTable) preload := module.RawGetString("preload").(*glua.LTable) loaders := L.CreateTable(1, 0) diff --git a/common/lua/program_test.go b/common/lua/program_test.go index 4737caa89..0ffc4da82 100644 --- a/common/lua/program_test.go +++ b/common/lua/program_test.go @@ -689,6 +689,71 @@ string.__tostring = nil } } +func TestProgramSandboxStringFormat(t *testing.T) { + program := compileTestProgram(t, ` +assert(string.format == require("string").format and string.format == ("").format) +for _, format in ipairs({string.format, require("string").format, ("").format}) do + assert(format("%s %04d %.2f %t", "hello", 42, 1.25, true) == "hello 0042 1.25 true") + assert(format("%q", "hello") == '"hello"') + assert(format("%[2]s:%[1]d", 42, "hello") == "hello:42") + assert(format("%%p") == "%p") + assert(format("%%p", {}) == format("%%p", "")) + assert(format("%s", 42) == "42") + assert(format("%s", nil) == "nil") + assert(format("%s", testIP) == "192.0.2.1") + assert(format("%#v", testIP) == '"192.0.2.1"') + assert(format("%p", testIP) == format("%p", "192.0.2.1")) + assert(format("%s", testNativeStringer) == "0xdeadbeef") + assert(format("%s", testNativeError) == "lookup failed") + assert(not pcall(format)) + for _, value in ipairs({{}, function() end, format, testUserdata, testThread, testChannel}) do + assert(format("%s", value) == "") + assert(format("%v", value) == "") + assert(format("%q", value) == '""') + assert(format("%#v", value) == '""') + for _, pattern in ipairs({"%p", "%#p", "%+020p", "%[1]p", "%[1]p %#[1]v", + "%x", "%X", "%#x", "%d", "%t", "%w"}) do + local result = format(pattern, value) + assert(result == format(pattern, ""), pattern .. ": " .. result) + end + end + assert(format("%p", nil) == format("%p", "nil")) +end +assert(("%s"):format({}) == "") +`) + L, err := program.NewState(context.Background(), func(L *glua.LState) { + L.SetGlobal("testIP", luar.New(L, net.ParseIP("192.0.2.1"))) + PushUserData(L, bytes.NewBufferString("0xdeadbeef")) + L.SetGlobal("testNativeStringer", L.Get(-1)) + L.Pop(1) + PushError(L, errors.New("lookup failed")) + L.SetGlobal("testNativeError", L.Get(-1)) + L.Pop(1) + userdata := L.NewUserData() + metatable := L.NewTable() + metatable.RawSetString("__tostring", L.NewFunction(func(L *glua.LState) int { + t.Error("string.format must not call __tostring") + L.Push(glua.LString("custom")) + return 1 + })) + L.SetMetatable(userdata, metatable) + L.SetGlobal("testUserdata", userdata) + thread, cancel := L.NewThread() + if cancel != nil { + t.Cleanup(cancel) + } + L.SetGlobal("testThread", thread) + L.SetGlobal("testChannel", glua.LChannel(make(chan glua.LValue))) + }, nil) + if err != nil { + t.Fatal(err) + } + defer L.Close() + if L.GetTop() != 0 { + t.Fatalf("stack top = %d, want 0", L.GetTop()) + } +} + func TestProgramSandboxModuleFailures(t *testing.T) { program := compileTestProgram(t, ` assert(package == nil)