mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 16:21:30 +02:00
legcompat
This commit is contained in:
+204
-55
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user