mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
258 lines
10 KiB
Lua
258 lines
10 KiB
Lua
-- 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, mod:list 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, mod:list "
|
|
.. "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, mod:read and mod:list",
|
|
-- newThread's state has a full standard library and none of this file's
|
|
-- rules, so it stays blocked -- but the reason mods reached for it was
|
|
-- background work, and mod.fetch is that without the escape.
|
|
thread = 'mod.fetch for background HTTP (needs the "network" permission)',
|
|
system = "mod.device:powerInfo() for battery information, mod.steps for "
|
|
.. "the step bridge", event = true,
|
|
}
|
|
|
|
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
|
|
-- by that mod's own overlay and must not be shared.
|
|
local function loveFacade(compat)
|
|
if not _G.love then return nil end
|
|
local overrides = compat and compat.love
|
|
return setmetatable({}, {
|
|
__index = function(_, key)
|
|
local override = overrides and overrides[key]
|
|
if override ~= nil then return override end
|
|
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,
|
|
-- a callback chain lands on the real table (compat.assign decides which
|
|
-- names qualify); a module table never does
|
|
__newindex = function(_, key, value)
|
|
if compat then
|
|
local allowed, reason = compat.assign(key, value)
|
|
if allowed then
|
|
_G.love[key] = value
|
|
return
|
|
end
|
|
if reason then error(reason, 2) end
|
|
end
|
|
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
|
|
end,
|
|
})
|
|
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, compat)
|
|
return function(name, ...)
|
|
-- the compat stand-in answers first, so a legacy require("io") gets the
|
|
-- rerouted table instead of the denial below (src/mods/LegacyCompat.lua)
|
|
local substitute = compat and compat.module(name)
|
|
if substitute ~= nil then return substitute end
|
|
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 compat = opts.compat
|
|
local env = baseGlobals()
|
|
env.love = loveFacade(compat)
|
|
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
|
|
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
|
|
if compat then
|
|
for key, value in pairs(compat.globals) do env[key] = value end
|
|
for key, value in pairs(compat.os) do env.os[key] = value end
|
|
compat.bind(env, function(source, chunkname)
|
|
return Sandbox.compile(source, chunkname, env)
|
|
end)
|
|
end
|
|
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
|