mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 03:02:39 +02:00
grandmas-kitchn
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
-- The environment a mod's own code runs in. Every chunk a mod authors -- the
|
||||
-- entry file, an options_schema, anything it hands to load() -- runs against
|
||||
-- this table instead of _G, so the only paths it can name are the ones the
|
||||
-- engine hands it (mod:read, mod.storage, mod.assets).
|
||||
--
|
||||
-- What this is and is not: raw io/os/ffi are the only way to name a file
|
||||
-- outside the game tree at all, and they are absent here, so the reported
|
||||
-- "any mod can rewrite anything in your home directory" hole closes by
|
||||
-- construction. Inside the LÖVE tree this is defense in depth, not a security
|
||||
-- boundary: an engine module reached through require, or ImageData:encode,
|
||||
-- still writes in the save directory.
|
||||
--
|
||||
-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm
|
||||
-- exists because AssetTransform's sandbox needed it and getting this wrong
|
||||
-- silently hands the chunk the real globals.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local Sandbox = {}
|
||||
|
||||
-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no
|
||||
-- matter what this file removes from the environment. package.loaded.io is
|
||||
-- the one call that would undo every other rule here.
|
||||
local DENIED = {
|
||||
io = "the filesystem", os = "the filesystem", debug = "the debug library",
|
||||
package = "the module loader", ffi = "arbitrary C calls",
|
||||
}
|
||||
|
||||
-- Same idea one level up: love.filesystem is reachable by name, and
|
||||
-- love.thread starts a Lua state this sandbox has no say over.
|
||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
|
||||
|
||||
-- The wire, which is what the network permission governs.
|
||||
local NETWORK = { socket = true, enet = true, http = true, https = true,
|
||||
ssl = true, mime = true, ltn12 = true }
|
||||
|
||||
local function head(name)
|
||||
return (name:match("^([^%.]+)")) or name
|
||||
end
|
||||
|
||||
-- nil when the require is allowed, else the message to fail it with.
|
||||
function Sandbox.moduleDenial(name, permissionSet)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local root = head(name)
|
||||
local reason = DENIED[root]
|
||||
if reason then
|
||||
return ("%s is not available to mods (it grants %s); use mod.storage, "
|
||||
.. "mod:read and the engine API instead"):format(name, reason)
|
||||
end
|
||||
if DENIED_PREFIX[root] and name ~= root then
|
||||
return ("%s is not available to mods; use mod.storage, mod:read and the "
|
||||
.. "engine API instead"):format(name)
|
||||
end
|
||||
if NETWORK[root] and not (permissionSet or {}).network then
|
||||
return ("%s needs the \"network\" permission in manifest.json"):format(name)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ------- the love facade
|
||||
|
||||
-- Dropped, not narrowed: filesystem writes anywhere in the save directory
|
||||
-- (including another mod's storage), thread opens a Lua state with a full
|
||||
-- standard library, system.openURL launches whatever it is handed, and event
|
||||
-- lets a mod quit the game out from under the player. Everything else LÖVE
|
||||
-- exposes passes through, so a new module in a future LÖVE is available
|
||||
-- without an edit here.
|
||||
-- value is the replacement to name in the error, or true when there is none
|
||||
local BLOCKED_LOVE = {
|
||||
filesystem = "mod.storage and mod:read", thread = true,
|
||||
system = true, event = true,
|
||||
}
|
||||
|
||||
local loveProxy
|
||||
local function loveFacade()
|
||||
if loveProxy or not _G.love then return loveProxy end
|
||||
loveProxy = setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
local hint = BLOCKED_LOVE[key]
|
||||
if hint then
|
||||
error(("love.%s is not available to mods%s"):format(key,
|
||||
type(hint) == "string" and (", use " .. hint) or ""), 2)
|
||||
end
|
||||
return _G.love[key]
|
||||
end,
|
||||
__newindex = function(_, key)
|
||||
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
|
||||
end,
|
||||
})
|
||||
return loveProxy
|
||||
end
|
||||
|
||||
-- ------- the environment
|
||||
|
||||
-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug,
|
||||
-- newproxy, module. os keeps only the clock -- getenv is how the reported
|
||||
-- exploit found the user's home directory.
|
||||
local SAFE_OS = { time = true, date = true, clock = true, difftime = true }
|
||||
|
||||
-- Per-mod copies, not the shared tables: a mod that assigns string.trim or
|
||||
-- replaces table.insert changes its own view and nobody else's. The functions
|
||||
-- are the same objects, so state behind them (math.randomseed's RNG) is
|
||||
-- unaffected -- only the namespace is private.
|
||||
local function copy(source)
|
||||
if type(source) ~= "table" then return source end
|
||||
local out = {}
|
||||
for key, value in pairs(source) do out[key] = value end
|
||||
return out
|
||||
end
|
||||
|
||||
local function baseGlobals()
|
||||
local safeOs = {}
|
||||
for key in pairs(SAFE_OS) do safeOs[key] = os[key] end
|
||||
return {
|
||||
assert = assert, error = error, ipairs = ipairs, next = next,
|
||||
pairs = pairs, pcall = pcall, xpcall = xpcall, select = select,
|
||||
tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
|
||||
rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen,
|
||||
setmetatable = setmetatable, getmetatable = getmetatable, print = print,
|
||||
collectgarbage = collectgarbage, _VERSION = _VERSION,
|
||||
coroutine = copy(coroutine), math = copy(math), string = copy(string),
|
||||
table = copy(table), bit = copy(bit), jit = jit, os = safeOs,
|
||||
}
|
||||
end
|
||||
|
||||
-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so
|
||||
-- a caller there compiles through Sandbox.compile instead.
|
||||
function Sandbox.bind(chunk, env)
|
||||
if setfenv then setfenv(chunk, env) end
|
||||
return chunk
|
||||
end
|
||||
|
||||
-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out
|
||||
-- of environments. Mods ship source.
|
||||
local function rejectBytecode(source, what)
|
||||
if type(source) == "string" and source:sub(1, 1) == "\27" then
|
||||
return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Sandbox.compile(source, chunkname, env)
|
||||
local ok, err = rejectBytecode(source, chunkname)
|
||||
if not ok then return nil, err end
|
||||
if setfenv then
|
||||
local chunk, compileErr = loadstring(source, chunkname)
|
||||
if not chunk then return nil, compileErr end
|
||||
return setfenv(chunk, env)
|
||||
end
|
||||
return load(source, chunkname, "t", env)
|
||||
end
|
||||
|
||||
-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment
|
||||
-- rather than the caller's, so without this every sandboxed mod is one
|
||||
-- load(mod:read(...)) away from the real _G -- which is exactly how the
|
||||
-- multi-file mods in mods/ are written.
|
||||
local function sandboxedLoad(env)
|
||||
return function(chunk, chunkname)
|
||||
if type(chunk) == "function" then
|
||||
local parts = {}
|
||||
while true do
|
||||
local piece = chunk()
|
||||
if piece == nil or piece == "" then break end
|
||||
parts[#parts + 1] = piece
|
||||
end
|
||||
chunk = table.concat(parts)
|
||||
end
|
||||
if type(chunk) ~= "string" then return nil, "load expects a string or reader" end
|
||||
return Sandbox.compile(chunk, chunkname or "=(load)", env)
|
||||
end
|
||||
end
|
||||
|
||||
-- The require a mod sees: the deny list lives here rather than on a stack
|
||||
-- walk, because pcall(require, "io") puts a C frame where the walk would look.
|
||||
-- Runtime.modRequire is how the loader's gate identifies the caller for the
|
||||
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
|
||||
-- lazily from an event handler).
|
||||
local function sandboxedRequire(modId, permissionSet)
|
||||
return function(name, ...)
|
||||
local denial = Sandbox.moduleDenial(name, permissionSet)
|
||||
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
|
||||
local previous = Runtime.modRequire
|
||||
Runtime.modRequire = modId or true
|
||||
local ok, result = pcall(_G.require, name, ...)
|
||||
Runtime.modRequire = previous
|
||||
if not ok then error(result, 0) end
|
||||
return result
|
||||
end
|
||||
end
|
||||
|
||||
function Sandbox.envFor(opts)
|
||||
opts = opts or {}
|
||||
local env = baseGlobals()
|
||||
env.love = loveFacade()
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions)
|
||||
local loader = sandboxedLoad(env)
|
||||
env.load = loader
|
||||
env.loadstring = loader
|
||||
-- a mod's globals are its own: two mods no longer share a namespace, and
|
||||
-- neither can reach the engine's
|
||||
env._G = env
|
||||
return env
|
||||
end
|
||||
|
||||
-- fs.load keeps the real filesystem's handling of the file; the environment is
|
||||
-- swapped after the fact. The 5.2+ arm has to go back to source, which is the
|
||||
-- only reason fs.read is touched here.
|
||||
function Sandbox.loadFile(fs, path, env)
|
||||
if fs.read then
|
||||
local ok, err = rejectBytecode(fs.read(path), path)
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
if setfenv then
|
||||
local chunk, err = fs.load(path)
|
||||
if not chunk then return nil, err end
|
||||
return setfenv(chunk, env)
|
||||
end
|
||||
local source = fs.read and fs.read(path)
|
||||
if not source then return nil, "unable to read " .. path end
|
||||
return Sandbox.compile(source, "@" .. path, env)
|
||||
end
|
||||
|
||||
Sandbox.safePath = SafePath.safe
|
||||
Sandbox.requirePath = SafePath.require
|
||||
|
||||
return Sandbox
|
||||
Reference in New Issue
Block a user