diff --git a/app/dns/lua.go b/app/dns/lua.go index 3c5194baf..1377f5740 100644 --- a/app/dns/lua.go +++ b/app/dns/lua.go @@ -52,7 +52,7 @@ func luaServers(s *DNS) []luaDNSServer { func registerLua(L *lua.LState, servers []luaDNSServer, client featureDNS.Client) { L.PreloadModule("xray.dns", func(L *lua.LState) int { - pushIPs := xlua.NewSlicePusher[net.IP](L) + pushIPs := xlua.NewReadOnlySlicePusher[net.IP](L) serverList := L.CreateTable(len(servers), 0) for i, client := range servers { diff --git a/app/dns/script.go b/app/dns/script.go index c465d175a..c4cae5197 100644 --- a/app/dns/script.go +++ b/app/dns/script.go @@ -30,6 +30,7 @@ func newScriptEngine(script string) (*scriptEngine, error) { func (e *scriptEngine) start(server *DNS) error { pool, err := xlua.NewPool(server.ctx, scriptExecutionTimeout, e.program.NewStateFactory( scriptExecutionTimeout*20, + 256, func(L *lua.LState) { geodata.RegisterLua(L) log.RegisterLua(L) diff --git a/app/geodata/download.go b/app/geodata/download.go index e24e3e4fd..11218e7fa 100644 --- a/app/geodata/download.go +++ b/app/geodata/download.go @@ -23,6 +23,9 @@ import ( const idleTimeout = 30 * time.Second +// DO NOT EDIT. Ignored by geodata, rejected by Lua. +const luaGuardHeader = "\x12\x01\x00" + type stage struct { target string temp string @@ -170,6 +173,10 @@ func (d *downloader) downloadOne(asset *Asset) (stage, error) { } }() + if _, err := temp.WriteString(luaGuardHeader); err != nil { + temp.Close() + return stage{}, err + } if err := d.fetch(asset.Url, temp); err != nil { temp.Close() return stage{}, err diff --git a/app/geodata/download_test.go b/app/geodata/download_test.go new file mode 100644 index 000000000..c7e225aa7 --- /dev/null +++ b/app/geodata/download_test.go @@ -0,0 +1,73 @@ +package geodata + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + commongeodata "github.com/xtls/xray-core/common/geodata" + xlua "github.com/xtls/xray-core/common/lua" + "github.com/yuin/gopher-lua/parse" + "google.golang.org/protobuf/proto" +) + +func TestDownloadOneLuaGuard(t *testing.T) { + for _, tc := range []struct { + name string + body string + code string + }{ + {"geodata", "\x0a\x03\x0a\x01A", "A"}, + {"prefixed geodata", luaGuardHeader + "\x0a\x03\x0a\x01A", "A"}, + {"Lua", "function HandleRoute() end", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("xray.location.asset", dir) + if err := os.WriteFile(filepath.Join(dir, "asset.lua"), nil, 0o600); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + d := &downloader{ctx: context.Background(), httpClient: server.Client()} + staged, err := d.downloadOne(&Asset{Url: server.URL, File: "asset.lua"}) + if err != nil { + t.Fatal(err) + } + tx, err := swapAll([]stage{staged}) + if err != nil { + t.Fatal(err) + } + if err := tx.commit(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(staged.target) + if err != nil || string(data) != luaGuardHeader+tc.body { + t.Fatalf("downloaded data = % x, %v", data, err) + } + program, err := xlua.Compile(staged.target) + var syntaxErr *parse.Error + if program != nil || !errors.As(err, &syntaxErr) || syntaxErr.Pos.Line != 1 || syntaxErr.Pos.Column != 1 { + t.Fatalf("want Lua rejection at the first byte, got %v, %v", program, err) + } + if tc.code != "" { + var list commongeodata.GeoSiteList + if err := proto.Unmarshal(data, &list); err != nil { + t.Fatal(err) + } + if len(list.Entry) != 1 || list.Entry[0].Code != tc.code { + t.Fatalf("header changed geodata: %v", &list) + } + if _, err := commongeodata.ParseDomainRules([]string{"ext:" + filepath.Base(staged.target) + ":" + tc.code}, commongeodata.Domain_Full); err != nil { + t.Fatalf("geodata loader rejected the header: %v", err) + } + } + }) + } +} diff --git a/app/router/lua.go b/app/router/lua.go index 75da27ccc..fd808f710 100644 --- a/app/router/lua.go +++ b/app/router/lua.go @@ -62,7 +62,7 @@ func (r *Router) RegisterLua(L *lua.LState) { } func registerLuaContext(L *lua.LState) { - pushIPs := xlua.NewSlicePusher[net.IP](L) + pushIPs := xlua.NewReadOnlySlicePusher[net.IP](L) attributes := L.NewTypeMetatable(luaAttributesType) L.SetField(attributes, "__index", L.NewFunction(func(L *lua.LState) int { values := L.CheckUserData(1).Value.(map[string]string) diff --git a/app/router/script.go b/app/router/script.go index 2893e0624..e2659cb87 100644 --- a/app/router/script.go +++ b/app/router/script.go @@ -31,6 +31,7 @@ func newScriptEngine(script string) (*scriptEngine, error) { func (e *scriptEngine) start(router *Router) error { pool, err := xlua.NewPool(router.ctx, scriptExecutionTimeout, e.program.NewStateFactory( scriptExecutionTimeout*20, + 256, func(L *lua.LState) { geodata.RegisterLua(L) log.RegisterLua(L) diff --git a/common/geodata/geodat_loader.go b/common/geodata/geodat_loader.go index 81d9e7e90..7c41f191d 100644 --- a/common/geodata/geodat_loader.go +++ b/common/geodata/geodat_loader.go @@ -17,12 +17,12 @@ import ( ) func checkFile(file, code string) error { - r, err := filesystem.OpenAsset(file) + r, size, err := filesystem.OpenAsset(file) if err != nil { return errors.New("failed to open ", file).Base(err) } defer r.Close() - if _, err := find(r, []byte(code), false); err != nil { + if _, err := find(r, []byte(code), false, size); err != nil { return errors.New("failed to check code ", code, " from ", file).Base(err) } return nil @@ -30,12 +30,12 @@ func checkFile(file, code string) error { func loadFile(file, code string) ([]byte, error) { runtime.GC() // peak mem - r, err := filesystem.OpenAsset(file) + r, size, err := filesystem.OpenAsset(file) if err != nil { return nil, errors.New("failed to open ", file).Base(err) } defer r.Close() - bs, err := find(r, []byte(code), true) + bs, err := find(r, []byte(code), true, size) if err != nil { return nil, errors.New("failed to load code ", code, " from ", file).Base(err) } @@ -60,13 +60,13 @@ func loadIP(file, code string) ([]*CIDR, error) { // unmarshalling it into a []*Domain, so value is only valid during fn. func loadSite(file, code, attrs string, fn func(Domain_Type, []byte)) error { runtime.GC() // peak mem - r, err := filesystem.OpenAsset(file) + r, size, err := filesystem.OpenAsset(file) if err != nil { return errors.New("failed to open ", file).Base(err) } defer r.Close() br := bufio.NewReaderSize(r, 64*1024) - n, err := seek(br, []byte(code)) + n, err := seek(br, []byte(code), size) if err != nil { return errors.New("failed to load code ", code, " from ", file).Base(err) } @@ -114,6 +114,9 @@ func decodeVarint(br *bufio.Reader) (uint64, error) { if err != nil { return 0, err } + if shift == 63 && b > 1 { + break // Only one value bit fits in the tenth byte. + } x |= (uint64(b) & 0x7F) << shift if (b & 0x80) == 0 { return x, nil @@ -123,9 +126,9 @@ func decodeVarint(br *bufio.Reader) (uint64, error) { return 0, errors.New("varint overflow") } -func find(r io.Reader, code []byte, readBody bool) ([]byte, error) { +func find(r io.Reader, code []byte, readBody bool, size int64) ([]byte, error) { br := bufio.NewReaderSize(r, 64*1024) - bodyL, err := seek(br, code) + bodyL, err := seek(br, code, size) if err != nil || !readBody { return nil, err } @@ -137,7 +140,7 @@ func find(r io.Reader, code []byte, readBody bool) ([]byte, error) { } // seek advances br to the body of the entry for code and returns the body length. -func seek(br *bufio.Reader, code []byte) (int, error) { +func seek(br *bufio.Reader, code []byte, size int64) (int, error) { codeL := len(code) if codeL == 0 { return 0, errors.New("empty code") @@ -145,7 +148,8 @@ func seek(br *bufio.Reader, code []byte) (int, error) { need := 2 + codeL // TODO: if code too long for { - if _, err := br.ReadByte(); err != nil { + tag, err := br.ReadByte() + if err != nil { return 0, err } @@ -153,10 +157,10 @@ func seek(br *bufio.Reader, code []byte) (int, error) { if err != nil { return 0, err } - bodyL := int(x) - if bodyL <= 0 { - return 0, errors.New("invalid body length: ", bodyL) + if x == 0 || x > uint64(size) || x > uint64(^uint(0)>>1) { + return 0, errors.New("invalid body length: ", x) } + bodyL := int(x) // Peek no more than the buffer holds: a code longer than the buffer cannot match a single // length byte anyway, so a short peek only skips it, as base find (io.ReadFull) does. @@ -167,7 +171,8 @@ func seek(br *bufio.Reader, code []byte) (int, error) { } return 0, err } - if bodyL >= need && len(prefix) >= need && int(prefix[1]) == codeL && bytes.Equal(prefix[2:], code) { + if tag == 0x0a && bodyL >= need && len(prefix) >= need && + prefix[0] == 0x0a && int(prefix[1]) == codeL && bytes.Equal(prefix[2:], code) { return bodyL, nil } if _, err := br.Discard(bodyL); err != nil { diff --git a/common/geodata/geodat_loader_test.go b/common/geodata/geodat_loader_test.go index b6d400294..3d1a3a192 100644 --- a/common/geodata/geodat_loader_test.go +++ b/common/geodata/geodat_loader_test.go @@ -1,6 +1,7 @@ package geodata import ( + "bytes" "fmt" "os" "path/filepath" @@ -12,6 +13,58 @@ import ( "google.golang.org/protobuf/proto" ) +func TestFindChecksEntryAndCodeTags(t *testing.T) { + for _, payload := range [][]byte{ + {0x12, 6, 0x0a, 4, 'T', 'E', 'S', 'T'}, // Unknown field posing as entry. + {0x08, 6, 0x0a, 4, 'T', 'E', 'S', 'T'}, // Wrong entry wire type. + {0x0a, 6, 0x1a, 4, 'T', 'E', 'S', 'T'}, // Unknown field posing as code. + {0x0a, 6, 0x08, 4, 'T', 'E', 'S', 'T'}, // Wrong code wire type. + } { + if _, err := find(bytes.NewReader(payload), []byte("TEST"), true, int64(len(payload))); err == nil { + t.Fatalf("matched incorrect entry/code tags in %x", payload) + } + } +} + +func TestLoadFileRejectsVarintOverflow(t *testing.T) { + payload := []byte{ + 0x0a, + 0x86, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x02, // 2^64 + 6 + 0x0a, 0x04, 'T', 'E', 'S', 'T', + } + dir := t.TempDir() + t.Setenv("xray.location.asset", dir) + if err := os.WriteFile(filepath.Join(dir, "overflow.dat"), payload, 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadFile("overflow.dat", "TEST"); err == nil || !strings.Contains(err.Error(), "varint overflow") { + t.Fatalf("want varint overflow, got %v", err) + } +} + +func TestLoadFileRejectsOversizedEntry(t *testing.T) { + payload := []byte{ + 0x0a, 0x03, 0x0a, 0x01, 'A', // A valid entry before the oversized one. + 0x0a, 0x80, 0x80, 0x80, 0x80, 0x04, // Entry length: 1 GiB. + 0x0a, 0x04, 'T', 'E', 'S', 'T', + } + dir := t.TempDir() + t.Setenv("xray.location.asset", dir) + if err := os.WriteFile(filepath.Join(dir, "oversized.dat"), payload, 0o600); err != nil { + t.Fatal(err) + } + // Check without allocating the body first, so a regression fails safely. + if err := checkFile("oversized.dat", "TEST"); err == nil { + t.Fatal("accepted an entry length larger than the file") + } + if _, err := loadFile("oversized.dat", "TEST"); err == nil || !strings.Contains(err.Error(), "invalid body length") { + t.Fatalf("want invalid body length, got %v", err) + } + if err := loadSite("oversized.dat", "TEST", "", func(Domain_Type, []byte) {}); err == nil || !strings.Contains(err.Error(), "invalid body length") { + t.Fatalf("want invalid body length, got %v", err) + } +} + type siteEntry struct { Type Domain_Type Value string diff --git a/common/geodata/lua.go b/common/geodata/lua.go index 43f7363ff..e144aa491 100644 --- a/common/geodata/lua.go +++ b/common/geodata/lua.go @@ -44,8 +44,8 @@ func RegisterLua(L *lua.LState) { "Match": luaIPMatch, "AnyMatch": luaIPAnyMatch, "Matches": luaIPMatches, - "FilterIPs": newLuaIPFilterIPs(xlua.NewSlicePusher[net.IP](L)), - }) + "FilterIPs": newLuaIPFilterIPs(xlua.NewReadOnlySlicePusher[net.IP](L)), + }, "SetReverse", "ToggleReverse") return 1 })) diff --git a/common/geodata/lua_test.go b/common/geodata/lua_test.go index f66a57bc4..fafc49c76 100644 --- a/common/geodata/lua_test.go +++ b/common/geodata/lua_test.go @@ -78,6 +78,7 @@ local geodata = require("xray.geodata") local matcher = geodata.BuildIPMatcher("127.0.0.0/8") assert(matcher.Match == matcher.match and matcher.AnyMatch == matcher.anyMatch) assert(matcher.Matches == matcher.matches and matcher.FilterIPs == matcher.filterIPs) +assert(matcher.Reload == nil and matcher.reload == nil) assert(matcher:match(ip)) assert(matcher:anyMatch({ip}) and matcher:matches({ip})) assert(not matcher:AnyMatch(nil)) @@ -97,6 +98,11 @@ assert(matcher.missing == nil) local domain = geodata.BuildDomainMatcher("full:example.com") assert(domain.Match == domain.match and domain.MatchAny == domain.matchAny) +assert(domain.Reload == nil and domain.reload == nil) +for _, value in ipairs({matcher, domain}) do + assert(not pcall(function() return -value end)) + assert(not pcall(function() return value ^ {} end)) +end assert(domain:matchAny("example.com")) assert(#domain:Match("example.com") == 1) assert(domain:match("example.com")[1] == 0) diff --git a/common/lua/geodata_security_watchdog_test.go b/common/lua/geodata_security_watchdog_test.go new file mode 100644 index 000000000..07e9b991c --- /dev/null +++ b/common/lua/geodata_security_watchdog_test.go @@ -0,0 +1,19 @@ +// DO NOT EDIT. Regression test for app/geodata's luaGuardHeader. + +package lua + +import ( + "errors" + "strings" + "testing" + + "github.com/yuin/gopher-lua/parse" +) + +func TestGeodataLuaGuard(t *testing.T) { + _, err := parse.Parse(strings.NewReader("\x12\x01\x00function HandleRoute() end"), "geodata") + var syntaxErr *parse.Error + if !errors.As(err, &syntaxErr) || syntaxErr.Pos.Line != 1 || syntaxErr.Pos.Column != 1 { + t.Fatalf("want Lua rejection at the first byte, got %v", err) + } +} diff --git a/common/lua/luar.go b/common/lua/luar.go index 072ee663f..4ea4aeaf4 100644 --- a/common/lua/luar.go +++ b/common/lua/luar.go @@ -1,6 +1,9 @@ package lua import ( + "net" + "reflect" + glua "github.com/yuin/gopher-lua" luar "layeh.com/gopher-luar" ) @@ -9,7 +12,7 @@ import ( // The returned function wraps slices without reflection or metatable lookup, // and pushes nil for nil slices. Use it with this state or its coroutines. func NewSlicePusher[T any](L *glua.LState) func(*glua.LState, []T) { - metatable := luar.New(L, []T{}).(*glua.LUserData).Metatable + metatable := luar.MT(L, []T(nil)).LTable return func(L *glua.LState, values []T) { if values == nil { L.Push(glua.LNil) @@ -22,21 +25,105 @@ func NewSlicePusher[T any](L *glua.LState) func(*glua.LState, []T) { } } +// NewReadOnlySlicePusher borrows slices without copying and recursively makes +// nested slice types read-only through their shared metatables. IP byte views +// and masks are also read-only. Other referenced elements retain their mutability. +// Element bindings are initialized on first indexing or iteration. +func NewReadOnlySlicePusher[T any](L *glua.LState) func(*glua.LState, []T) { + sliceMetatable := luar.MT(L, []T{}) + if sliceMetatable.RawGetString("__readonly") != glua.LTrue { + readOnly := L.NewFunction(func(L *glua.LState) int { + L.RaiseError("slice is read-only") + return 0 + }) + setSliceReadOnly[[]T](L, readOnly) + } + return NewSlicePusher[T](L) +} + +func setSliceReadOnly[S ~[]E, E any](L *glua.LState, readOnly *glua.LFunction) { + var visit func(reflect.Type) + visit = func(typ reflect.Type) { + if typ.Kind() != reflect.Slice { + return + } + metatable := luar.MT(L, reflect.Zero(typ).Interface()) + if metatable.RawGetString("__readonly") == glua.LTrue { + return + } + // Mark before descending because named slice types can be recursive. + metatable.RawSetString("__readonly", glua.LTrue) + metatable.RawSetString("__newindex", readOnly) + // Reject explicitly so Lua cannot fall back to the other operand. + metatable.RawSetString("__add", readOnly) + if typ == reflect.TypeFor[net.IP]() { + // A nil receiver lets AppendText return its argument as a writable []byte. + methods := metatable.RawGetString("methods").(*glua.LTable) + appendText := methods.RawGetString("AppendText") + for key, method := methods.Next(glua.LNil); key != glua.LNil; key, method = methods.Next(key) { + if method == appendText { + methods.RawSet(key, glua.LNil) + } + } + visit(reflect.TypeFor[net.IPMask]()) + } + if element := typ.Elem(); element.Kind() == reflect.Slice { + index := metatable.RawGetString("__index").(*glua.LFunction) + call := metatable.RawGetString("__call").(*glua.LFunction) + initialize := func() { + visit(element) + metatable.RawSetString("__index", index) + metatable.RawSetString("__call", call) + } + metatable.RawSetString("__index", L.NewFunction(func(L *glua.LState) int { + initialize() + return index.GFunction(L) + })) + metatable.RawSetString("__call", L.NewFunction(func(L *glua.LState) int { + initialize() + return call.GFunction(L) + })) + } + } + visit(reflect.TypeFor[S]()) +} + // DirectMethod handles a Lua call without luar's reflected method invocation. // It returns the result count and whether it handled the arguments. On false, // it must leave the stack unchanged for the original luar wrapper. type DirectMethod func(L *glua.LState) (nresults int, handled bool) +// PushMethods pushes a luar userdata exposing only the named Go methods and +// their aliases through reflected calls. The method list is fixed by the first +// binding of that Go type in each LState. +func PushMethods(L *glua.LState, value any, methods ...string) { + PushWithDirectMethods(L, value, nil, methods...) +} + // PushWithDirectMethods pushes a luar userdata with typed Go method bindings. // Handled calls bypass luar's argument conversion and reflect.Call; method lookup // uses the methods table directly instead of luar's reflected __index handler. -// value must expose methods only. Bindings and their closures are installed once -// per Go type per LState, outside the method-call hot path. -func PushWithDirectMethods(L *glua.LState, value any, directMethods map[string]DirectMethod) { +// Only directMethods and additionalMethods are exposed, including their aliases. +// Bindings and their closures are installed once per Go type per LState, outside +// the method-call hot path. +func PushWithDirectMethods(L *glua.LState, value any, directMethods map[string]DirectMethod, additionalMethods ...string) { userdata := luar.New(L, value).(*glua.LUserData) metatable := userdata.Metatable.(*glua.LTable) methods := metatable.RawGetString("methods").(*glua.LTable) if metatable.RawGetString("__index") != methods { + // Keep all aliases for the explicitly exposed methods. + exposed := make(map[glua.LValue]bool, len(directMethods)+len(additionalMethods)) + for name := range directMethods { + exposed[methods.RawGetString(name)] = true + } + for _, name := range additionalMethods { + exposed[methods.RawGetString(name)] = true + } + for key, method := methods.Next(glua.LNil); key != glua.LNil; key, method = methods.Next(key) { + if !exposed[method] { + methods.RawSet(key, glua.LNil) + } + } for name, direct := range directMethods { original := methods.RawGetString(name) fn := L.NewFunction(func(L *glua.LState) int { @@ -52,6 +139,10 @@ func PushWithDirectMethods(L *glua.LState, value any, directMethods map[string]D } } } + // Pointer operations would bypass the method-only API. + metatable.RawSetString("__newindex", glua.LNil) + metatable.RawSetString("__pow", glua.LNil) + metatable.RawSetString("__unm", glua.LNil) metatable.RawSetString("__index", methods) } L.Push(userdata) diff --git a/common/lua/luar_test.go b/common/lua/luar_test.go index cad82b6c6..15ba4c4dd 100644 --- a/common/lua/luar_test.go +++ b/common/lua/luar_test.go @@ -2,6 +2,7 @@ package lua import ( "net" + "reflect" "testing" glua "github.com/yuin/gopher-lua" @@ -47,6 +48,188 @@ assert(ok and result == true) } } +func TestReadOnlySlicePusher(t *testing.T) { + L := glua.NewState() + defer L.Close() + values := []int{3, 5} + NewReadOnlySlicePusher[int](L)(L, values) + L.SetGlobal("values", L.Get(-1)) + L.Pop(1) + if err := L.DoString(` +assert(#values == 2 and values[1] == 3 and values[2] == 5) +assert(not pcall(function() values[1] = 7 end)) +assert(not pcall(function() return values + 7 end)) +local sum = 0 +for _, value in values() do sum = sum + value end +assert(sum == 8) +`); err != nil { + t.Fatal(err) + } + got := L.GetGlobal("values").(*glua.LUserData).Value.([]int) + if &got[0] != &values[0] || values[0] != 3 || values[1] != 5 { + t.Fatal("read-only binding copied or modified the slice") + } +} + +func TestReadOnlyIPSlicePusher(t *testing.T) { + L := glua.NewState() + defer L.Close() + ips := []net.IP{{192, 0, 2, 1}, net.ParseIP("2001:db8::1")} + NewReadOnlySlicePusher[net.IP](L)(L, ips) + L.SetGlobal("ips", L.Get(-1)) + L.Pop(1) + readOnly := luar.MT(L, ips).RawGetString("__newindex") + NewReadOnlySlicePusher[net.IP](L) + if luar.MT(L, ips).RawGetString("__newindex") != readOnly { + t.Fatal("IP metatables were initialized more than once") + } + if err := L.DoString(` +local function readOnly(value) + assert(not pcall(function() value[1] = value[1] end)) + assert(not pcall(function() return value + value[1] end)) +end +assert(#ips == 2 and ips[1]:String() == "192.0.2.1") +readOnly(ips) +for _, ip in ips() do + readOnly(ip) + readOnly(ip:To16()) + if ip:To4() then + readOnly(ip:To4()) + readOnly(ip:DefaultMask()) + end + assert(ip:Equal(ip:To16()) and type(ip:String()) == "string") + assert(ip.AppendText == nil and ip.appendText == nil) + local text, err = ip:MarshalText() + assert(not err) + text[1] = 88 -- Independently owned byte buffers remain writable. + assert(not pcall(function() return ips + text end)) +end +`); err != nil { + t.Fatal(err) + } + got := L.GetGlobal("ips").(*glua.LUserData).Value.([]net.IP) + if &got[0] != &ips[0] || &got[0][0] != &ips[0][0] || &got[1][0] != &ips[1][0] { + t.Fatal("read-only binding copied the IP data") + } + for i, want := range []string{"192.0.2.1", "2001:db8::1"} { + if got[i].String() != want { + t.Fatalf("IP %d changed to %s; want %s", i, got[i], want) + } + } +} + +func TestReadOnlyIPSlicePusherLazy(t *testing.T) { + for _, tc := range []struct{ name, access string }{ + {"index", `local ip = ips[1]`}, + {"iterate", `local _, ip = ips()()`}, + {"coroutine", `local co = coroutine.create(function() return ips[1] end); local ok, ip = coroutine.resume(co); assert(ok)`}, + } { + t.Run(tc.name, func(t *testing.T) { + L := glua.NewState() + defer L.Close() + methods := 0 + luar.GetConfig(L).MethodNames = func(_ reflect.Type, method reflect.Method) []string { + methods++ + return []string{method.Name} + } + NewReadOnlySlicePusher[net.IP](L)(L, []net.IP{{192, 0, 2, 1}}) + L.SetGlobal("ips", L.Get(-1)) + L.Pop(1) + if err := L.DoString(`assert(#ips == 1); assert(not pcall(function() ips[1] = nil end))`); err != nil { + t.Fatal(err) + } + if methods != 0 { + t.Fatal("IP methods initialized without reading an element") + } + source := tc.access + ` +assert(ip:String() == "192.0.2.1" and ip.AppendText == nil) +assert(not pcall(function() ip[1] = 0 end)) +assert(not pcall(function() ip:To4()[1] = 0 end)) +assert(not pcall(function() ip:To16()[1] = 0 end)) +assert(not pcall(function() ip:DefaultMask()[1] = 0 end)) +` + if err := L.DoString(source); err != nil { + t.Fatal(err) + } + initialized := methods + if initialized == 0 { + t.Fatal("IP access did not initialize its methods") + } + if err := L.DoString(source); err != nil { + t.Fatal(err) + } + if methods != initialized { + t.Fatal("IP methods initialized more than once") + } + }) + } +} + +func TestReadOnlyNestedSlicePusher(t *testing.T) { + L := glua.NewState() + defer L.Close() + values := [][][]byte{{{3, 5}}} + NewReadOnlySlicePusher[[][]byte](L)(L, values) + L.SetGlobal("values", L.Get(-1)) + L.Pop(1) + ips := [][]net.IP{{{192, 0, 2, 1}}} + NewReadOnlySlicePusher[[]net.IP](L)(L, ips) + L.SetGlobal("ips", L.Get(-1)) + L.Pop(1) + if err := L.DoString(` +local function readOnly(value) + assert(not pcall(function() value[1] = value[1] end)) + assert(not pcall(function() return value + value[1] end)) +end +assert(values[1][1][1] == 3 and values[1][1][2] == 5) +readOnly(values) +for _, group in values() do + readOnly(group) + for _, bytes in group() do readOnly(bytes) end +end +readOnly(ips) +readOnly(ips[1]) +local ip = ips[1][1] +assert(ip:String() == "192.0.2.1") +readOnly(ip) +readOnly(ip:To4()) +readOnly(ip:To16()) +readOnly(ip:DefaultMask()) +assert(ip.AppendText == nil and ip.appendText == nil) +`); err != nil { + t.Fatal(err) + } + readOnly := luar.MT(L, ips[0]).RawGetString("__newindex") + NewReadOnlySlicePusher[net.IP](L) + if luar.MT(L, ips[0]).RawGetString("__newindex") != readOnly { + t.Fatal("nested slice metatable was initialized again") + } + got := L.GetGlobal("values").(*glua.LUserData).Value.([][][]byte) + if &got[0][0][0] != &values[0][0][0] || values[0][0][0] != 3 || values[0][0][1] != 5 { + t.Fatal("read-only binding copied or modified the nested slice") + } +} + +type recursiveReadOnlySlice []recursiveReadOnlySlice + +func TestReadOnlyRecursiveSlicePusher(t *testing.T) { + L := glua.NewState() + defer L.Close() + values := make(recursiveReadOnlySlice, 1) + values[0] = values + NewReadOnlySlicePusher[recursiveReadOnlySlice](L)(L, values) + L.SetGlobal("values", L.Get(-1)) + L.Pop(1) + if err := L.DoString(` +local nested = values[1][1][1] +assert(#nested == 1) +assert(not pcall(function() nested[1] = nested end)) +assert(not pcall(function() return nested + nested end)) +`); err != nil { + t.Fatal(err) + } +} + func TestSlicePusherMetatablePerState(t *testing.T) { first := glua.NewState() defer first.Close() diff --git a/common/lua/privileged.go b/common/lua/privileged.go new file mode 100644 index 000000000..6a0abf95c --- /dev/null +++ b/common/lua/privileged.go @@ -0,0 +1,117 @@ +package lua + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "sync" + + "github.com/xtls/xray-core/common/errors" +) + +var privilegedKey struct { + sync.Once + publicKey ed25519.PublicKey +} + +// SetPrivilegedPublicKey configures the trusted Ed25519 public key once at +// startup, before compiling scripts. An empty value disables authorization. +// Nonempty values must be unpadded Base64URL encodings of exactly 32 bytes. +func SetPrivilegedPublicKey(value string) error { + var publicKey ed25519.PublicKey + if value != "" { + decoded, err := decodePrivilegedValue(value, ed25519.PublicKeySize) + if err != nil { + return errors.New("invalid Lua privileged public key").Base(err) + } + publicKey = ed25519.PublicKey(decoded) + } + privilegedKey.Do(func() { + privilegedKey.publicKey = publicKey + }) + return nil +} + +func decodePrivilegedValue(value string, size int) ([]byte, error) { + encoding := base64.RawURLEncoding.Strict() + if len(value) != encoding.EncodedLen(size) { + return nil, errors.New("expected an unpadded Base64URL encoding of ", size, " bytes") + } + decoded, err := encoding.DecodeString(value) + if err != nil || len(decoded) != size || encoding.EncodeToString(decoded) != value { + return nil, errors.New("expected an unpadded Base64URL encoding of ", size, " bytes") + } + return decoded, nil +} + +func verifyPrivilegedSignature(code []byte) (bool, error) { + _, end, value, found := privilegedSignatureLine(code) + if !found { + return false, nil + } + + publicKey := privilegedKey.publicKey + if len(publicKey) == 0 { + return false, errors.New("Lua privileged signature requires --lua-privileged-pubkey") + } + signature, err := decodePrivilegedValue(string(bytes.TrimSpace(value)), ed25519.SignatureSize) + if err != nil { + return false, errors.New("invalid Lua privileged signature").Base(err) + } + digest := sha256.Sum256(bytes.TrimSpace(code[end:])) + if !ed25519.Verify(publicKey, digest[:], signature) { + return false, errors.New("Lua privileged signature verification failed") + } + return true, nil +} + +// SignPrivilegedScript signs the script body, then prepends or replaces the signature line. +func SignPrivilegedScript(code []byte, seed string) ([]byte, error) { + decoded, err := decodePrivilegedValue(seed, ed25519.SeedSize) + if err != nil { + return nil, errors.New("invalid Ed25519 private key seed").Base(err) + } + + start, end, _, found := privilegedSignatureLine(code) + body := code + if found { + body = code[end:] + } + + digest := sha256.Sum256(bytes.TrimSpace(body)) + signature := ed25519.Sign(ed25519.NewKeyFromSeed(decoded), digest[:]) + header := []byte("-- @xray:privileged-signature=" + base64.RawURLEncoding.EncodeToString(signature)) + + if !found { + if newline := bytes.IndexByte(code, '\n'); newline > 0 && code[newline-1] == '\r' { + header = append(header, '\r') + } + header = append(header, '\n') + return append(header, code...), nil + } + signed := make([]byte, 0, start+len(header)+len(code)-end) + signed = append(signed, code[:start]...) + signed = append(signed, header...) + return append(signed, code[end:]...), nil +} + +func privilegedSignatureLine(code []byte) (start, end int, value []byte, found bool) { + for rest := code; len(rest) != 0; { + start = len(code) - len(rest) + var line []byte + line, rest, _ = bytes.Cut(rest, []byte{'\n'}) + line = bytes.TrimSuffix(line, []byte{'\r'}) + if len(bytes.TrimSpace(line)) == 0 { + continue + } + end = start + len(line) + line, found := bytes.CutPrefix(bytes.TrimLeft(line, " \t"), []byte("--")) + if !found { + return 0, 0, nil, false + } + value, found = bytes.CutPrefix(bytes.TrimLeft(line, " \t"), []byte("@xray:privileged-signature=")) + return start, end, value, found + } + return 0, 0, nil, false +} diff --git a/common/lua/privileged_sign_test.go b/common/lua/privileged_sign_test.go new file mode 100644 index 000000000..1ed12767c --- /dev/null +++ b/common/lua/privileged_sign_test.go @@ -0,0 +1,85 @@ +package lua + +// TODO: Re-enable these tests and their helper when Compile supports privileged signature authorization again. +/* +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "strings" + "testing" +) + +func privilegedSeed(t *testing.T) string { + t.Helper() + seed, err := hex.DecodeString(privilegedTestSeed) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(seed) +} + +func TestSignPrivilegedScriptPreservesSource(t *testing.T) { + setTestPrivilegedPublicKey(t, privilegedTestPublicKey) + seed := privilegedSeed(t) + for _, tc := range []struct { + name, prefix, header, body, newline string + }{ + {name: "unsigned", body: privilegedFixtureBody, newline: "\n"}, + {name: "unsigned boundary whitespace", body: " \t\n\n" + privilegedFixtureBody + " \t\n", newline: "\n"}, + {name: "unsigned CRLF", body: strings.ReplaceAll(privilegedFixtureBody, "\n", "\r\n"), newline: "\r\n"}, + {name: "replace invalid header", header: privilegedSignatureHeader + "%", body: "\n" + privilegedFixtureBody, newline: "\n"}, + {name: "replace indented header", prefix: " \t\r\n\n", header: " \t--\t @xray:privileged-signature=% \t", body: "\r\n \t\r\n" + privilegedFixtureBody + "\r\n", newline: "\r\n"}, + {name: "replace valid header", header: privilegedSignatureHeader + privilegedFixtureSignature, body: "\n" + privilegedFixtureBody, newline: "\n"}, + {name: "empty script", newline: "\n"}, + {name: "header without newline", header: privilegedSignatureHeader + "%"}, + {name: "ordinary comment before marker", body: "-- ordinary comment\n" + privilegedSignatureHeader + "%\nvalue = 1", newline: "\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + code := []byte(tc.prefix + tc.header + tc.body) + original := bytes.Clone(code) + signed, err := SignPrivilegedScript(code, seed) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(code, original) { + t.Fatal("signing modified the input buffer") + } + body := tc.body + if tc.header != "" { + body = strings.TrimPrefix(body, tc.newline) + } + header := privilegedSignatureHeader + testSignature(t, body) + want := header + tc.newline + tc.body + if tc.header != "" { + want = tc.prefix + header + tc.body + } + if string(signed) != want { + t.Fatalf("signed source = %q, want %q", signed, want) + } + program, err := Compile(writeTestScript(t, string(signed))) + if err != nil || program == nil || !program.privileged { + t.Fatalf("signed source Compile = %v, %v; want privileged program", program, err) + } + again, err := SignPrivilegedScript(signed, seed) + if err != nil || !bytes.Equal(again, signed) { + t.Fatalf("resigning changed the source: %q, %v", again, err) + } + }) + } +} + +func TestSignPrivilegedScriptRejectsInvalidSeed(t *testing.T) { + seed := privilegedSeed(t) + for _, value := range []string{ + "", "%", seed + "=", + base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.SeedSize-1)), + base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.PrivateKeySize)), + } { + if signed, err := SignPrivilegedScript([]byte(privilegedFixtureBody), value); signed != nil || err == nil { + t.Fatalf("SignPrivilegedScript with invalid seed = %q, %v; want rejection", signed, err) + } + } +} +*/ diff --git a/common/lua/program.go b/common/lua/program.go index f37e91d50..57b950cee 100644 --- a/common/lua/program.go +++ b/common/lua/program.go @@ -3,11 +3,14 @@ package lua import ( "bytes" "context" + "fmt" "os" + "regexp" "strings" "time" "github.com/xtls/xray-core/common/base64" + "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/platform" glua "github.com/yuin/gopher-lua" "github.com/yuin/gopher-lua/parse" @@ -15,7 +18,8 @@ import ( // Program holds immutable bytecode that can be run by independent LStates. type Program struct { - proto *glua.FunctionProto + proto *glua.FunctionProto + privileged bool } // LStateFactory returns a fully initialized state or nil and an error. @@ -27,10 +31,17 @@ func Compile(script string) (*Program, error) { var code []byte var err error name := "[inline]" + privileged := false if strings.HasPrefix(script, base64.GzipPrefix) || strings.HasPrefix(script, base64.Prefix) { + // TODO: Restore inline script support and re-enable the related tests. + return nil, errors.New("currently unavailable") code, err = base64.Decode(script) } else if name, err = platform.ResolveLuaFile(script); err == nil { code, err = os.ReadFile(name) + // TODO: Restore privileged signature authorization and re-enable the related tests and CLI entries. + // if err == nil { + // privileged, err = verifyPrivilegedSignature(code) + // } } if err != nil { return nil, err @@ -44,14 +55,26 @@ func Compile(script string) (*Program, error) { if err != nil { return nil, err } - return &Program{proto: proto}, nil + return &Program{proto: proto, privileged: privileged}, nil } // NewState creates a state, runs register, executes the program under ctx, and // runs validate. It removes the initialization context before returning a state // owned by the caller. -func (p *Program) NewState(ctx context.Context, register func(*glua.LState), validate func(*glua.LState) error) (*glua.LState, error) { - L := glua.NewState() +// Choose registrySize for the script type's expected stack usage, balancing +// per-state memory usage against the number of states retained in a pool. +func (p *Program) NewState(ctx context.Context, registrySize int, register func(*glua.LState), validate func(*glua.LState) error) (*glua.LState, error) { + options := glua.Options{ + RegistrySize: registrySize, + RegistryMaxSize: glua.RegistrySize, + RegistryGrowStep: registrySize, + } + var L *glua.LState + if p.privileged { + L = glua.NewState(options) + } else { + L = newSandboxedState(options) + } valid := false defer func() { if !valid { @@ -63,6 +86,10 @@ func (p *Program) NewState(ctx context.Context, register func(*glua.LState), val if register != nil { register(L) } + if !p.privileged { + // Hide package internals to prevent data snooping and sandbox escape. + L.SetGlobal(glua.LoadLibName, glua.LNil) + } L.Push(L.NewFunctionFromProto(p.proto)) // Execute the Lua script's top level. if err := L.PCall(0, 0, nil); err != nil { @@ -77,11 +104,157 @@ func (p *Program) NewState(ctx context.Context, register func(*glua.LState), val return L, nil } +func newSandboxedState(options glua.Options) *glua.LState { + options.SkipOpenLibs = true + L := glua.NewState(options) + + for _, lib := range []struct { + name string + open glua.LGFunction + }{ + {glua.LoadLibName, glua.OpenPackage}, + {glua.BaseLibName, glua.OpenBase}, + {glua.TabLibName, glua.OpenTable}, + {glua.StringLibName, glua.OpenString}, + {glua.MathLibName, glua.OpenMath}, + {glua.OsLibName, glua.OpenOs}, + } { + L.Push(L.NewFunction(lib.open)) + L.Push(glua.LString(lib.name)) + L.Call(1, 0) + } + + for _, name := range []string{ + "dofile", "loadfile", "load", "loadstring", "getfenv", "setfenv", + "getmetatable", "setmetatable", "rawget", "rawset", + "collectgarbage", "module", "newproxy", "print", "_printregs", + } { + L.SetGlobal(name, glua.LNil) + } + + // 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: + result = value.String() + case glua.LTUserData: + switch native := value.(*glua.LUserData).Value.(type) { + case error: // Errors can also implement fmt.Stringer; redact them first. + result = redactMemoryAddresses(native.Error()) + case fmt.Stringer: + result = native.String() + } + } + return glua.LString(result) + } + L.SetGlobal("tostring", L.NewFunction(func(L *glua.LState) int { + L.Push(toString(L.CheckAny(1))) + return 1 + })) + + // 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 + })) + + // Redact addresses before caught errors become readable by the script. + redactError := func(value glua.LValue) glua.LValue { + if message, ok := value.(glua.LString); ok { + if redacted := redactMemoryAddresses(string(message)); redacted != string(message) { + return glua.LString(redacted) + } + } + return value + } + for _, name := range []string{"pcall", "xpcall"} { + call := L.GetGlobal(name) + L.SetGlobal(name, L.NewFunction(func(L *glua.LState) int { + nargs := L.GetTop() + if name == "xpcall" { + L.CheckFunction(1) + handler := L.CheckFunction(2) + L.Replace(2, L.NewFunction(func(L *glua.LState) int { + L.Replace(1, redactError(L.Get(1))) + L.Insert(handler, 1) + L.Call(1, 1) + return 1 + })) + } + L.Insert(call, 1) + L.Call(nargs, glua.MultRet) + if L.Get(1) == glua.LFalse { + L.Replace(2, redactError(L.Get(2))) + } + return L.GetTop() + })) + } + + // Prevent seed output from compromising cryptographic security. + L.GetGlobal(glua.MathLibName).(*glua.LTable).RawSetString("randomseed", glua.LNil) + + // Keep only time functions, including when os is accessed through require. + osLib := L.GetGlobal(glua.OsLibName).(*glua.LTable) + for key, _ := osLib.Next(glua.LNil); key != glua.LNil; key, _ = osLib.Next(key) { + switch key { + case glua.LString("clock"), glua.LString("date"), glua.LString("difftime"), glua.LString("time"): + default: + osLib.RawSet(key, 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) + loaders.RawSetInt(1, L.NewFunction(func(L *glua.LState) int { + name := L.CheckString(1) + loader := preload.RawGetString(name) + if loader == glua.LNil { + loader = glua.LString("no preloaded module: " + name) + } + L.Push(loader) + return 1 + })) + module.RawSetString("loaders", loaders) + L.SetField(L.Get(glua.RegistryIndex), "_LOADERS", loaders) + for _, name := range []string{"loadlib", "seeall", "path", "cpath", "config"} { + module.RawSetString(name, glua.LNil) + } + + return L +} + +var memoryAddressPattern = regexp.MustCompile(`0[xX][0-9a-fA-F]+`) + +func redactMemoryAddresses(message string) string { + if !strings.Contains(message, "0x") && !strings.Contains(message, "0X") { + return message + } + return memoryAddressPattern.ReplaceAllString(message, "
") +} + // NewStateFactory returns a factory that gives each state an initialization timeout. -func (p *Program) NewStateFactory(initTimeout time.Duration, register func(*glua.LState), validate func(*glua.LState) error) LStateFactory { +// Choose registrySize for the script type's expected stack usage, balancing +// per-state memory usage against the number of states retained in a pool. +func (p *Program) NewStateFactory(initTimeout time.Duration, registrySize int, register func(*glua.LState), validate func(*glua.LState) error) LStateFactory { return func(ctx context.Context) (*glua.LState, error) { initCtx, cancel := context.WithTimeout(ctx, initTimeout) defer cancel() - return p.NewState(initCtx, register, validate) + return p.NewState(initCtx, registrySize, register, validate) } } diff --git a/common/lua/program_test.go b/common/lua/program_test.go index 0465f7a15..fbd34de19 100644 --- a/common/lua/program_test.go +++ b/common/lua/program_test.go @@ -2,24 +2,24 @@ package lua import ( "bytes" - "compress/gzip" "context" - "encoding/base64" "errors" + "fmt" + "net" "os" "path/filepath" + "runtime" "strings" "testing" + "weak" - xbase64 "github.com/xtls/xray-core/common/base64" + xerrors "github.com/xtls/xray-core/common/errors" glua "github.com/yuin/gopher-lua" + luar "layeh.com/gopher-luar" ) func TestProgramStatesAreIndependent(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.lua") - if err := os.WriteFile(path, []byte("value = (value or 0) + 1"), 0o600); err != nil { - t.Fatal(err) - } + path := writeTestScript(t, "value = (value or 0) + 1") program, err := Compile(path) if err != nil { t.Fatal(err) @@ -27,13 +27,13 @@ func TestProgramStatesAreIndependent(t *testing.T) { if program.proto.SourceName != path { t.Fatalf("program source = %q, want %q", program.proto.SourceName, path) } - first, err := program.NewState(context.Background(), nil, nil) + first, err := program.NewState(context.Background(), 256, nil, nil) if err != nil { t.Fatal(err) } defer first.Close() first.SetGlobal("value", glua.LNumber(42)) - second, err := program.NewState(context.Background(), nil, nil) + second, err := program.NewState(context.Background(), 256, nil, nil) if err != nil { t.Fatal(err) } @@ -54,7 +54,7 @@ func TestProgramInitializationObservesCancellation(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) cancel() - state, err := program.NewState(ctx, nil, nil) + state, err := program.NewState(ctx, 256, nil, nil) if err == nil || state != nil { if state != nil { state.Close() @@ -74,7 +74,7 @@ func TestNewStateClosesFailedValidation(t *testing.T) { } wantErr := errors.New("invalid script") var checked *glua.LState - L, err := program.NewState(context.Background(), nil, func(L *glua.LState) error { + L, err := program.NewState(context.Background(), 256, nil, func(L *glua.LState) error { checked = L return wantErr }) @@ -83,6 +83,8 @@ func TestNewStateClosesFailedValidation(t *testing.T) { } } +// TODO: Re-enable these tests and their helper when Compile supports inline scripts again. +/* func compressSource(t *testing.T, code string) []byte { t.Helper() var data bytes.Buffer @@ -114,7 +116,7 @@ func TestCompileInline(t *testing.T) { if program.proto.SourceName != "[inline]" { t.Fatalf("program source = %q, want [inline]", program.proto.SourceName) } - state, err := program.NewState(context.Background(), nil, nil) + state, err := program.NewState(context.Background(), 256, nil, nil) if err != nil { t.Fatal(err) } @@ -152,9 +154,11 @@ func TestCompileInlineDiagnostics(t *testing.T) { for _, tc := range []struct { code string compileError bool + wantMessage string }{ - {"function broken(", true}, - {`error("initialization failed")`, false}, + {"function broken(", true, ""}, + {`error("initialization failed")`, false, ""}, + {"\n-- @xray:privileged-signature=invalid\n\nerror('failure')", false, "[inline]:4: failure"}, } { program, err := Compile(xbase64.Prefix + base64.StdEncoding.EncodeToString([]byte(tc.code))) if tc.compileError { @@ -165,7 +169,7 @@ func TestCompileInlineDiagnostics(t *testing.T) { if err != nil { t.Fatal(err) } - state, initErr := program.NewState(context.Background(), nil, nil) + state, initErr := program.NewState(context.Background(), 256, nil, nil) if state != nil { state.Close() t.Fatal("NewState returned a state after failed initialization") @@ -175,6 +179,26 @@ func TestCompileInlineDiagnostics(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "[inline]") { t.Fatalf("Lua error = %v, want [inline] diagnostic", err) } + if tc.wantMessage != "" && !strings.Contains(err.Error(), tc.wantMessage) { + t.Fatalf("Lua error = %v, want %q", err, tc.wantMessage) + } + } +} +*/ + +func TestCompileFileExtension(t *testing.T) { + for _, name := range []string{"script", "script.dat", "script.lua.txt", "script.LUA"} { + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte("value = 42"), 0o600); err != nil { + t.Fatal(err) + } + if program, err := Compile(path); program != nil || err == nil || !strings.Contains(err.Error(), "Lua file extension must be .lua") { + t.Fatalf("Compile(%q) = %v, %v; want nil program and .lua extension error", path, program, err) + } + } + path := writeTestScript(t, "value = 42") + string(filepath.Separator) + "." + if program, err := Compile(path); program == nil || err != nil { + t.Fatalf("Compile(%q) = %v, %v; want acceptance of the resolved .lua path", path, program, err) } } @@ -194,3 +218,965 @@ func TestCompileRejectsReadFailure(t *testing.T) { t.Fatalf("Compile = %v, %v; want nil program and permission error", program, err) } } + +func compileTestProgram(t *testing.T, code string) *Program { + t.Helper() + program, err := Compile(writeTestScript(t, code)) + if err != nil { + t.Fatal(err) + } + return program +} + +func writeTestScript(t *testing.T, code string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "script.lua") + if err := os.WriteFile(path, []byte(code), 0o600); err != nil { + t.Fatal(err) + } + path, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatal(err) + } + return path +} + +// TODO: Re-enable these tests and helpers when Compile supports privileged signature authorization again. +/* +const privilegedSignatureHeader = "-- @xray:privileged-signature=" + +// RFC 8032 test-vector key. The fixture signature was independently computed +// with Node's OpenSSL-backed crypto.sign(null, SHA256(body), privateKey), rather +// than by the signing helper or the production verifier. +const ( + privilegedTestSeed = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60" + privilegedTestPublicKey = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + privilegedFixtureSignature = "rFbZtaaphe_69UEgSvfYwZcLZYHTXLFxdcukfEuLq6pzvmj8qtw6r60fD-7L5dwqSvnE7F1oAV0G_vHP3rLVBA" + privilegedFixtureDigest = "edef639ae641bacc9b76cea27473eed11369fbad65e223a87990864e899e578a" + privilegedFixtureBody = `-- Ordinary comments participate in the signed digest. +value = 1 + +function HasPrivilegedLibraries() + return io ~= nil and os ~= nil and debug ~= nil and channel ~= nil +end` +) + +func setTestPrivilegedPublicKey(t *testing.T, key string) { + t.Helper() + // Each test setup represents a new process with its own startup key. + privilegedKey.Once = sync.Once{} + privilegedKey.publicKey = nil + t.Cleanup(func() { + privilegedKey.Once = sync.Once{} + privilegedKey.publicKey = nil + }) + if err := SetPrivilegedPublicKey(key); err != nil { + t.Fatal(err) + } +} + +func testSignature(t *testing.T, body string) string { + t.Helper() + seed, err := hex.DecodeString(privilegedTestSeed) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(bytes.TrimSpace([]byte(body))) + return base64.RawURLEncoding.EncodeToString(ed25519.Sign(ed25519.NewKeyFromSeed(seed), digest[:])) +} + +func TestSetPrivilegedPublicKey(t *testing.T) { + setTestPrivilegedPublicKey(t, privilegedTestPublicKey) + path := writeTestScript(t, privilegedSignatureHeader+privilegedFixtureSignature+"\n"+privilegedFixtureBody) + for _, tc := range []struct{ name, key string }{ + {"invalid base64", "%"}, + {"padding", privilegedTestPublicKey + "="}, + {"standard base64 alphabet", strings.ReplaceAll(privilegedTestPublicKey, "_", "/")}, + {"short key", base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize-1))}, + {"long key", base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize+1))}, + {"leading whitespace", " " + privilegedTestPublicKey}, + {"trailing whitespace", privilegedTestPublicKey + "\t"}, + {"embedded newline", privilegedTestPublicKey[:8] + "\n" + privilegedTestPublicKey[8:]}, + {"embedded carriage return", privilegedTestPublicKey[:8] + "\r" + privilegedTestPublicKey[8:]}, + {"nonzero unused bits", privilegedTestPublicKey[:len(privilegedTestPublicKey)-1] + "p"}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := SetPrivilegedPublicKey(tc.key); err == nil { + t.Fatal("invalid public key was accepted") + } + program, err := Compile(path) + if err != nil || program == nil || !program.privileged { + t.Fatalf("invalid setter changed the trusted key: Compile = %v, %v", program, err) + } + }) + } + setTestPrivilegedPublicKey(t, "") + if program, err := Compile(path); program != nil || err == nil { + t.Fatalf("signed script after disabling authorization = %v, %v; want rejection", program, err) + } + unsigned, err := Compile(writeTestScript(t, privilegedFixtureBody)) + if err != nil || unsigned == nil || unsigned.privileged { + t.Fatalf("unsigned script with authorization disabled = %v, %v; want sandbox", unsigned, err) + } +} + +func TestPrivilegedPublicKeyIsFixed(t *testing.T) { + wrongKey := base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)) + for _, key := range []string{"", privilegedTestPublicKey} { + name := "disabled" + if key != "" { + name = "configured" + } + t.Run(name, func(t *testing.T) { + setTestPrivilegedPublicKey(t, key) + path := writeTestScript(t, privilegedSignatureHeader+privilegedFixtureSignature+"\n"+privilegedFixtureBody) + for _, replacement := range []string{privilegedTestPublicKey, "", wrongKey} { + if err := SetPrivilegedPublicKey(replacement); err != nil { + t.Fatal(err) + } + program, err := Compile(path) + if key == "" { + if program != nil || err == nil { + t.Fatalf("disabled startup key changed: Compile = %v, %v", program, err) + } + } else if err != nil || program == nil || !program.privileged { + t.Fatalf("trusted startup key changed: Compile = %v, %v", program, err) + } + } + }) + } +} + +func TestProgramPrivilegedSignatureFixture(t *testing.T) { + setTestPrivilegedPublicKey(t, privilegedTestPublicKey) + digest := sha256.Sum256([]byte(privilegedFixtureBody)) + if got := hex.EncodeToString(digest[:]); got != privilegedFixtureDigest { + t.Fatalf("fixture SHA-256 = %s, want %s", got, privilegedFixtureDigest) + } + for _, signed := range []bool{false, true} { + name, code := "unsigned", privilegedFixtureBody + if signed { + name, code = "signed", privilegedSignatureHeader+privilegedFixtureSignature+"\n"+code + } + t.Run(name, func(t *testing.T) { + program, err := Compile(writeTestScript(t, code)) + if err != nil { + t.Fatal(err) + } + if program.privileged != signed { + t.Fatalf("privileged = %t, want %t", program.privileged, signed) + } + L, err := program.NewState(context.Background(), 256, nil, nil) + if err != nil { + t.Fatal(err) + } + defer L.Close() + if err := L.CallByParam(glua.P{Fn: L.GetGlobal("HasPrivilegedLibraries"), NRet: 1, Protect: true}); err != nil { + t.Fatal(err) + } + if got := L.Get(-1); got != glua.LBool(signed) { + t.Fatalf("privileged libraries available = %v, want %t", got, signed) + } + }) + } +} + +func TestProgramPrivilegedSignatureHeader(t *testing.T) { + setTestPrivilegedPublicKey(t, privilegedTestPublicKey) + header := privilegedSignatureHeader + privilegedFixtureSignature + for _, tc := range []struct { + name, code string + privileged bool + }{ + {"match", header + "\n" + privilegedFixtureBody, true}, + {"no comment space", "--@xray:privileged-signature=" + privilegedFixtureSignature + "\n" + privilegedFixtureBody, true}, + {"comment whitespace", "-- \t @xray:privileged-signature=" + privilegedFixtureSignature + "\n" + privilegedFixtureBody, true}, + {"signature whitespace", privilegedSignatureHeader + " \t" + privilegedFixtureSignature + " \t\n" + privilegedFixtureBody, true}, + {"blank lines and indentation", " \t\r\n\n \t" + header + "\n" + privilegedFixtureBody, true}, + {"CRLF header only", "\r\n" + header + "\r\n" + privilegedFixtureBody, true}, + {"body boundary whitespace", header + "\n \t\r\n\n" + privilegedFixtureBody + " \t\r\n\n", true}, + {"missing header", privilegedFixtureBody, false}, + {"after comment", "-- ordinary comment\n" + header + "\n" + privilegedFixtureBody, false}, + {"after code", "value = 1\n" + header + "\n" + privilegedFixtureBody, false}, + {"inside string", `value = "` + header + `"`, false}, + {"wrong marker", "-- xray:privileged-signature=" + privilegedFixtureSignature + "\n" + privilegedFixtureBody, false}, + {"invalid marker after comment", "-- ordinary comment\n" + privilegedSignatureHeader + "%\nvalue = 1", false}, + } { + t.Run(tc.name, func(t *testing.T) { + program, err := Compile(writeTestScript(t, tc.code)) + if err != nil { + t.Fatal(err) + } + if program.privileged != tc.privileged { + t.Fatalf("privileged = %t, want %t", program.privileged, tc.privileged) + } + }) + } +} + +func TestProgramRejectsInvalidPrivilegedSignatures(t *testing.T) { + seed, err := hex.DecodeString(privilegedTestSeed) + if err != nil { + t.Fatal(err) + } + wrongKey := base64.RawURLEncoding.EncodeToString(ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)).Public().(ed25519.PublicKey)) + bodySignature := base64.RawURLEncoding.EncodeToString(ed25519.Sign(ed25519.NewKeyFromSeed(seed), []byte(privilegedFixtureBody))) + for _, tc := range []struct{ name, key, signature string }{ + {"missing key", "", privilegedFixtureSignature}, + {"wrong key", wrongKey, privilegedFixtureSignature}, + {"empty signature", privilegedTestPublicKey, ""}, + {"whitespace-only signature", privilegedTestPublicKey, " \t"}, + {"invalid base64", privilegedTestPublicKey, "%"}, + {"padding", privilegedTestPublicKey, privilegedFixtureSignature + "=="}, + {"standard base64 alphabet", privilegedTestPublicKey, strings.ReplaceAll(privilegedFixtureSignature, "_", "/")}, + {"short signature", privilegedTestPublicKey, base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.SignatureSize-1))}, + {"long signature", privilegedTestPublicKey, base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.SignatureSize+1))}, + {"embedded carriage return", privilegedTestPublicKey, privilegedFixtureSignature[:8] + "\r" + privilegedFixtureSignature[8:]}, + {"nonzero unused bits", privilegedTestPublicKey, privilegedFixtureSignature[:len(privilegedFixtureSignature)-1] + "B"}, + {"damaged signature", privilegedTestPublicKey, "s" + privilegedFixtureSignature[1:]}, + {"signature over body instead of digest", privilegedTestPublicKey, bodySignature}, + } { + t.Run(tc.name, func(t *testing.T) { + setTestPrivilegedPublicKey(t, tc.key) + program, err := Compile(writeTestScript(t, privilegedSignatureHeader+tc.signature+"\n"+privilegedFixtureBody)) + if program != nil || err == nil { + t.Fatalf("Compile = %v, %v; want nil program and signature rejection", program, err) + } + }) + } + setTestPrivilegedPublicKey(t, privilegedTestPublicKey) + code := privilegedSignatureHeader + "%\n" + privilegedSignatureHeader + privilegedFixtureSignature + "\n" + privilegedFixtureBody + if program, err := Compile(writeTestScript(t, code)); program != nil || err == nil { + t.Fatalf("later signature replaced invalid first signature: %v, %v", program, err) + } +} + +func TestProgramSignedBodyTampering(t *testing.T) { + setTestPrivilegedPublicKey(t, privilegedTestPublicKey) + for _, tc := range []struct{ name, body string }{ + {"statement", strings.Replace(privilegedFixtureBody, "value = 1", "value = 2", 1)}, + {"comment", strings.Replace(privilegedFixtureBody, "Ordinary", "Modified", 1)}, + {"added comment", "-- added comment\n" + privilegedFixtureBody}, + {"indentation", strings.Replace(privilegedFixtureBody, " return", " return", 1)}, + {"internal spaces", strings.Replace(privilegedFixtureBody, "value = 1", "value = 1", 1)}, + {"internal trailing spaces", strings.Replace(privilegedFixtureBody, "value = 1\n", "value = 1 \t\n", 1)}, + {"removed blank line", strings.Replace(privilegedFixtureBody, "\n\n", "\n", 1)}, + {"added blank line", strings.Replace(privilegedFixtureBody, "\n\n", "\n\n\n", 1)}, + {"LF to CRLF", strings.ReplaceAll(privilegedFixtureBody, "\n", "\r\n")}, + } { + t.Run(tc.name, func(t *testing.T) { + code := privilegedSignatureHeader + privilegedFixtureSignature + "\n" + tc.body + if program, err := Compile(writeTestScript(t, code)); program != nil || err == nil { + t.Fatalf("tampered body Compile = %v, %v; want signature rejection", program, err) + } + }) + } + crlfBody := strings.ReplaceAll(privilegedFixtureBody, "\n", "\r\n") + crlfSignature := testSignature(t, crlfBody) + program, err := Compile(writeTestScript(t, privilegedSignatureHeader+crlfSignature+"\r\n"+crlfBody)) + if err != nil || program == nil || !program.privileged { + t.Fatalf("correctly signed CRLF body = %v, %v; want privileged program", program, err) + } + if program, err := Compile(writeTestScript(t, privilegedSignatureHeader+crlfSignature+"\n"+privilegedFixtureBody)); program != nil || err == nil { + t.Fatalf("CRLF to LF tampering = %v, %v; want signature rejection", program, err) + } +} + +*/ + +// TODO: Re-enable this test when Compile supports inline scripts again. +/* +func TestProgramInlineSignaturesCannotAuthorize(t *testing.T) { + for _, key := range []string{"", privilegedTestPublicKey} { + setTestPrivilegedPublicKey(t, key) + for _, signature := range []string{privilegedFixtureSignature, "%", ""} { + code := privilegedSignatureHeader + signature + "\n" + privilegedFixtureBody + for _, script := range []string{ + xbase64.Prefix + base64.StdEncoding.EncodeToString([]byte(code)), + xbase64.GzipPrefix + base64.StdEncoding.EncodeToString(compressSource(t, code)), + } { + program, err := Compile(script) + if err != nil || program == nil || program.privileged { + t.Fatalf("inline signed source Compile = %v, %v; want sandbox without verification", program, err) + } + L, err := program.NewState(context.Background(), 256, nil, nil) + if err != nil { + t.Fatal(err) + } + if got := L.GetGlobal("os"); got != glua.LNil { + L.Close() + t.Fatalf("inline os = %v, want nil", got) + } + L.Close() + } + } + } +} +*/ + +// TODO: Re-enable this test when Compile supports privileged signature authorization again. +/* +func TestProgramSignedDiagnostics(t *testing.T) { + setTestPrivilegedPublicKey(t, privilegedTestPublicKey) + const invalidBody = "\n\nlocal = 1" + invalidPath := writeTestScript(t, "\n"+privilegedSignatureHeader+"%\n"+invalidBody) + if program, err := Compile(invalidPath); program != nil || err == nil || !strings.Contains(err.Error(), "signature") { + t.Fatalf("invalid signature and invalid syntax = %v, %v; want signature error before parsing", program, err) + } + validPath := writeTestScript(t, "\n"+privilegedSignatureHeader+testSignature(t, invalidBody)+"\n"+invalidBody) + if program, err := Compile(validPath); program != nil || err == nil || !strings.Contains(err.Error(), validPath+" line:5(") { + t.Fatalf("verified invalid syntax = %v, %v; want original source line 5", program, err) + } + const body = "\n\nerror('signed failure')" + path := writeTestScript(t, "\n"+privilegedSignatureHeader+testSignature(t, body)+"\n"+body) + program, err := Compile(path) + if err != nil { + t.Fatal(err) + } + L, err := program.NewState(context.Background(), 256, nil, nil) + if L != nil { + L.Close() + t.Fatal("failing signed script returned a state") + } + if err == nil || !strings.Contains(err.Error(), path+":5: signed failure") { + t.Fatalf("signed initialization error = %v; want original source line 5", err) + } +} + +*/ + +func TestProgramSandbox(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "escape.lua"), []byte(`disk_loaded = true; return "escaped"`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("LUA_PATH", filepath.ToSlash(filepath.Join(dir, "?.lua"))) + program := compileTestProgram(t, ` +local function check() + assert(package == nil) + assert(require("_G") == _G) + for _, name in ipairs({"io", "debug", "channel", "ch", "coroutine", "dofile", "loadfile", + "load", "loadstring", "getfenv", "setfenv", "collectgarbage", "module", + "getmetatable", "setmetatable", "rawget", "rawset", "newproxy", "print", "_printregs"}) do + assert(_G[name] == nil, name) + end + for _, name in ipairs({"io", "debug", "channel", "ch", "coroutine", "package", "escape"}) do + assert(not pcall(require, name), name) + end + for _, name in ipairs({"math", "string", "table", "os"}) do + assert(require(name) == _G[name], name) + end + for _, lib in ipairs({os, require("os")}) do + for _, name in ipairs({"clock", "date", "difftime", "time"}) do + assert(type(lib[name]) == "function", "os." .. name) + end + for _, name in ipairs({"execute", "exit", "getenv", "remove", "rename", "setenv", "setlocale", "tmpname"}) do + assert(lib[name] == nil, "os." .. name) + end + end + assert(string.dump == nil and ("").dump == nil and require("string").dump == nil) + assert(math.randomseed == nil and require("math").randomseed == nil) + local helper = require("xray.test") + assert(helper.value == "ok" and helper == require("xray.test")) + assert(math.abs(-2) == 2 and string.upper("ok") == "OK") + assert(table.concat({"a", "b"}) == "ab") +end +check() +function CheckSandbox() + check() + local injected = false + package = { + preload = { + escape = function() injected = true; return "escaped" end, + ["xray.late"] = function() injected = true; return "injected" end, + }, + loaded = {escape = "escaped", ["cached.only"] = "injected"}, + loaders = {function() injected = true; return function() return "escaped" end end}, + path = testPath, + cpath = testPath, + } + assert(not pcall(require, "escape") and disk_loaded == nil) + assert(not pcall(require, "cached.only") and not pcall(require, "package")) + assert(require("xray.late") == "host" and require("xray.late") == "host") + assert(require("xray.test").value == "ok" and not injected) + package = nil +end`) + var helperLoads, lateLoads int + L, err := program.NewState(context.Background(), 256, func(L *glua.LState) { + if L.GetGlobal("io") != glua.LNil || L.GetGlobal("loadfile") != glua.LNil { + t.Fatal("sandbox was not configured before module registration") + } + L.SetGlobal("testPath", glua.LString(filepath.ToSlash(filepath.Join(dir, "?.lua")))) + L.PreloadModule("xray.test", func(L *glua.LState) int { + helperLoads++ + module := L.NewTable() + module.RawSetString("value", glua.LString("ok")) + L.Push(module) + return 1 + }) + L.PreloadModule("xray.late", func(L *glua.LState) int { + lateLoads++ + L.Push(glua.LString("host")) + return 1 + }) + }, nil) + if err != nil { + t.Fatal(err) + } + defer L.Close() + if L.GetTop() != 0 { + t.Fatalf("stack top = %d, want 0", L.GetTop()) + } + if err := L.CallByParam(glua.P{Fn: L.GetGlobal("CheckSandbox"), Protect: true}); err != nil { + t.Fatal(err) + } + if helperLoads != 1 || lateLoads != 1 { + t.Fatalf("module initialization calls = (%d, %d), want (1, 1)", helperLoads, lateLoads) + } +} + +func TestProgramSandboxToString(t *testing.T) { + program := compileTestProgram(t, ` +assert(tostring == require("_G").tostring) +for _, convert in ipairs({tostring, require("_G").tostring}) do + assert(convert(nil) == "nil") + assert(convert(true) == "true" and convert(false) == "false") + assert(convert(0) == "0" and convert(-42) == "-42" and convert(1.25) == "1.25") + assert(convert("") == "" and convert("hello") == "hello") + assert(convert("0xdeadbeef") == "0xdeadbeef") + assert(convert(42, {}) == "42") + assert(not pcall(convert)) + assert(convert(testIPv4) == "192.0.2.1") + assert(convert(testIPv6) == "2001:db8::1") + assert(convert(testNativeIP) == "198.51.100.1") + assert(convert(testNativeStringer) == "0xdeadbeef") + assert(convert(testNativeError) == "lookup failed") + for _, value in ipairs({{}, function() end, convert, testUserdata, testThread, testChannel, testGoStruct, testIPs}) do + local ok, result = pcall(convert, value) + assert(ok and result == "") + end +end +string.__tostring = function() error("string __tostring must not run") end +assert(tostring("hello") == "hello") +string.__tostring = nil +`) + var metamethodCalls int + L, err := program.NewState(context.Background(), 256, func(L *glua.LState) { + L.SetGlobal("testIPv4", luar.New(L, net.ParseIP("192.0.2.1"))) + L.SetGlobal("testIPv6", luar.New(L, net.ParseIP("2001:db8::1"))) + PushUserData(L, net.ParseIP("198.51.100.1")) + L.SetGlobal("testNativeIP", L.Get(-1)) + L.Pop(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) + L.SetGlobal("testGoStruct", luar.New(L, &struct{ Value string }{"readable field"})) + NewSlicePusher[net.IP](L)(L, []net.IP{net.ParseIP("192.0.2.1")}) + L.SetGlobal("testIPs", L.Get(-1)) + L.Pop(1) + userdata := L.NewUserData() + metatable := L.NewTable() + metatable.RawSetString("__tostring", L.NewFunction(func(L *glua.LState) int { + metamethodCalls++ + 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 metamethodCalls != 0 { + t.Fatalf("__tostring calls = %d, want 0", metamethodCalls) + } +} + +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("%s", "0xdeadbeef") == "0xdeadbeef") + 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(), 256, 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 TestProgramSandboxProtectedCalls(t *testing.T) { + program := compileTestProgram(t, ` +assert(pcall == require("_G").pcall and xpcall == require("_G").xpcall) +local object = {} +local function succeed(a, b, c) + assert(a == 42 and b == nil and c == object) + return nil, object, "0xdeadbeef" +end +local ok, a, b, c = pcall(succeed, 42, nil, object) +assert(ok and a == nil and b == object and c == "0xdeadbeef") +assert(select("#", pcall(succeed, 42, nil, object)) == 4) +local function noArguments(...) + assert(select("#", ...) == 0) + return nil, object, nil +end +local function unexpectedHandler() error("handler must not run") end +ok, a, b, c = xpcall(noArguments, unexpectedHandler, "ignored") +assert(ok and a == nil and b == object and c == nil) +assert(select("#", xpcall(noArguments, unexpectedHandler)) == 4) +ok, a = pcall(testCallable, 42) +assert(ok and a == 42) +ok, a = pcall(nil) +assert(not ok and a == "attempt to call a nil value") +ok, a = pcall(pcall) +assert(not ok and string.find(a, "bad argument #1", 1, true)) +ok, a = pcall(xpcall, nil, nil) +assert(not ok and string.find(a, "bad argument #1", 1, true)) +ok, a = pcall(xpcall, noArguments, nil) +assert(not ok and string.find(a, "bad argument #2", 1, true)) + +local businessError = "DNS lookup timed out (192.0.2.1)" +ok, a = pcall(error, businessError, 0) +assert(not ok and a == businessError) +ok, a = xpcall(function() error(businessError, 0) end, function(message) + assert(message == businessError) + return "handled: " .. message, "ignored" +end) +assert(not ok and a == "handled: " .. businessError) +local message = "lookup at 0x12Ab / 0XFE90; 2001:db8::1; 0x" +local redacted = "lookup at
/
; 2001:db8::1; 0x" +ok, a = pcall(error, message, 0) +assert(not ok and a == redacted) +ok, a = pcall(error, "business id=0xdeadbeef", 0) +assert(not ok and a == "business id=
") +for _, value in ipairs({object, testObject, true, 42}) do + ok, a = pcall(error, value, 0) + assert(not ok and a == value) + ok, a = xpcall(function() error(value, 0) end, function(caught) + assert(caught == value) + return caught + end) + assert(not ok and a == value) +end +ok, a = pcall(error, nil, 0) +assert(not ok and a == nil) + +local conversion = "cannot use function:
(type *lua.LFunction) as type string" +ok, a = pcall(testTakeString, function() end) +assert(not ok and string.find(a, conversion, 1, true)) +ok, a = xpcall(function() testTakeString(function() end) end, function(caught) + testSendError(caught) + return caught +end) +assert(not ok and string.find(a, conversion, 1, true)) +ok, a = pcall(function() testValues[1] = testObject end) +assert(not ok and string.find(a, "
", 1, true) and not string.find(a, "0x", 1, true)) +assert(string.find(a, "as type int", 1, true)) +testValues[1] = 7 +local extended = testValues + 9 +assert(testValues[1] == 7 and #extended == 2 and extended[2] == 9) +ok, a = pcall(testPanic) +assert(not ok and a == "{
}") +ok, a = xpcall(testPanic, function(caught) + testSendError(caught) + return caught +end) +assert(not ok and a == "{
}") +ok, a = xpcall(function() error(message, 0) end, function(caught) + assert(caught == redacted) + error("handler failed at 0xABCD", 0) +end) +assert(not ok and a == "handler failed at
") +ok, a = xpcall(function() error(businessError, 0) end, testPanic) +assert(not ok and a == "{
}") +`) + var sent []string + L, err := program.NewState(context.Background(), 256, func(L *glua.LState) { + callable := L.NewTable() + metatable := L.NewTable() + metatable.RawSetString("__call", L.NewFunction(func(L *glua.LState) int { + L.Push(L.Get(2)) + return 1 + })) + L.SetMetatable(callable, metatable) + L.SetGlobal("testCallable", callable) + L.SetGlobal("testTakeString", luar.New(L, func(string) {})) + target := 42 + PushUserData(L, struct{ Pointer *int }{&target}) + L.SetGlobal("testObject", L.Get(-1)) + L.Pop(1) + NewSlicePusher[int](L)(L, []int{1}) + L.SetGlobal("testValues", L.Get(-1)) + L.Pop(1) + L.SetGlobal("testPanic", L.NewFunction(func(L *glua.LState) int { + panic(struct{ First, Second *int }{&target, &target}) + })) + L.SetGlobal("testSendError", L.NewFunction(func(L *glua.LState) int { + message := L.CheckString(1) + if strings.Contains(message, "0x") || strings.Contains(message, "0X") { + t.Errorf("outbound error contains an address: %q", message) + } + sent = append(sent, message) + return 0 + })) + }, nil) + if err != nil { + t.Fatal(err) + } + defer L.Close() + if len(sent) != 2 || !strings.Contains(sent[0], "cannot use function:
") || sent[1] != "{
}" { + t.Fatalf("outbound errors = %q", sent) + } + if L.GetTop() != 0 { + t.Fatalf("stack top = %d, want 0", L.GetTop()) + } +} + +func TestProgramSandboxGoErrorStrings(t *testing.T) { + program := compileTestProgram(t, ` +for name, expected in pairs(testErrorMessages) do + local value = _G[name] + assert(tostring(value) == expected, name) + assert(string.format("%s", value) == expected, name) + assert(string.format("%p", value) == string.format("%p", expected), name) + assert(string.format("%#v", value) == string.format("%#v", expected), name) + local ok, caught = pcall(error, value, 0) + assert(not ok and caught == value and tostring(caught) == expected, name) + ok, caught = xpcall(function() error(value, 0) end, function(caught) + assert(caught == value and tostring(caught) == expected, name) + return caught + end) + assert(not ok and caught == value, name) +end +`) + nativeErrors := make(map[string]error) + originalMessages := make(map[string]string) + L, err := program.NewState(context.Background(), 256, func(L *glua.LState) { + luaError := &glua.ApiError{Object: L.NewFunction(func(*glua.LState) int { return 0 })} + stringerError := xerrors.New("DNS query failed").Base(luaError) + messages := L.NewTable() + for _, tc := range []struct { + name string + err error + expected string + }{ + {"nativeError", errors.New("lookup failed at 0x12Ab and 0XFe90"), "lookup failed at
and
"}, + {"luaError", luaError, "function:
"}, + {"wrappedError", fmt.Errorf("DNS query failed: %w", luaError), "DNS query failed: function:
"}, + {"stringerError", stringerError, strings.ReplaceAll(stringerError.Error(), luaError.Object.String(), "function:
")}, + } { + nativeErrors[tc.name] = tc.err + originalMessages[tc.name] = tc.err.Error() + PushError(L, tc.err) + L.SetGlobal(tc.name, L.Get(-1)) + L.Pop(1) + messages.RawSetString(tc.name, glua.LString(tc.expected)) + } + L.SetGlobal("testErrorMessages", messages) + }, nil) + if err != nil { + t.Fatal(err) + } + defer L.Close() + for name, native := range nativeErrors { + if got := ReadError(L.GetGlobal(name), "invalid"); got != native { + t.Fatalf("%s error identity changed", name) + } + if got := native.Error(); got != originalMessages[name] { + t.Fatalf("%s host error = %q, want %q", name, got, originalMessages[name]) + } + } +} + +func TestProgramSandboxModuleFailures(t *testing.T) { + program := compileTestProgram(t, ` +assert(package == nil) +local ok, err = pcall(require, "xray.failure") +assert(not ok and string.find(err, "module initialization failed", 1, true)) +package = {loaded = {["xray.failure"] = "injected"}} +ok, err = pcall(require, "xray.failure") +assert(not ok and string.find(err, "loop or previous error loading module", 1, true)) +for i = 1, 2 do + ok, err = pcall(require, "xray.cycle") + assert(not ok and string.find(err, "loop or previous error loading module", 1, true)) +end +package = nil +`) + var failedLoads, cycleLoads int + L, err := program.NewState(context.Background(), 256, func(L *glua.LState) { + L.PreloadModule("xray.failure", func(L *glua.LState) int { + failedLoads++ + L.RaiseError("module initialization failed") + return 0 + }) + L.PreloadModule("xray.cycle", func(L *glua.LState) int { + cycleLoads++ + L.Push(L.GetGlobal("require")) + L.Push(glua.LString("xray.cycle")) + L.Call(1, 1) + return 1 + }) + }, nil) + if err != nil { + t.Fatal(err) + } + defer L.Close() + if failedLoads != 1 || cycleLoads != 1 { + t.Fatalf("module initialization calls = (%d, %d), want (1, 1)", failedLoads, cycleLoads) + } + if L.GetTop() != 0 { + t.Fatalf("stack top = %d, want 0", L.GetTop()) + } +} + +func TestProgramSandboxModulesAreCollected(t *testing.T) { + for _, fail := range []bool{false, true} { + name := "success" + if fail { + name = "failure" + } + t.Run(name, func(t *testing.T) { + ref := func() weak.Pointer[glua.LTable] { + program := compileTestProgram(t, `payload = string.rep("x", 1024 * 1024); assert(pcall(require, "xray.test") == expectedSuccess)`) + L, err := program.NewState(context.Background(), 256, func(L *glua.LState) { + L.SetGlobal("expectedSuccess", glua.LBool(!fail)) + L.PreloadModule("xray.test", func(L *glua.LState) int { + if fail { + L.RaiseError("module initialization failed") + } + L.Push(L.NewTable()) + return 1 + }) + }, nil) + if err != nil { + t.Fatal(err) + } + ref := weak.Make(L.G.Global) + L.Close() + return ref + }() + runtime.GC() + runtime.GC() + if ref.Value() != nil { + t.Fatal("closed sandbox's global table was not collected") + } + }) + } +} + +func TestProgramSandboxClosesFailedInitialization(t *testing.T) { + program := compileTestProgram(t, `require("xray.failure")`) + var partial *glua.LState + L, err := program.NewState(context.Background(), 256, func(L *glua.LState) { + partial = L + L.PreloadModule("xray.failure", func(L *glua.LState) int { + L.RaiseError("module initialization failed") + return 0 + }) + }, func(*glua.LState) error { + t.Error("validation ran after failed initialization") + return nil + }) + if L != nil { + L.Close() + t.Fatal("sandbox initialization returned a state") + } + if err == nil || !strings.Contains(err.Error(), "module initialization failed") || partial == nil || !partial.IsClosed() { + t.Fatalf("error = %v, partial state closed = %t", err, partial != nil && partial.IsClosed()) + } +} + +// TODO: Re-enable these tests when Compile supports privileged signature authorization again. +/* +func TestProgramPrivilegedLibraries(t *testing.T) { + setTestPrivilegedPublicKey(t, privilegedTestPublicKey) + const body = `assert(require("io") == io and require("os") == os) +assert(require("debug") == debug and require("channel") == channel) +assert(type(coroutine.create) == "function" and require("coroutine") == coroutine) +assert(type(dofile) == "function" and type(loadfile) == "function") +assert(type(getfenv) == "function" and type(setfenv) == "function") +assert(type(collectgarbage) == "function" and type(print) == "function") +assert(type(getmetatable) == "function" and type(setmetatable) == "function") +assert(type(rawget) == "function" and type(rawset) == "function") +assert(tostring(42) == "42" and type(tostring({})) == "string") +assert(tostring(setmetatable({}, {__tostring = function() return "custom" end})) == "custom") +local ok, message = pcall(error, "lookup failed at 0x12Ab", 0) +assert(not ok and message == "lookup failed at 0x12Ab") +ok, message = xpcall(function() error("lookup failed at 0x12Ab", 0) end, function(caught) + assert(caught == "lookup failed at 0x12Ab") + return caught +end) +assert(not ok and message == "lookup failed at 0x12Ab") +assert(type(string.dump) == "function" and type(math.randomseed) == "function") +assert(type(package.loaders[2]) == "function" and type(package.path) == "string") +assert(type(debug.traceback()) == "string" and type(os.time()) == "number") +assert(assert(loadstring("return 42"))() == 42) +local file = assert(io.open(testPath, "w")) +file:write("privileged") +file:close()` + code := privilegedSignatureHeader + testSignature(t, body) + "\n" + body + path := filepath.Join(t.TempDir(), "privileged.lua") + if err := os.WriteFile(path, []byte(code), 0o600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, script string + privileged bool + }{ + {"file", path, true}, + // TODO: Re-enable these cases when Compile supports inline scripts again. + // {"base64", xbase64.Prefix + base64.StdEncoding.EncodeToString([]byte(code)), false}, + // {"gzip", xbase64.GzipPrefix + base64.StdEncoding.EncodeToString(compressSource(t, code)), false}, + } { + t.Run(tc.name, func(t *testing.T) { + program, err := Compile(tc.script) + if err != nil { + t.Fatal(err) + } + output := filepath.Join(t.TempDir(), "privileged.txt") + L, err := program.NewState(context.Background(), 256, func(L *glua.LState) { + L.SetGlobal("testPath", glua.LString(output)) + }, nil) + if !tc.privileged { + if L != nil { + L.Close() + t.Fatal("inline script with a valid signature accessed privileged libraries") + } + if err == nil || !strings.Contains(err.Error(), "no preloaded module: io") { + t.Fatalf("NewState error = %v, want io module unavailable", err) + } + if _, err := os.Stat(output); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("inline script output stat error = %v, want file not found", err) + } + return + } + if err != nil { + t.Fatal(err) + } + defer L.Close() + data, err := os.ReadFile(output) + if err != nil || string(data) != "privileged" { + t.Fatalf("privileged file contents = %q, error = %v", data, err) + } + }) + } +} + +func TestProgramPrivilegesAreCompileTimeSnapshot(t *testing.T) { + setTestPrivilegedPublicKey(t, privilegedTestPublicKey) + for _, privileged := range []bool{false, true} { + name := "sandboxed" + if privileged { + name = "privileged" + } + t.Run(name, func(t *testing.T) { + const body = "value = 1" + code := body + if privileged { + code = privilegedSignatureHeader + testSignature(t, body) + "\n" + body + } + path := filepath.Join(t.TempDir(), "snapshot.lua") + if err := os.WriteFile(path, []byte(code), 0o600); err != nil { + t.Fatal(err) + } + program, err := Compile(path) + if err != nil { + t.Fatal(err) + } + // Source edits cannot change an already compiled Program's authorization. + changed := privilegedSignatureHeader + testSignature(t, body) + "\n" + body + if privileged { + changed = body + } + if err := os.WriteFile(path, []byte(changed), 0o600); err != nil { + t.Fatal(err) + } + check := func(L *glua.LState) { + t.Helper() + if got := L.GetField(L.GetGlobal("os"), "execute").Type() == glua.LTFunction; got != privileged { + t.Fatalf("os.execute available = %t, want compile-time value %t", got, privileged) + } + } + factory := program.NewStateFactory(time.Second, 256, nil, nil) + for range 2 { + L, err := factory(context.Background()) + if err != nil { + t.Fatal(err) + } + defer L.Close() + check(L) + } + recompiled, err := Compile(path) + if err != nil { + t.Fatal(err) + } + if recompiled.privileged == privileged { + t.Fatal("recompilation did not observe the changed source") + } + }) + } +} +*/ diff --git a/common/platform/filesystem/file.go b/common/platform/filesystem/file.go index d9d1d39fe..24cf112e2 100644 --- a/common/platform/filesystem/file.go +++ b/common/platform/filesystem/file.go @@ -34,12 +34,13 @@ func ReadAsset(file string) ([]byte, error) { return ReadFile(path) } -func OpenAsset(file string) (io.ReadCloser, error) { - path, _, err := getAssetFileLocation(file) +func OpenAsset(file string) (io.ReadCloser, int64, error) { + path, info, err := getAssetFileLocation(file) if err != nil { - return nil, err + return nil, 0, err } - return NewFileReader(path) + r, err := NewFileReader(path) + return r, info.Size(), err } func StatAsset(file string) (os.FileInfo, error) { diff --git a/common/platform/platform.go b/common/platform/platform.go index 4c5cf2a0f..9a650876a 100644 --- a/common/platform/platform.go +++ b/common/platform/platform.go @@ -93,7 +93,7 @@ func GetConfDirPath() string { return configPath } -// ResolveLuaFile finds a local Lua script and returns its absolute path. +// ResolveLuaFile finds a local .lua script and returns its resolved absolute path. // Relative paths: XRAY_LOCATION_CONFDIR > XRAY_LOCATION_CONFIG > working dir > executable dir. func ResolveLuaFile(path string) (string, error) { if path == "" { @@ -113,7 +113,22 @@ func ResolveLuaFile(path string) (string, error) { } } } - return resolveFile(paths) + path, err := resolveFile(paths) + if err != nil { + return "", err + } + return resolveLuaTarget(path) +} + +func resolveLuaTarget(path string) (string, error) { + path, err := filepath.EvalSymlinks(path) + if err != nil { + return "", fmt.Errorf("failed to resolve Lua file symlinks: %w", err) + } + if filepath.Ext(path) != ".lua" { + return "", errors.New("Lua file extension must be .lua") + } + return path, nil } func resolveFile(paths []string) (string, error) { diff --git a/common/platform/platform_test.go b/common/platform/platform_test.go index 95d5b6545..87df1a195 100644 --- a/common/platform/platform_test.go +++ b/common/platform/platform_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/xtls/xray-core/common" @@ -88,6 +89,10 @@ func TestResolveLuaFile(t *testing.T) { for _, path := range paths[:3] { common.Must(os.WriteFile(path, nil, 0o600)) } + for i, path := range paths { + paths[i], err = filepath.EvalSymlinks(path) + common.Must(err) + } if got, err := ResolveLuaFile(paths[2]); err != nil || got != paths[2] { t.Fatalf("absolute path = %q, %v; want %q", got, err, paths[2]) } @@ -115,3 +120,35 @@ func TestResolveLuaFile(t *testing.T) { } } } + +func TestResolveLuaFileSymlink(t *testing.T) { + for _, tc := range []struct { + link, target string + valid bool + }{ + {"alias.lua", "data.dat", false}, + {"alias.dat", "script.lua", true}, + } { + t.Run(tc.link, func(t *testing.T) { + dir, err := filepath.EvalSymlinks(t.TempDir()) + common.Must(err) + target := filepath.Join(dir, tc.target) + common.Must(os.WriteFile(target, nil, 0o600)) + link := filepath.Join(dir, tc.link) + if err := os.Symlink(tc.target, link); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("cannot create symlinks: %v", err) + } + t.Fatal(err) + } + got, err := ResolveLuaFile(link) + if tc.valid { + if err != nil || got != target { + t.Fatalf("ResolveLuaFile(%q) = %q, %v; want %q", link, got, err, target) + } + } else if err == nil || got != "" || !strings.Contains(err.Error(), "Lua file extension must be .lua") { + t.Fatalf("ResolveLuaFile(%q) = %q, %v; want .lua extension error", link, got, err) + } + }) + } +} diff --git a/core/config.go b/core/config.go index 112035782..199105bc1 100644 --- a/core/config.go +++ b/core/config.go @@ -175,9 +175,13 @@ func init() { switch v := input.(type) { case cmdarg.Arg: r, err := confloader.LoadConfig(v[0]) - common.Must(err) + if err != nil { + return nil, err + } data, err := buf.ReadAllToBytes(r) - common.Must(err) + if err != nil { + return nil, err + } return loadProtobufConfig(data) case io.Reader: data, err := buf.ReadAllToBytes(v) diff --git a/main/commands/all/commands.go b/main/commands/all/commands.go index be2771da9..aaf0642e8 100644 --- a/main/commands/all/commands.go +++ b/main/commands/all/commands.go @@ -4,6 +4,8 @@ import ( "github.com/xtls/xray-core/main/commands/all/api" "github.com/xtls/xray-core/main/commands/all/base64" "github.com/xtls/xray-core/main/commands/all/convert" + // TODO: Restore this import and lua.CmdLua when Lua privileged signature authorization is re-enabled. + // "github.com/xtls/xray-core/main/commands/all/lua" "github.com/xtls/xray-core/main/commands/all/tls" "github.com/xtls/xray-core/main/commands/base" ) @@ -14,9 +16,11 @@ func init() { api.CmdAPI, base64.CmdBase64, convert.CmdConvert, + // lua.CmdLua, tls.CmdTLS, cmdUUID, cmdX25519, + // cmdEd25519, cmdWG, cmdMLDSA65, cmdMLKEM768, diff --git a/main/commands/all/ed25519.go b/main/commands/all/ed25519.go new file mode 100644 index 000000000..c23fe0d96 --- /dev/null +++ b/main/commands/all/ed25519.go @@ -0,0 +1,53 @@ +package all + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "fmt" + + "github.com/xtls/xray-core/main/commands/base" +) + +var cmdEd25519 = &base.Command{ + UsageLine: `{{.Exec}} ed25519 [-i "private key seed (Base64URL)"]`, + Short: "Generate an Ed25519 key pair", + Long: ` +Generate an Ed25519 key pair, or restore it from a 32-byte private key seed +with -i. PrivateKey (seed) and PublicKey use unpadded Base64URL. +`, +} + +var inputEd25519 = cmdEd25519.Flag.String("i", "", "Ed25519 private key seed in unpadded Base64URL.") + +func init() { + cmdEd25519.Run = executeEd25519 +} + +func executeEd25519(cmd *base.Command, args []string) { + if len(args) != 0 { + cmd.Usage() + } + + var privateKey ed25519.PrivateKey + if *inputEd25519 == "" { + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + base.Fatalf("failed to generate Ed25519 key: %s", err) + } + privateKey = key + } else { + encoding := base64.RawURLEncoding.Strict() + seed, err := encoding.DecodeString(*inputEd25519) + if err != nil || len(seed) != ed25519.SeedSize || encoding.EncodeToString(seed) != *inputEd25519 { + base.Fatalf("invalid Ed25519 private key seed: expected 32 bytes in unpadded Base64URL") + } + privateKey = ed25519.NewKeyFromSeed(seed) + } + + if _, err := fmt.Printf("PrivateKey: %s\nPublicKey: %s\n", + base64.RawURLEncoding.EncodeToString(privateKey.Seed()), + base64.RawURLEncoding.EncodeToString(privateKey.Public().(ed25519.PublicKey))); err != nil { + base.Fatalf("failed to write Ed25519 keys: %s", err) + } +} diff --git a/main/commands/all/lua/lua.go b/main/commands/all/lua/lua.go new file mode 100644 index 000000000..d5ff26082 --- /dev/null +++ b/main/commands/all/lua/lua.go @@ -0,0 +1,13 @@ +package lua + +import "github.com/xtls/xray-core/main/commands/base" + +// CmdLua holds the Lua script commands. +var CmdLua = &base.Command{ + UsageLine: "{{.Exec}} lua", + Short: "Manage Lua scripts", + Long: "{{.Exec}} {{.LongName}} provides tools for local Lua scripts.", + Commands: []*base.Command{ + cmdSign, + }, +} diff --git a/main/commands/all/lua/sign.go b/main/commands/all/lua/sign.go new file mode 100644 index 000000000..a300e1540 --- /dev/null +++ b/main/commands/all/lua/sign.go @@ -0,0 +1,98 @@ +package lua + +import ( + "os" + "path/filepath" + + xlua "github.com/xtls/xray-core/common/lua" + "github.com/xtls/xray-core/main/commands/base" +) + +var cmdSign = &base.Command{ + UsageLine: "{{.Exec}} lua sign [-key seed] [-o output-file] ", + Short: "Sign a local Lua script for privileged execution", + Long: ` +Sign a local Lua file using a 32-byte Ed25519 private key seed in unpadded +Base64URL. Use {{.Exec}} ed25519 to generate a key pair. +Write back to the input file, or use -o to write another file (- for stdout). +`, +} + +var ( + signKey = cmdSign.Flag.String("key", "", "Ed25519 private key seed in unpadded Base64URL.") + signOutput = cmdSign.Flag.String("o", "", "Output file, or - for stdout. Default: input file.") +) + +func init() { + cmdSign.Run = executeSign +} + +func executeSign(cmd *base.Command, args []string) { + if len(args) != 1 { + cmd.Usage() + } + + info, err := os.Stat(args[0]) + if err != nil { + base.Fatalf("failed to inspect Lua file: %s", err) + } + if !info.Mode().IsRegular() { + base.Fatalf("Lua input is not a regular file: %s", args[0]) + } + + code, err := os.ReadFile(args[0]) + if err != nil { + base.Fatalf("failed to read Lua file: %s", err) + } + + signed, err := xlua.SignPrivilegedScript(code, *signKey) + if err != nil { + base.Fatalf("failed to sign Lua file: %s", err) + } + + if *signOutput == "-" { + if _, err := os.Stdout.Write(signed); err != nil { + base.Fatalf("failed to write signed Lua file: %s", err) + } + return + } + output := *signOutput + if output == "" { + output = args[0] + } + if err := writeSignedFile(output, signed, info.Mode().Perm()); err != nil { + base.Fatalf("failed to write signed Lua file: %s", err) + } +} + +func writeSignedFile(path string, code []byte, mode os.FileMode) error { + if info, err := os.Stat(path); err == nil { + if !info.Mode().IsRegular() { + return &os.PathError{Op: "write", Path: path, Err: os.ErrInvalid} + } + mode = info.Mode().Perm() + path, err = filepath.EvalSymlinks(path) + if err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + + file, err := os.CreateTemp(filepath.Dir(path), ".xray-lua-sign-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + defer file.Close() + if _, err := file.Write(code); err != nil { + return err + } + if err := file.Chmod(mode); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(file.Name(), path) +} diff --git a/main/commands/base/env.go b/main/commands/base/env.go index 27ba0b1c3..9d431479e 100644 --- a/main/commands/base/env.go +++ b/main/commands/base/env.go @@ -4,6 +4,8 @@ package base type CommandEnvHolder struct { // Executable name of current binary Exec string + // Full name of the command being executed + CommandName string // commands column width of current command CommandsWidth int } diff --git a/main/commands/base/execute.go b/main/commands/base/execute.go index 8e23dabfa..32844781a 100644 --- a/main/commands/base/execute.go +++ b/main/commands/base/execute.go @@ -61,6 +61,7 @@ BigCmdLoop: } buildCommandText(cmd) + CommandEnv.CommandName = cmdName cmd.Run(cmd, args) Exit() return diff --git a/main/confloader/external/external.go b/main/confloader/external/external.go index 02066109b..412890bb2 100644 --- a/main/confloader/external/external.go +++ b/main/confloader/external/external.go @@ -8,12 +8,14 @@ import ( "net/http" "net/url" "os" + "path/filepath" "strings" "time" "github.com/xtls/xray-core/common/buf" "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/utils" + "github.com/xtls/xray-core/main/commands/base" "github.com/xtls/xray-core/main/confloader" ) @@ -31,7 +33,12 @@ func ConfigLoader(arg string) (out io.Reader, err error) { data, err = io.ReadAll(os.Stdin) default: - data, err = os.ReadFile(arg) + file, openErr := os.Open(arg) + if openErr != nil { + return nil, openErr + } + defer file.Close() + data, err = readConfigFile(file) } if err != nil { @@ -41,6 +48,33 @@ func ConfigLoader(arg string) (out io.Reader, err error) { return } +func readConfigFile(file *os.File) ([]byte, error) { + if base.CommandEnv.CommandName != "run" { + return io.ReadAll(file) + } + + info, err := file.Stat() + if err != nil { + return nil, err + } + if info.Mode().IsRegular() { + path, err := filepath.Abs(file.Name()) + if err != nil { + return nil, err + } + path, err = filepath.EvalSymlinks(path) + if err != nil { + return nil, err + } + switch strings.ToLower(filepath.Ext(path)) { + case ".jsonc", ".json", ".yml", ".yaml", ".toml", ".pb", ".protobuf": + default: + return nil, errors.New("unsupported config file extension: ", path) + } + } + return io.ReadAll(file) +} + // FetchHTTPContent issues an HTTP GET against either a regular HTTP(S) URL // or a Unix socket HTTP endpoint. // diff --git a/main/run.go b/main/run.go index b87ca6356..6ca9afbc5 100644 --- a/main/run.go +++ b/main/run.go @@ -36,10 +36,6 @@ The -confdir=dir flag sets a dir with multiple json config The -format=json flag sets the format of config files. Default "auto". -The config root env object sets process environment variables after all config -files are parsed. Variables needed to locate or parse config files must be set -in the process environment before Xray starts. - The -test flag tells Xray to test config files only, without launching the server. @@ -47,6 +43,9 @@ The -dump flag tells Xray to print the merged config. `, } +// The --lua-privileged-pubkey=key flag sets the trusted Ed25519 public key +// for privileged local Lua scripts, encoded as unpadded Base64URL. + func init() { cmdRun.Run = executeRun // break init loop log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds) @@ -58,6 +57,8 @@ var ( dump = cmdRun.Flag.Bool("dump", false, "Dump merged config only, without launching Xray server.") test = cmdRun.Flag.Bool("test", false, "Test config file only, without launching Xray server.") format = cmdRun.Flag.String("format", "auto", "Format of input file.") + // TODO: Restore this flag and its help text when Lua privileged signature authorization is re-enabled. + // luaPrivilegedPublicKey = cmdRun.Flag.String("lua-privileged-pubkey", "", "Unpadded Base64URL Ed25519 public key for privileged local Lua scripts.") /* We have to do this here because Golang's Test will also need to parse flag, before * main func in this file is run. @@ -214,6 +215,11 @@ func getConfigFormat() string { } func startXray() (core.Server, error) { + // TODO: Restore public key initialization when Lua privileged signature authorization is re-enabled. + // if err := lua.SetPrivilegedPublicKey(*luaPrivilegedPublicKey); err != nil { + // return nil, errors.New("failed to configure Lua privileged authorization").Base(err) + // } + configFiles := getConfigFilePath(true) c, err := core.LoadConfig(getConfigFormat(), configFiles) diff --git a/main/run_test.go b/main/run_test.go new file mode 100644 index 000000000..d442bb038 --- /dev/null +++ b/main/run_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "flag" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRunConfigCommands(t *testing.T) { + if os.Getenv("XRAY_TEST_CONFIG_COMMAND") == "1" { + os.Args = append([]string{os.Args[0]}, flag.Args()...) + main() + return + } + dir := t.TempDir() + config := filepath.Join(dir, "config.yaml") + script := filepath.Join(dir, "config.lua") + binary := filepath.Join(dir, "config.pb") + message := filepath.Join(dir, "user.tmsg") + for path, body := range map[string]string{ + config: "{}", + script: "{}", + binary: "", + message: `{"type":"xray.app.dispatcher.Config","value":""}`, + } { + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + file, err := os.Open(script) + if err != nil { + t.Fatal(err) + } + defer file.Close() + for _, tc := range []struct { + name string + args []string + code int + input io.Reader + }{ + {"format override", []string{"run", "-test", "-format", "json", "-c", config}, 0, nil}, + {"invalid extension", []string{"run", "-test", "-format", "json", "-c", script}, 23, nil}, + {"default command", []string{"-test", "-format", "json", "-c", script}, 23, nil}, + {"protobuf config", []string{"run", "-test", "-c", binary}, 0, nil}, + {"protobuf error", []string{"run", "-test", "-format", "protobuf", "-c", script}, 23, nil}, + {"merged source", []string{"run", "-test", "-format", "json", "-c", config, "-c", script}, 23, nil}, + {"dump", []string{"run", "-dump", "-c", config}, 0, nil}, + {"stdin pipe", []string{"run", "-test", "-c", "stdin:"}, 0, nil}, + {"stdin file", []string{"run", "-test", "-c", "stdin:"}, 0, file}, + {"convert json", []string{"convert", "json", message}, 0, nil}, + {"convert pb", []string{"convert", "pb", "-debug", config}, 0, nil}, + {"convert pb stdin", []string{"convert", "pb", "-debug", "stdin:"}, 0, file}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + args := append([]string{"-test.run=^TestRunConfigCommands$", "--"}, tc.args...) + cmd := exec.CommandContext(ctx, os.Args[0], args...) + cmd.Env = append(os.Environ(), "XRAY_TEST_CONFIG_COMMAND=1") + cmd.Stdin = strings.NewReader("{}") + if tc.input != nil { + if _, err := file.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + cmd.Stdin = tc.input + } + output, err := cmd.CombinedOutput() + if cmd.ProcessState == nil { + t.Fatal(err) + } + if code := cmd.ProcessState.ExitCode(); code != tc.code { + t.Fatalf("exit code = %d, want %d: %v\n%s", code, tc.code, err, output) + } + if tc.code == 23 && !strings.Contains(string(output), "failed to load config files") { + t.Fatalf("missing config error:\n%s", output) + } + }) + } +}