legcompat

This commit is contained in:
bryanthaboi
2026-08-15 06:04:04 -04:00
parent 7804ef9793
commit 43cbc554c3
8 changed files with 1274 additions and 76 deletions
+204 -55
View File
@@ -1,7 +1,8 @@
-- T4: the mod sandbox (src/mods/Sandbox.lua). A mod's own chunks run against
-- an environment with no io, no os beyond the clock, and no way to name a path
-- outside its own directory, so a mod cannot reach the player's filesystem.
-- Every case here is an escape a mod would actually try.
-- T4: the mod sandbox (src/mods/Sandbox.lua) and the compat reroute over it
-- (src/mods/LegacyCompat.lua). A mod's own chunks still cannot name a path
-- outside their own directory: the pre-sandbox globals are back as stand-ins
-- whose reads come from the mod's own files and whose writes land in a private
-- per-mod overlay. Every case here is an escape a mod would actually try.
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -9,6 +10,7 @@ local T = require("tests.modkit")
local Manifest = require("src.mods.Manifest")
local Sandbox = require("src.mods.Sandbox")
local SafePath = require("src.mods.SafePath")
local LegacyCompat = require("src.mods.LegacyCompat")
local function manifest(id, extra)
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
@@ -20,16 +22,14 @@ end
local PROBE = [[
local mod = ...
local out = mod.exports
out.io = io
out.package = package
out.dofile = dofile
out.loadfile = loadfile
out.io = type(io)
out.package = type(package)
out.dofile = type(dofile)
out.loadfile = type(loadfile)
out.setfenv = setfenv
out.getfenv = getfenv
out.debug = debug
out.osGetenv = os.getenv
out.osExecute = os.execute
out.osRemove = os.remove
out.osGetenv = type(os.getenv)
out.osTime = type(os.time)
out.stringOk = ("a"):rep(3)
@@ -38,30 +38,86 @@ local PROBE = [[
if ok then return false end
return tostring(err)
end
out.requireIo = attempt(require, "io")
out.requireOs = attempt(require, "os")
out.requireIoIsShim = select(2, pcall(require, "io")) == io
out.requireLoveFsIsShim =
select(2, pcall(require, "love.filesystem")) == love.filesystem
out.requireDebug = attempt(require, "debug")
out.requirePackage = attempt(require, "package")
out.requireFfi = attempt(require, "ffi")
out.requireLoveFs = attempt(require, "love.filesystem")
out.requireSocket = attempt(require, "socket")
-- called from a nested Lua frame rather than straight off pcall, which is
-- the shape a stack-walking gate reads differently
out.requireIoNested = attempt(function() return require("io") end)
out.requireSemver = select(2, pcall(require, "src.mods.Semver"))
out.loveFilesystem = attempt(function() return love.filesystem end)
out.loveThread = attempt(function() return love.thread end)
out.loveSystem = attempt(function() return love.system end)
out.loveGraphics = type(love.graphics)
out.loveAssign = attempt(function() love.filesystem = {} end)
-- the callback chain a mod wrapping the mouse writes: it has to reach the
-- real love table or the wrap silently never fires
out.chainedInner = false
local inner = love.mousemoved
love.mousemoved = function(...)
out.chainedInner = true
if inner then return inner(...) end
end
out.assignRun = attempt(function() love.run = function() end end)
out.assignGarbage = attempt(function() love.mousemoved = 7 end)
out.powerInfo = type(love.system.getPowerInfo)
out.openUrl = love.system.openURL("https://example.com")
out.eventQuit = love.event.quit()
out.popen = select(1, io.popen("ls"))
-- the reroute: a write anywhere a mod used to name must land in the mod's
-- own overlay, and reading it back must see the write and nothing else
local escape = io.open("/etc/hosts", "w")
out.escapeOpened = escape ~= nil
if escape then
escape:write("pwned")
escape:close()
end
local reread = io.open("/etc/hosts", "r")
out.escapeReadBack = reread and reread:read("*a") or nil
if reread then reread:close() end
out.homeEnv = os.getenv("HOME")
out.saveDir = love.filesystem.getSaveDirectory()
love.filesystem.write("cfg/settings.txt", "x=1\ny=2\n")
out.roundTrip = love.filesystem.read("cfg/settings.txt")
out.roundTripInfo = love.filesystem.getInfo("cfg/settings.txt")
local lines = {}
for line in love.filesystem.lines("cfg/settings.txt") do
lines[#lines + 1] = line
end
out.roundTripLines = lines
love.filesystem.append("cfg/settings.txt", "z=3\n")
out.appended = love.filesystem.read("cfg/settings.txt")
-- an absolute path built off the reported save directory comes back to the
-- same overlay, which is what a legacy mod's own path joining does
love.filesystem.write(out.saveDir .. "/cfg/settings.txt", "rooted")
out.rootedRead = love.filesystem.read("cfg/settings.txt")
-- the mod's own packaged files still read through the old call
out.ownThroughLove = love.filesystem.read("mods/fix_sandbox/data/note.txt")
out.ownThroughIo = (function()
local f = io.open("data/note.txt", "r")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end)()
-- copy-on-write: writing over a packaged path shadows it, it does not
-- rewrite the shipped file
love.filesystem.write("mods/fix_sandbox/data/note.txt", "shadowed")
out.shadowed = love.filesystem.read("mods/fix_sandbox/data/note.txt")
out.shadowedOwn = mod:read("data/note.txt")
-- the multi-file pattern mods/timekeepers_hut uses: a chunk loaded from the
-- mod's own source must inherit the sandbox, not the real globals
local child = load("return io, os.getenv, _G")
local childIo, childGetenv, childG = child()
out.childIo = childIo
out.childGetenv = childGetenv
out.childIoIsShim = childIo == io
out.childGetenvIsShim = childGetenv == os.getenv
out.childSharesEnv = childG == _G
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
@@ -95,58 +151,121 @@ local FILES = {
["mods/fix_sandbox/assets/sprites/walk.png"] = "png",
}
LegacyCompat.reset()
local savedMouseMoved = love.mousemoved
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
local installedMouseMoved = love.mousemoved
love.mousemoved = savedMouseMoved
T.eq(#run.errors, 0,
"the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
local out = run.loader.exports.fix_sandbox or {}
-- ------- the standard library a mod does not get
-- ------- the pre-sandbox globals are stand-ins, not the real thing
T.eq(out.io, nil, "io is absent from the mod environment")
T.eq(out.package, nil, "package is absent, so package.loaded is unreachable")
T.eq(out.dofile, nil, "dofile is absent")
T.eq(out.loadfile, nil, "loadfile is absent")
T.eq(out.setfenv, nil, "setfenv is absent, so a mod cannot swap its own env")
T.eq(out.getfenv, nil, "getfenv is absent, so a mod cannot read the real _G out")
T.eq(out.debug, nil, "the debug library is absent")
T.eq(out.osGetenv, nil, "os.getenv is absent -- it is how the report's exploit "
.. "found the user's home directory")
T.eq(out.osExecute, nil, "os.execute is absent")
T.eq(out.osRemove, nil, "os.remove is absent")
T.eq(out.osTime, "function", "os.time still works: the clock is not the hole")
T.eq(out.io, "table", "io is present again, as the compat stand-in")
T.eq(out.package, "table", "so is package, as an inert stub")
T.eq(out.dofile, "function", "dofile routes through the stand-in")
T.eq(out.loadfile, "function", "so does loadfile")
T.eq(out.osGetenv, "function", "os.getenv answers rather than crashing the mod")
T.eq(out.osTime, "function", "os.time still works: the clock was never the hole")
T.eq(out.stringOk, "aaa", "the safe standard library is intact")
-- ------- require, the one call that would undo all of the above
-- what stays gone: there is no rerouted stand-in for these, so faking one
-- would be the hole rather than a compat shim
T.eq(out.setfenv, nil, "setfenv is still absent, so a mod cannot swap its own env")
T.eq(out.getfenv, nil, "getfenv is still absent, so a mod cannot read the real _G out")
T.eq(out.debug, nil, "the debug library is still absent")
T.check(out.loveThread ~= false, "love.thread is still refused: it opens a full Lua state")
T.check(out.requireFfi ~= false, "require(\"ffi\") is still refused: it is arbitrary C")
T.check(out.requireDebug ~= false, "require(\"debug\") is still refused")
T.check(out.requirePackage ~= false, "require(\"package\") is still refused")
T.eq(out.popen, nil, "io.popen refuses rather than spawning a process")
T.eq(out.openUrl, false, "love.system.openURL does nothing")
T.eq(out.eventQuit, false, "love.event.quit cannot close the game on the player")
T.check(out.requireIo and out.requireIo:find("not available to mods", 1, true),
"require(\"io\") is refused: " .. tostring(out.requireIo))
T.check(out.requireOs ~= false, "require(\"os\") is refused")
T.check(out.requireDebug ~= false, "require(\"debug\") is refused")
T.check(out.requirePackage ~= false, "require(\"package\") is refused")
T.check(out.requireFfi ~= false, "require(\"ffi\") is refused: it is arbitrary C")
T.check(out.requireLoveFs ~= false, "require(\"love.filesystem\") is refused")
T.check(out.requireIoNested ~= false,
"require(\"io\") from a nested frame is refused the same way")
-- ------- require answers with the same stand-ins
T.check(out.requireIoIsShim, "require(\"io\") hands back the same compat table")
T.check(out.requireLoveFsIsShim,
"require(\"love.filesystem\") hands back the same compat table")
T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
"a network module names the permission it needs: " .. tostring(out.requireSocket))
"a network module still names the permission it needs: " .. tostring(out.requireSocket))
T.eq(type(out.requireSemver), "table",
"the supported engine requires still resolve")
-- ------- the love facade
T.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true),
"love.filesystem is refused and names the replacement")
T.check(out.loveThread ~= false, "love.thread is refused: it opens a full Lua state")
T.check(out.loveSystem and out.loveSystem:find("mod.device:powerInfo()", 1, true),
"love.system is refused and names the scoped power replacement")
T.eq(out.loveGraphics, "table", "the rest of love passes through")
T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")
T.check(out.loveAssign ~= false,
"a mod cannot replace a love module table: " .. tostring(out.loveAssign))
T.eq(out.powerInfo, "function",
"love.system reads through to the same information mod.device exposes")
-- a wrapped callback has to land on the real table or the wrap never fires
T.eq(type(installedMouseMoved), "function",
"love.mousemoved assigned by a mod reaches the real love table")
T.check(installedMouseMoved ~= savedMouseMoved,
"and it is the mod's wrapper, not the one that was there")
T.check(out.assignRun and out.assignRun:find("fixed-step loop", 1, true),
"love.run stays refused: it is the engine's own loop ("
.. tostring(out.assignRun) .. ")")
T.check(out.assignGarbage ~= false,
"and a callback slot only takes a function")
-- ------- containment: every rerouted write lands in the mod's own overlay
T.check(out.escapeOpened, "io.open on an absolute path outside the tree opens")
T.eq(out.escapeReadBack, "pwned", "and reads its own write back")
T.eq(FILES["/etc/hosts"], nil,
"but nothing was written outside the game tree")
T.eq(FILES["mod_compat/fix_sandbox/etc/hosts"], "pwned",
"the bytes went to this mod's private overlay instead")
T.eq(out.homeEnv, "/pokeport/fix_sandbox",
"os.getenv(\"HOME\") answers with the mod's virtual root, not the real one")
T.eq(out.saveDir, "/pokeport/fix_sandbox",
"and so does the reported save directory")
T.eq(out.roundTrip, "x=1\ny=2\n", "a love.filesystem write reads back")
T.eq(out.roundTripInfo and out.roundTripInfo.type, "file",
"and getInfo sees it")
T.same(out.roundTripLines, { "x=1", "y=2" }, "lines() walks it")
T.eq(out.appended, "x=1\ny=2\nz=3\n", "append extends it")
T.eq(out.rootedRead, "rooted",
"a path joined to the reported save directory routes to the same key")
T.eq(FILES["mod_compat/fix_sandbox/cfg/settings.txt"], "rooted",
"one key in the overlay, under the mod's own id, however it was named")
-- ------- reads still see the mod's packaged files
T.eq(out.ownThroughLove, "own file",
"love.filesystem.read of a path inside the mod reads the shipped file")
T.eq(out.ownThroughIo, "own file", "and so does io.open on a relative path")
T.eq(out.shadowed, "shadowed", "a write over a packaged path shadows it")
T.eq(FILES["mods/fix_sandbox/data/note.txt"], "own file",
"without rewriting what the mod shipped")
T.eq(out.shadowedOwn, "own file",
"and mod:read still reports the packaged bytes")
-- ------- the reroute is reported, not silent
do
local report = run.loader:legacyReport("fix_sandbox")
local calls = {}
for _, row in ipairs(report) do calls[row.call] = row end
T.check(calls["io.open"], "io.open is recorded against the mod")
T.check(calls["love.filesystem.write"], "so is love.filesystem.write")
T.check(calls["os.getenv"], "and os.getenv")
T.check(calls["love.filesystem.write"].count >= 2,
"with a count, so a manager can rank the worst offenders")
T.check(calls["io.open"].advice and #calls["io.open"].advice > 0,
"each row carries the advice the warning printed")
end
-- ------- env propagation and isolation
T.eq(out.childIo, nil,
T.check(out.childIoIsShim,
"a chunk a mod load()s inherits the sandbox (5.1 would hand it the real _G)")
T.eq(out.childGetenv, nil, "the child chunk gets the same reduced os")
T.check(out.childGetenvIsShim, "the child chunk gets the same rerouted os")
T.check(out.childSharesEnv, "the child chunk shares the mod's own globals table")
T.check(out.globalsAreOwn, "a mod's globals write to its own table")
T.eq(_G.SANDBOX_LEAK, nil, "and never reach the engine's _G")
@@ -187,6 +306,36 @@ T.check(out.infoEscape and out.infoEscape:find("must stay inside", 1, true),
"mod:info cannot climb either")
run.release()
-- ------- two mods never share an overlay
do
local files = {
["mods/one/manifest.json"] = manifest("one"),
["mods/one/main.lua"] = [[
local mod = ...
love.filesystem.write("shared.txt", "from one")
mod.exports.mine = love.filesystem.read("shared.txt")
]],
["mods/two/manifest.json"] = manifest("two"),
["mods/two/main.lua"] = [[
local mod = ...
mod.exports.peek = love.filesystem.read("shared.txt")
mod.exports.climb = love.filesystem.read("../one/shared.txt")
]],
}
local pair = T.sdk.loadMods({ "mods/one", "mods/two" },
{ fs = T.sdk.memfs(files) })
T.eq(#pair.errors, 0, "both mods load (" .. tostring(pair.errors[1]) .. ")")
T.eq(pair.loader.exports.one.mine, "from one", "the first mod sees its write")
T.eq(pair.loader.exports.two.peek, nil,
"the second mod, naming the same path, sees nothing")
T.eq(files["mod_compat/one/shared.txt"], "from one",
"because the overlay is keyed by mod id")
T.eq(pair.loader.exports.two.climb, nil,
"and a climb out of the overlay resolves inside it, not into the neighbour")
pair.release()
end
-- ------- the grammar itself
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
@@ -226,11 +375,11 @@ do
bytecodeRun.release()
end
-- ------- the sandbox is not opt-in
-- ------- the sandbox with no compat layer is still closed
do
local env = Sandbox.envFor({ modId = "probe" })
T.eq(env.io, nil, "a bare Sandbox.envFor is already closed")
T.eq(env.io, nil, "a bare Sandbox.envFor has no io")
T.eq(env._G, env, "_G points at the sandbox, not the real globals")
T.check(not pcall(env.require, "io"), "and its require refuses io")
end