mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
208 lines
8.6 KiB
Lua
208 lines
8.6 KiB
Lua
-- 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.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local T = require("tests.modkit")
|
|
local Manifest = require("src.mods.Manifest")
|
|
local Sandbox = require("src.mods.Sandbox")
|
|
local SafePath = require("src.mods.SafePath")
|
|
|
|
local function manifest(id, extra)
|
|
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
|
|
.. '"api":2%s}'):format(id, id, extra or "")
|
|
end
|
|
|
|
-- what a probe reports back; pcall'd so one broken assumption does not take
|
|
-- the whole entry chunk down and hide the rest
|
|
local PROBE = [[
|
|
local mod = ...
|
|
local out = mod.exports
|
|
out.io = io
|
|
out.package = package
|
|
out.dofile = dofile
|
|
out.loadfile = loadfile
|
|
out.setfenv = setfenv
|
|
out.getfenv = getfenv
|
|
out.debug = debug
|
|
out.osGetenv = os.getenv
|
|
out.osExecute = os.execute
|
|
out.osRemove = os.remove
|
|
out.osTime = type(os.time)
|
|
out.stringOk = ("a"):rep(3)
|
|
|
|
local function attempt(fn, ...)
|
|
local ok, err = pcall(fn, ...)
|
|
if ok then return false end
|
|
return tostring(err)
|
|
end
|
|
out.requireIo = attempt(require, "io")
|
|
out.requireOs = attempt(require, "os")
|
|
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 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.childSharesEnv = childG == _G
|
|
|
|
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
|
|
out.readAbsolute = attempt(function() return mod:read("/etc/hosts") end)
|
|
out.readBackslash = attempt(function() return mod:read("..\\secret.txt") end)
|
|
out.assetsEscape = attempt(function() return mod.assets:path("../../x.png") end)
|
|
out.readOwn = mod:read("data/note.txt")
|
|
|
|
_G.SANDBOX_LEAK = "escaped"
|
|
out.globalsAreOwn = _G ~= nil and _G.SANDBOX_LEAK == "escaped"
|
|
-- a mod stomping the standard library must not reach the engine
|
|
table.insert = function() error("stomped") end
|
|
string.format = function() error("stomped") end
|
|
]]
|
|
|
|
local FILES = {
|
|
["mods/fix_sandbox/manifest.json"] = manifest("fix_sandbox"),
|
|
["mods/fix_sandbox/main.lua"] = PROBE,
|
|
["mods/fix_sandbox/data/note.txt"] = "own file",
|
|
}
|
|
|
|
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
|
|
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
|
|
|
|
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.stringOk, "aaa", "the safe standard library is intact")
|
|
|
|
-- ------- require, the one call that would undo all of the above
|
|
|
|
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")
|
|
T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
|
|
"a network module 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 ~= false, "love.system is refused: openURL launches anything")
|
|
T.eq(out.loveGraphics, "table", "the rest of love passes through")
|
|
T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")
|
|
|
|
-- ------- env propagation and isolation
|
|
|
|
T.eq(out.childIo, nil,
|
|
"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.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")
|
|
T.eq(("%d"):format(1), "1",
|
|
"a mod stomping string.format cannot reach the engine's copy")
|
|
do
|
|
local probe = {}
|
|
table.insert(probe, "still works")
|
|
T.eq(probe[1], "still works",
|
|
"nor table.insert -- each mod gets its own standard-library namespace")
|
|
end
|
|
|
|
-- ------- paths
|
|
|
|
T.check(out.readEscape and out.readEscape:find("must stay inside", 1, true),
|
|
"mod:read cannot climb out of the mod directory: " .. tostring(out.readEscape))
|
|
T.check(out.readAbsolute ~= false, "mod:read refuses an absolute path")
|
|
T.check(out.readBackslash ~= false, "mod:read refuses a backslash climb")
|
|
T.check(out.assetsEscape ~= false, "mod.assets:path refuses a climb")
|
|
T.eq(out.readOwn, "own file", "and the mod's own files still read")
|
|
run.release()
|
|
|
|
-- ------- the grammar itself
|
|
|
|
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
|
|
"..\\x", "a\\b", "..", ".", "" }) do
|
|
T.eq(SafePath.safe(bad), nil, ("SafePath rejects %q"):format(bad))
|
|
end
|
|
T.eq(SafePath.safe("maps/NEW_BARK_TOWN.lua"), "maps/NEW_BARK_TOWN.lua",
|
|
"an ordinary relative path passes")
|
|
T.eq(SafePath.safe("./main.lua"), "main.lua",
|
|
"a leading ./ is normalized rather than rejected, so older manifests load")
|
|
|
|
-- ------- manifest paths are untrusted input too
|
|
|
|
T.check(not pcall(Manifest.validate,
|
|
{ id = "evil", name = "evil", version = "1.0.0", entry = "../../../evil.lua" }),
|
|
"a manifest cannot point entry outside the mod directory")
|
|
T.check(not pcall(Manifest.validate,
|
|
{ id = "evil", name = "evil", version = "1.0.0", entry = "main.lua",
|
|
options_schema = "../../options.lua" }),
|
|
"nor options_schema")
|
|
T.check(pcall(Manifest.validate,
|
|
{ id = "fine", name = "fine", version = "1.0.0", entry = "main.lua" }),
|
|
"an ordinary manifest still validates")
|
|
|
|
-- ------- bytecode
|
|
|
|
do
|
|
local bad = {
|
|
["mods/fix_bytecode/manifest.json"] = manifest("fix_bytecode"),
|
|
["mods/fix_bytecode/main.lua"] = string.dump(function() end),
|
|
}
|
|
local bytecodeRun = T.sdk.loadMods({ "mods/fix_bytecode" },
|
|
{ fs = T.sdk.memfs(bad) })
|
|
T.eq(#bytecodeRun.errors, 1, "a mod that ships bytecode fails to load")
|
|
T.check(tostring(bytecodeRun.errors[1]):find("bytecode", 1, true),
|
|
"and says why: " .. tostring(bytecodeRun.errors[1]))
|
|
bytecodeRun.release()
|
|
end
|
|
|
|
-- ------- the sandbox is not opt-in
|
|
|
|
do
|
|
local env = Sandbox.envFor({ modId = "probe" })
|
|
T.eq(env.io, nil, "a bare Sandbox.envFor is already closed")
|
|
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
|
|
|
|
T.finish("sandbox")
|