Compare commits

...
21 Commits
Author SHA1 Message Date
Meo597 b408f7f0c7 lua: add recursive read-only bindings for borrowed slices 2026-10-15 06:36:12 +08:00
Meo597 ef4f4888df lua: allow os time functions in sandbox 2026-10-15 04:33:35 +08:00
Meo597 c2a9b074f4 lua: redact memory addresses in sandbox errors 2026-10-15 03:57:24 +08:00
Meo597 9726bbc9d7 lua: prevent memory address leaks through sandbox string.format and refine comments 2026-10-15 03:07:54 +08:00
Meo597 6cd0c8a414 lua: preserve native Stringer and error output in sandbox tostring() 2026-10-15 02:28:36 +08:00
Meo597 796bbd763a lua: prevent configuration files from being executed as scripts 2026-10-15 01:34:40 +08:00
Meo597 e04191e65b lua: validate script extension after resolving symlinks 2026-10-14 23:12:30 +08:00
Meo597 bd28a87f0b geodata: prevent downloaded assets from compiling as Lua 2026-10-14 22:29:56 +08:00
Meo597 a04770587e geodata: validate entry and code tags before matching 2026-10-14 22:04:54 +08:00
Meo597 eb9c6030ab geodata: reject entry lengths exceeding asset size 2026-10-14 22:03:08 +08:00
Meo597 e4124d3dbb geodata: stricter varint overflow checks 2026-10-14 21:36:24 +08:00
Meo597 4c0f3a94c5 disable inline lua 2026-10-14 10:18:40 +08:00
Meo597 19842ed0c2 luar: whitelist 2026-10-14 09:49:58 +08:00
Meo597 34efc32ff0 sandbox: disable more func 2026-10-14 09:16:32 +08:00
Meo597 4828ed8270 cmd: add Ed25519 key generation and Lua script signing 2026-10-14 08:14:05 +08:00
Meo597 e84f3065dc fix format 2026-10-14 07:07:03 +08:00
Meo597 fa9dc21a55 Replace privileged PSK with Ed25519 signature verification 2026-10-14 06:57:32 +08:00
Meo597 590a5236bb Reduce memory usage 2026-10-13 12:31:04 +08:00
Meo597 1aae252fc3 Lua: Restrict PSK privileges to local Lua files 2026-10-13 11:38:06 +08:00
Meo597 20c4c8f956 Lua: Hide package from sandboxed scripts 2026-10-13 11:24:47 +08:00
Meo597 4c571f5e06 Lua: Add sandbox mechanism 2026-10-13 09:52:31 +08:00
26 changed files with 2245 additions and 55 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ func luaServers(s *DNS) []luaDNSServer {
func registerLua(L *lua.LState, servers []luaDNSServer, client featureDNS.Client) { func registerLua(L *lua.LState, servers []luaDNSServer, client featureDNS.Client) {
L.PreloadModule("xray.dns", func(L *lua.LState) int { 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) serverList := L.CreateTable(len(servers), 0)
for i, client := range servers { for i, client := range servers {
+7
View File
@@ -23,6 +23,9 @@ import (
const idleTimeout = 30 * time.Second const idleTimeout = 30 * time.Second
// DO NOT EDIT. Ignored by geodata, rejected by Lua.
const luaGuardHeader = "\x12\x01\x00"
type stage struct { type stage struct {
target string target string
temp 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 { if err := d.fetch(asset.Url, temp); err != nil {
temp.Close() temp.Close()
return stage{}, err return stage{}, err
+73
View File
@@ -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)
}
}
})
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ func (r *Router) RegisterLua(L *lua.LState) {
} }
func registerLuaContext(L *lua.LState) { func registerLuaContext(L *lua.LState) {
pushIPs := xlua.NewSlicePusher[net.IP](L) pushIPs := xlua.NewReadOnlySlicePusher[net.IP](L)
attributes := L.NewTypeMetatable(luaAttributesType) attributes := L.NewTypeMetatable(luaAttributesType)
L.SetField(attributes, "__index", L.NewFunction(func(L *lua.LState) int { L.SetField(attributes, "__index", L.NewFunction(func(L *lua.LState) int {
values := L.CheckUserData(1).Value.(map[string]string) values := L.CheckUserData(1).Value.(map[string]string)
+19 -14
View File
@@ -17,12 +17,12 @@ import (
) )
func checkFile(file, code string) error { func checkFile(file, code string) error {
r, err := filesystem.OpenAsset(file) r, size, err := filesystem.OpenAsset(file)
if err != nil { if err != nil {
return errors.New("failed to open ", file).Base(err) return errors.New("failed to open ", file).Base(err)
} }
defer r.Close() 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 errors.New("failed to check code ", code, " from ", file).Base(err)
} }
return nil return nil
@@ -30,12 +30,12 @@ func checkFile(file, code string) error {
func loadFile(file, code string) ([]byte, error) { func loadFile(file, code string) ([]byte, error) {
runtime.GC() // peak mem runtime.GC() // peak mem
r, err := filesystem.OpenAsset(file) r, size, err := filesystem.OpenAsset(file)
if err != nil { if err != nil {
return nil, errors.New("failed to open ", file).Base(err) return nil, errors.New("failed to open ", file).Base(err)
} }
defer r.Close() defer r.Close()
bs, err := find(r, []byte(code), true) bs, err := find(r, []byte(code), true, size)
if err != nil { if err != nil {
return nil, errors.New("failed to load code ", code, " from ", file).Base(err) 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. // unmarshalling it into a []*Domain, so value is only valid during fn.
func loadSite(file, code, attrs string, fn func(Domain_Type, []byte)) error { func loadSite(file, code, attrs string, fn func(Domain_Type, []byte)) error {
runtime.GC() // peak mem runtime.GC() // peak mem
r, err := filesystem.OpenAsset(file) r, size, err := filesystem.OpenAsset(file)
if err != nil { if err != nil {
return errors.New("failed to open ", file).Base(err) return errors.New("failed to open ", file).Base(err)
} }
defer r.Close() defer r.Close()
br := bufio.NewReaderSize(r, 64*1024) br := bufio.NewReaderSize(r, 64*1024)
n, err := seek(br, []byte(code)) n, err := seek(br, []byte(code), size)
if err != nil { if err != nil {
return errors.New("failed to load code ", code, " from ", file).Base(err) 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 { if err != nil {
return 0, err return 0, err
} }
if shift == 63 && b > 1 {
break // Only one value bit fits in the tenth byte.
}
x |= (uint64(b) & 0x7F) << shift x |= (uint64(b) & 0x7F) << shift
if (b & 0x80) == 0 { if (b & 0x80) == 0 {
return x, nil return x, nil
@@ -123,9 +126,9 @@ func decodeVarint(br *bufio.Reader) (uint64, error) {
return 0, errors.New("varint overflow") 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) br := bufio.NewReaderSize(r, 64*1024)
bodyL, err := seek(br, code) bodyL, err := seek(br, code, size)
if err != nil || !readBody { if err != nil || !readBody {
return nil, err 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. // 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) codeL := len(code)
if codeL == 0 { if codeL == 0 {
return 0, errors.New("empty code") 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 need := 2 + codeL // TODO: if code too long
for { for {
if _, err := br.ReadByte(); err != nil { tag, err := br.ReadByte()
if err != nil {
return 0, err return 0, err
} }
@@ -153,10 +157,10 @@ func seek(br *bufio.Reader, code []byte) (int, error) {
if err != nil { if err != nil {
return 0, err return 0, err
} }
bodyL := int(x) if x == 0 || x > uint64(size) || x > uint64(^uint(0)>>1) {
if bodyL <= 0 { return 0, errors.New("invalid body length: ", x)
return 0, errors.New("invalid body length: ", bodyL)
} }
bodyL := int(x)
// Peek no more than the buffer holds: a code longer than the buffer cannot match a single // 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. // 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 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 return bodyL, nil
} }
if _, err := br.Discard(bodyL); err != nil { if _, err := br.Discard(bodyL); err != nil {
+53
View File
@@ -1,6 +1,7 @@
package geodata package geodata
import ( import (
"bytes"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -12,6 +13,58 @@ import (
"google.golang.org/protobuf/proto" "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 siteEntry struct {
Type Domain_Type Type Domain_Type
Value string Value string
+2 -2
View File
@@ -44,8 +44,8 @@ func RegisterLua(L *lua.LState) {
"Match": luaIPMatch, "Match": luaIPMatch,
"AnyMatch": luaIPAnyMatch, "AnyMatch": luaIPAnyMatch,
"Matches": luaIPMatches, "Matches": luaIPMatches,
"FilterIPs": newLuaIPFilterIPs(xlua.NewSlicePusher[net.IP](L)), "FilterIPs": newLuaIPFilterIPs(xlua.NewReadOnlySlicePusher[net.IP](L)),
}) }, "SetReverse", "ToggleReverse")
return 1 return 1
})) }))
+6
View File
@@ -78,6 +78,7 @@ local geodata = require("xray.geodata")
local matcher = geodata.BuildIPMatcher("127.0.0.0/8") local matcher = geodata.BuildIPMatcher("127.0.0.0/8")
assert(matcher.Match == matcher.match and matcher.AnyMatch == matcher.anyMatch) assert(matcher.Match == matcher.match and matcher.AnyMatch == matcher.anyMatch)
assert(matcher.Matches == matcher.matches and matcher.FilterIPs == matcher.filterIPs) assert(matcher.Matches == matcher.matches and matcher.FilterIPs == matcher.filterIPs)
assert(matcher.Reload == nil and matcher.reload == nil)
assert(matcher:match(ip)) assert(matcher:match(ip))
assert(matcher:anyMatch({ip}) and matcher:matches({ip})) assert(matcher:anyMatch({ip}) and matcher:matches({ip}))
assert(not matcher:AnyMatch(nil)) assert(not matcher:AnyMatch(nil))
@@ -97,6 +98,11 @@ assert(matcher.missing == nil)
local domain = geodata.BuildDomainMatcher("full:example.com") local domain = geodata.BuildDomainMatcher("full:example.com")
assert(domain.Match == domain.match and domain.MatchAny == domain.matchAny) 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:matchAny("example.com"))
assert(#domain:Match("example.com") == 1) assert(#domain:Match("example.com") == 1)
assert(domain:match("example.com")[1] == 0) assert(domain:match("example.com")[1] == 0)
@@ -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)
}
}
+77 -3
View File
@@ -1,6 +1,9 @@
package lua package lua
import ( import (
"net"
"reflect"
glua "github.com/yuin/gopher-lua" glua "github.com/yuin/gopher-lua"
luar "layeh.com/gopher-luar" luar "layeh.com/gopher-luar"
) )
@@ -22,21 +25,88 @@ 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.
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")
methods.ForEach(func(key, method glua.LValue) {
if method == appendText {
methods.RawSet(key, glua.LNil)
}
})
visit(reflect.TypeFor[net.IPMask]())
}
visit(typ.Elem())
}
visit(reflect.TypeFor[S]())
}
// DirectMethod handles a Lua call without luar's reflected method invocation. // 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 returns the result count and whether it handled the arguments. On false,
// it must leave the stack unchanged for the original luar wrapper. // it must leave the stack unchanged for the original luar wrapper.
type DirectMethod func(L *glua.LState) (nresults int, handled bool) 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. // PushWithDirectMethods pushes a luar userdata with typed Go method bindings.
// Handled calls bypass luar's argument conversion and reflect.Call; method lookup // Handled calls bypass luar's argument conversion and reflect.Call; method lookup
// uses the methods table directly instead of luar's reflected __index handler. // uses the methods table directly instead of luar's reflected __index handler.
// value must expose methods only. Bindings and their closures are installed once // Only directMethods and additionalMethods are exposed, including their aliases.
// per Go type per LState, outside the method-call hot path. // Bindings and their closures are installed once per Go type per LState, outside
func PushWithDirectMethods(L *glua.LState, value any, directMethods map[string]DirectMethod) { // 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) userdata := luar.New(L, value).(*glua.LUserData)
metatable := userdata.Metatable.(*glua.LTable) metatable := userdata.Metatable.(*glua.LTable)
methods := metatable.RawGetString("methods").(*glua.LTable) methods := metatable.RawGetString("methods").(*glua.LTable)
if metatable.RawGetString("__index") != methods { 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 { for name, direct := range directMethods {
original := methods.RawGetString(name) original := methods.RawGetString(name)
fn := L.NewFunction(func(L *glua.LState) int { fn := L.NewFunction(func(L *glua.LState) int {
@@ -52,6 +122,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) metatable.RawSetString("__index", methods)
} }
L.Push(userdata) L.Push(userdata)
+135
View File
@@ -47,6 +47,141 @@ 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 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)
readOnly := luar.MT(L, ips[0]).RawGetString("__newindex")
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)
}
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) { func TestSlicePusherMetatablePerState(t *testing.T) {
first := glua.NewState() first := glua.NewState()
defer first.Close() defer first.Close()
+117
View File
@@ -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
}
+82
View File
@@ -0,0 +1,82 @@
package lua
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)
}
}
}
+160 -4
View File
@@ -3,11 +3,14 @@ package lua
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"os" "os"
"regexp"
"strings" "strings"
"time" "time"
"github.com/xtls/xray-core/common/base64" "github.com/xtls/xray-core/common/base64"
"github.com/xtls/xray-core/common/errors"
"github.com/xtls/xray-core/common/platform" "github.com/xtls/xray-core/common/platform"
glua "github.com/yuin/gopher-lua" glua "github.com/yuin/gopher-lua"
"github.com/yuin/gopher-lua/parse" "github.com/yuin/gopher-lua/parse"
@@ -15,7 +18,8 @@ import (
// Program holds immutable bytecode that can be run by independent LStates. // Program holds immutable bytecode that can be run by independent LStates.
type Program struct { type Program struct {
proto *glua.FunctionProto proto *glua.FunctionProto
privileged bool
} }
// LStateFactory returns a fully initialized state or nil and an error. // LStateFactory returns a fully initialized state or nil and an error.
@@ -27,10 +31,15 @@ func Compile(script string) (*Program, error) {
var code []byte var code []byte
var err error var err error
name := "[inline]" name := "[inline]"
privileged := false
if strings.HasPrefix(script, base64.GzipPrefix) || strings.HasPrefix(script, base64.Prefix) { 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) code, err = base64.Decode(script)
} else if name, err = platform.ResolveLuaFile(script); err == nil { } else if name, err = platform.ResolveLuaFile(script); err == nil {
code, err = os.ReadFile(name) if code, err = os.ReadFile(name); err == nil {
privileged, err = verifyPrivilegedSignature(code)
}
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -44,14 +53,19 @@ func Compile(script string) (*Program, error) {
if err != nil { if err != nil {
return nil, err 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 // NewState creates a state, runs register, executes the program under ctx, and
// runs validate. It removes the initialization context before returning a state // runs validate. It removes the initialization context before returning a state
// owned by the caller. // owned by the caller.
func (p *Program) NewState(ctx context.Context, register func(*glua.LState), validate func(*glua.LState) error) (*glua.LState, error) { func (p *Program) NewState(ctx context.Context, register func(*glua.LState), validate func(*glua.LState) error) (*glua.LState, error) {
L := glua.NewState() var L *glua.LState
if p.privileged {
L = glua.NewState()
} else {
L = newSandboxedState()
}
valid := false valid := false
defer func() { defer func() {
if !valid { if !valid {
@@ -63,6 +77,10 @@ func (p *Program) NewState(ctx context.Context, register func(*glua.LState), val
if register != nil { if register != nil {
register(L) 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)) L.Push(L.NewFunctionFromProto(p.proto))
// Execute the Lua script's top level. // Execute the Lua script's top level.
if err := L.PCall(0, 0, nil); err != nil { if err := L.PCall(0, 0, nil); err != nil {
@@ -77,6 +95,144 @@ func (p *Program) NewState(ctx context.Context, register func(*glua.LState), val
return L, nil return L, nil
} }
func newSandboxedState() *glua.LState {
L := glua.NewState(glua.Options{SkipOpenLibs: true})
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 := "<opaque>"
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 {
return glua.LString(redactMemoryAddresses(string(message)))
}
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)
osLib.ForEach(func(key, _ glua.LValue) {
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 {
return memoryAddressPattern.ReplaceAllString(message, "<address>")
}
// NewStateFactory returns a factory that gives each state an initialization timeout. // 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 { func (p *Program) NewStateFactory(initTimeout time.Duration, register func(*glua.LState), validate func(*glua.LState) error) LStateFactory {
return func(ctx context.Context) (*glua.LState, error) { return func(ctx context.Context) (*glua.LState, error) {
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
package platform
import (
"io"
"os"
"path/filepath"
)
type configFile struct {
path string
info os.FileInfo
}
var configFiles []configFile
// ReadConfigFile reads a config source and records local regular files.
func ReadConfigFile(file *os.File) ([]byte, error) {
info, err := file.Stat()
if err != nil {
return nil, err
}
path := ""
if info.Mode().IsRegular() && file != os.Stdin {
path, err = filepath.Abs(file.Name())
if err != nil {
return nil, err
}
path, err = filepath.EvalSymlinks(path)
if err != nil {
return nil, err
}
}
data, err := io.ReadAll(file)
if err != nil {
return nil, err
}
if info.Mode().IsRegular() {
configFiles = append(configFiles, configFile{path: path, info: info})
}
return data, nil
}
func isConfigFile(path string, info os.FileInfo) bool {
for _, file := range configFiles {
if file.path == path || os.SameFile(file.info, info) {
return true
}
}
return false
}
+5 -4
View File
@@ -34,12 +34,13 @@ func ReadAsset(file string) ([]byte, error) {
return ReadFile(path) return ReadFile(path)
} }
func OpenAsset(file string) (io.ReadCloser, error) { func OpenAsset(file string) (io.ReadCloser, int64, error) {
path, _, err := getAssetFileLocation(file) path, info, err := getAssetFileLocation(file)
if err != nil { 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) { func StatAsset(file string) (os.FileInfo, error) {
+30 -8
View File
@@ -93,7 +93,7 @@ func GetConfDirPath() string {
return configPath 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. // Relative paths: XRAY_LOCATION_CONFDIR > XRAY_LOCATION_CONFIG > working dir > executable dir.
func ResolveLuaFile(path string) (string, error) { func ResolveLuaFile(path string) (string, error) {
if path == "" { if path == "" {
@@ -113,15 +113,37 @@ func ResolveLuaFile(path string) (string, error) {
} }
} }
} }
return resolveFile(paths) path, info, err := resolveFile(paths)
if err != nil {
return "", err
}
path, err = resolveLuaTarget(path)
if err != nil {
return "", err
}
if isConfigFile(path, info) {
return "", fmt.Errorf("Lua file was loaded as a configuration file: %s", path)
}
return path, nil
} }
func resolveFile(paths []string) (string, error) { 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, os.FileInfo, error) {
var tried []string var tried []string
for _, path := range paths { for _, path := range paths {
path, err := filepath.Abs(path) path, err := filepath.Abs(path)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to resolve file path: %w", err) return "", nil, fmt.Errorf("failed to resolve file path: %w", err)
} }
tried = append(tried, path) tried = append(tried, path)
info, err := os.Stat(path) info, err := os.Stat(path)
@@ -129,12 +151,12 @@ func resolveFile(paths []string) (string, error) {
continue continue
} }
if err != nil { if err != nil {
return "", fmt.Errorf("failed to inspect file %q: %w", path, err) return "", nil, fmt.Errorf("failed to inspect file %q: %w", path, err)
} }
if !info.Mode().IsRegular() { if !info.Mode().IsRegular() {
return "", fmt.Errorf("file is not a regular file: %s", path) return "", nil, fmt.Errorf("file is not a regular file: %s", path)
} }
return path, nil return path, info, nil
} }
return "", fmt.Errorf("file not found; tried %q: %w", tried, os.ErrNotExist) return "", nil, fmt.Errorf("file not found; tried %q: %w", tried, os.ErrNotExist)
} }
+37
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"testing" "testing"
"github.com/xtls/xray-core/common" "github.com/xtls/xray-core/common"
@@ -88,6 +89,10 @@ func TestResolveLuaFile(t *testing.T) {
for _, path := range paths[:3] { for _, path := range paths[:3] {
common.Must(os.WriteFile(path, nil, 0o600)) 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] { if got, err := ResolveLuaFile(paths[2]); err != nil || got != paths[2] {
t.Fatalf("absolute path = %q, %v; want %q", got, err, 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)
}
})
}
}
+194
View File
@@ -0,0 +1,194 @@
package serial_test
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
xlua "github.com/xtls/xray-core/common/lua"
"github.com/xtls/xray-core/core"
"github.com/xtls/xray-core/infra/conf"
"github.com/xtls/xray-core/infra/conf/serial"
_ "github.com/xtls/xray-core/main/confloader/external"
glua "github.com/yuin/gopher-lua"
)
func TestConfigSourceSecurity(t *testing.T) {
// Keep registered files alive until every file identity check finishes.
dir := t.TempDir()
t.Run("polyglots", testConfigLuaPolyglots)
t.Run("merged", func(t *testing.T) { testMergedConfigCannotRunAsLua(t, dir) })
t.Run("hardlink", func(t *testing.T) { testConfigHardLinkCannotRunAsLua(t, dir) })
t.Run("stdin", func(t *testing.T) { testStdinConfigCannotRunAsLua(t, dir) })
}
func testConfigLuaPolyglots(t *testing.T) {
const luaBody = "polyglotExecuted = true\nfunction HandleRoute() return 'direct' end\n"
for _, tc := range []struct {
name string
decode func(io.Reader) (*conf.Config, error)
source string
script string
}{
{"JSON", serial.DecodeJSONConfig, "null = 0\n" + luaBody, ""},
{"YAML", serial.DecodeYAMLConfig, `--[[: ignored
routing:
script: payload.lua
--]] --: ignored
_G: assert((function() polyglotExecuted = true; function HandleRoute() return 'direct' end end)())
`, "payload.lua"},
{"TOML", serial.DecodeTOMLConfig, `routing = { script = "payload.lua" }
-- = """
polyglotExecuted = true
function HandleRoute() return 'direct' end
-- """
`, "payload.lua"},
} {
t.Run(tc.name, func(t *testing.T) {
config, err := tc.decode(strings.NewReader(tc.source))
if err != nil {
t.Fatal(err)
}
script := ""
if config.RouterConfig != nil {
script = config.RouterConfig.Script
}
if script != tc.script {
t.Fatalf("routing.script = %q, want %q", script, tc.script)
}
if built, err := config.Build(); err != nil || built == nil {
t.Fatalf("config Build failed: %v", err)
}
if _, err := serial.DecodeJSONConfigStrict(strings.NewReader(tc.source)); err == nil {
t.Fatal("strict JSON accepted the polyglot")
}
dir := t.TempDir()
t.Setenv("xray.location.confdir", dir)
if err := os.WriteFile(filepath.Join(dir, "payload.lua"), []byte(tc.source), 0o600); err != nil {
t.Fatal(err)
}
configPath := filepath.Join(dir, "payload."+strings.ToLower(tc.name))
if err := os.WriteFile(configPath, []byte(tc.source), 0o600); err != nil {
t.Fatal(err)
}
if program, err := xlua.Compile(configPath); program != nil || err == nil || !strings.Contains(err.Error(), "Lua file extension must be .lua") {
t.Fatalf("Compile(%q) = %v, %v; want .lua extension rejection", configPath, program, err)
}
program, err := xlua.Compile("payload.lua")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
state, err := program.NewState(ctx, nil, nil)
if err != nil {
t.Fatal(err)
}
defer state.Close()
if state.GetGlobal("polyglotExecuted") != glua.LTrue {
t.Fatal("Lua top-level code did not execute")
}
if err := state.CallByParam(glua.P{Fn: state.GetGlobal("HandleRoute"), NRet: 1, Protect: true}); err != nil {
t.Fatal(err)
}
if got := state.Get(-1); got != glua.LString("direct") {
t.Fatalf("HandleRoute() = %v, want direct", got)
}
t.Logf("config built with routing.script=%q; the same bytes executed as Lua", script)
})
}
}
func testMergedConfigCannotRunAsLua(t *testing.T, dir string) {
t.Setenv("xray.location.confdir", dir)
base := filepath.Join(dir, "base.lua")
for format, body := range map[string]string{
"json": "null = 0\nfunction HandleRoute() return 'direct' end\n",
"yaml": "_G: assert((function() function HandleRoute() return 'direct' end end)())\n",
"toml": "-- = \"\"\"\nfunction HandleRoute() return 'direct' end\n-- \"\"\"\n",
} {
t.Run(format, func(t *testing.T) {
path := filepath.Join(dir, format+".lua")
if err := os.WriteFile(base, []byte(`{"routing":{"script":"`+format+`.lua"}}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if program, err := xlua.Compile(path); err != nil || program == nil {
t.Fatalf("fixture must compile before config loading: %v", err)
}
if _, err := serial.BuildConfig([]*core.ConfigSource{
{Name: base, Format: "json"},
{Name: path, Format: format},
}); err != nil {
t.Fatal(err)
}
for _, source := range []string{base, path} {
if program, err := xlua.Compile(source); program != nil || err == nil || !strings.Contains(err.Error(), "loaded as a configuration file") {
t.Fatalf("Compile(%q) = %v, %v; want config source rejection", source, program, err)
}
}
if err := os.Rename(path, path+".old"); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("function HandleRoute() end"), 0o600); err != nil {
t.Fatal(err)
}
if program, err := xlua.Compile(path); program != nil || err == nil || !strings.Contains(err.Error(), "loaded as a configuration file") {
t.Fatalf("Compile = %v, %v; want replaced config path rejection", program, err)
}
})
}
path := filepath.Join(dir, "ordinary.lua")
if err := os.WriteFile(path, []byte("function HandleRoute() end"), 0o600); err != nil {
t.Fatal(err)
}
if program, err := xlua.Compile(path); err != nil || program == nil {
t.Fatalf("ordinary script in the config directory must remain usable: %v", err)
}
}
func testConfigHardLinkCannotRunAsLua(t *testing.T, dir string) {
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte("null = 0\nfunction HandleRoute() end"), 0o600); err != nil {
t.Fatal(err)
}
alias := filepath.Join(dir, "alias.lua")
if err := os.Link(path, alias); err != nil {
t.Fatal(err)
}
if _, err := serial.BuildConfig([]*core.ConfigSource{{Name: path, Format: "json"}}); err != nil {
t.Fatal(err)
}
if program, err := xlua.Compile(alias); program != nil || err == nil || !strings.Contains(err.Error(), "loaded as a configuration file") {
t.Fatalf("Compile = %v, %v; want config hard link rejection", program, err)
}
}
func testStdinConfigCannotRunAsLua(t *testing.T, dir string) {
path := filepath.Join(dir, "config.lua")
if err := os.WriteFile(path, []byte("null = 0\nfunction HandleRoute() end"), 0o600); err != nil {
t.Fatal(err)
}
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
originalStdin := os.Stdin
os.Stdin = file
t.Cleanup(func() {
os.Stdin = originalStdin
file.Close()
})
if _, err := serial.BuildConfig([]*core.ConfigSource{{Name: "stdin:", Format: "json"}}); err != nil {
t.Fatal(err)
}
if program, err := xlua.Compile(path); program != nil || err == nil || !strings.Contains(err.Error(), "loaded as a configuration file") {
t.Fatalf("Compile = %v, %v; want redirected config rejection", program, err)
}
}
+3
View File
@@ -4,6 +4,7 @@ import (
"github.com/xtls/xray-core/main/commands/all/api" "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/base64"
"github.com/xtls/xray-core/main/commands/all/convert" "github.com/xtls/xray-core/main/commands/all/convert"
"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/all/tls"
"github.com/xtls/xray-core/main/commands/base" "github.com/xtls/xray-core/main/commands/base"
) )
@@ -14,9 +15,11 @@ func init() {
api.CmdAPI, api.CmdAPI,
base64.CmdBase64, base64.CmdBase64,
convert.CmdConvert, convert.CmdConvert,
lua.CmdLua,
tls.CmdTLS, tls.CmdTLS,
cmdUUID, cmdUUID,
cmdX25519, cmdX25519,
cmdEd25519,
cmdWG, cmdWG,
cmdMLDSA65, cmdMLDSA65,
cmdMLKEM768, cmdMLKEM768,
+53
View File
@@ -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)
}
}
+13
View File
@@ -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,
},
}
+98
View File
@@ -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] <input-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)
}
+8 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/xtls/xray-core/common/buf" "github.com/xtls/xray-core/common/buf"
"github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/errors"
"github.com/xtls/xray-core/common/platform"
"github.com/xtls/xray-core/common/utils" "github.com/xtls/xray-core/common/utils"
"github.com/xtls/xray-core/main/confloader" "github.com/xtls/xray-core/main/confloader"
) )
@@ -28,10 +29,15 @@ func ConfigLoader(arg string) (out io.Reader, err error) {
data, err = FetchHTTPContent(arg) data, err = FetchHTTPContent(arg)
case arg == "stdin:": case arg == "stdin:":
data, err = io.ReadAll(os.Stdin) data, err = platform.ReadConfigFile(os.Stdin)
default: default:
data, err = os.ReadFile(arg) file, openErr := os.Open(arg)
if openErr != nil {
return nil, openErr
}
defer file.Close()
data, err = platform.ReadConfigFile(file)
} }
if err != nil { if err != nil {
+13 -8
View File
@@ -17,6 +17,7 @@ import (
"github.com/xtls/xray-core/common/cmdarg" "github.com/xtls/xray-core/common/cmdarg"
"github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/errors"
clog "github.com/xtls/xray-core/common/log" clog "github.com/xtls/xray-core/common/log"
"github.com/xtls/xray-core/common/lua"
"github.com/xtls/xray-core/common/platform" "github.com/xtls/xray-core/common/platform"
"github.com/xtls/xray-core/core" "github.com/xtls/xray-core/core"
"github.com/xtls/xray-core/main/commands/base" "github.com/xtls/xray-core/main/commands/base"
@@ -36,9 +37,8 @@ The -confdir=dir flag sets a dir with multiple json config
The -format=json flag sets the format of config files. The -format=json flag sets the format of config files.
Default "auto". Default "auto".
The config root env object sets process environment variables after all config The --lua-privileged-pubkey=key flag sets the trusted Ed25519 public key
files are parsed. Variables needed to locate or parse config files must be set for privileged local Lua scripts, encoded as unpadded Base64URL.
in the process environment before Xray starts.
The -test flag tells Xray to test config files only, The -test flag tells Xray to test config files only,
without launching the server. without launching the server.
@@ -53,11 +53,12 @@ func init() {
} }
var ( var (
configFiles cmdarg.Arg // "Config file for Xray.", the option is customed type, parse in main configFiles cmdarg.Arg // "Config file for Xray.", the option is customed type, parse in main
configDir string configDir string
dump = cmdRun.Flag.Bool("dump", false, "Dump merged config only, without launching Xray server.") 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.") test = cmdRun.Flag.Bool("test", false, "Test config file only, without launching Xray server.")
format = cmdRun.Flag.String("format", "auto", "Format of input file.") format = cmdRun.Flag.String("format", "auto", "Format of input file.")
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 /* We have to do this here because Golang's Test will also need to parse flag, before
* main func in this file is run. * main func in this file is run.
@@ -214,6 +215,10 @@ func getConfigFormat() string {
} }
func startXray() (core.Server, error) { func startXray() (core.Server, error) {
if err := lua.SetPrivilegedPublicKey(*luaPrivilegedPublicKey); err != nil {
return nil, errors.New("failed to configure Lua privileged authorization").Base(err)
}
configFiles := getConfigFilePath(true) configFiles := getConfigFilePath(true)
c, err := core.LoadConfig(getConfigFormat(), configFiles) c, err := core.LoadConfig(getConfigFormat(), configFiles)