Browse Source

Initial commit

master
luk3yx 5 months ago
commit
099a8972f1
7 changed files with 805 additions and 0 deletions
  1. +22
    -0
      LICENSE.md
  2. +89
    -0
      README.md
  3. +255
    -0
      csm/init.lua
  4. +176
    -0
      init.lua
  5. +87
    -0
      minify.lua
  6. +114
    -0
      sscsm_init.lua
  7. +62
    -0
      sscsm_testing.lua

+ 22
- 0
LICENSE.md View File

@@ -0,0 +1,22 @@

# The MIT License (MIT)

Copyright © 2019 by luk3yx.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

+ 89
- 0
README.md View File

@@ -0,0 +1,89 @@
# Minetest server-sent CSM proof-of-concept

Attempts to run server-sent CSMs locally in a sandbox.

## How it works

Any client with the CSM installed will automatically attempt to request SSCSMs
from the server via a mod channel. If the server has this mod installed, it
will reply with a few messages containing the mod name and partially minified
mod code. The CSM will then create a separate environment so SSCSMs cannot mess
with existing CSMs (and so CSMs do not accidentally interfere with SSCSMs), and
execute the SSCSMs inside this environment. *Note that it is trivial for users
to modify this environment.* The server-side mod sends two "built-in" SSCSMs
before and after all other SSCSMs to add extra helper functions (in the `sscsm`
namespace), to execute `register_on_mods_loaded` callbacks and attempt to leave
the mod channel.

## Instructions

To create a SSCSM:

- Install this mod onto a server.
- Enable mod channels on the server (add `enable_mod_channels = true` to
minetest.conf).
- Create SSCSMs with the [API](#api).
- Install the CSM (in the `csm/` directory) onto clients and enable it.

## Server-side mod facing API

*This API is subject to change.*

### `sscsm.register(def)`

Registers a server-provided CSM with the following definition table.

- `name` *(string)*: The name of the server-provided CSM. Please use the
`modname:sscsmname` convention. Cannot start with a colon or contain
newlines.
- `code` *(string)*: The code to be sent to clients.
- `file` *(string)*: The file to read the code from, read during the
`register()` call.
- `depends` *(list)*: A list of SSCSMs that must be loaded before this one.

This definition table must have `name` and either `code` or `file`.

#### Maximum SSCSM size

Because of Minetest network protocol limitations, the amount of data that can
be sent over mod channels is limited, and therefore the maximum SSCSM size is
65300 (to leave room for the player name and future expansion). The name of the
SSCSM also counts towards this total.

Because of this size limitation, SSCSMs are passed through a primitive code
minifier that removes some whitespace and comments, so even if your code is
above this size limit it could still work.

## Server-sent CSM facing API

SSCSMs can access most functions on [client_lua_api.txt](https://github.com/minetest/minetest/blob/master/doc/client_lua_api.txt), as well as a separate `sscsm` namespace:

- `sscsm.global_exists(name)`: The same as `minetest.global_exists`.
- `sscsm.register_on_mods_loaded(callback)`: Runs the callback once all SSCSMs
are loaded.
- `sscsm.register_chatcommand(...)`: Similar to
`minetest.register_chatcommand`, however overrides commands starting in `/`
instead. This can be used to make some commands have instantaneous
responses. The command handler is only added once `register_chatcommand`
has been called.
- `sscsm.unregister_chatcommand(name)`: Unregisters a chatcommand.

To communicate with the server-side mods, it is possible to open a mod
channel.

## Security considerations

Do not trust any input sent to the server via SSCSMs (and do not store
sensitive data in SSCSM code), as malicious users can and will inspect code and
modify the output from SSCSMs.

I repeat, **do not trust the client** and/or SSCSMs with any sensitive
information and do not trust any output from the client and/or SSCSMs. Make
sure to rerun any privilege checks on the server.

### Other recommendations

Although it is possible to kick clients that do not support SSCSMs, this has
not been implemented. Some users may not want to allow servers to automatically
download and run code locally for security reasons. Please try and make sure
clients without SSCSMs do not suffer from major functionality loss.

+ 255
- 0
csm/init.lua View File

@@ -0,0 +1,255 @@
--
-- SSCSM: Server-Sent Client-Side Mods proof-of-concept
--
-- © 2019 by luk3yx
--

-- For debugging, this can be a global variable.
local sscsm = {}

-- Load the Env class
-- Mostly copied from https://stackoverflow.com/a/26367080
-- Don't copy metatables
local function copy(obj, s)
if s and s[obj] ~= nil then return s[obj] end
if type(obj) ~= 'table' then return obj end
s = s or {}
local res = {}
s[obj] = res
for k, v in pairs(obj) do res[copy(k, s)] = copy(v, s) end
return res
end

-- Safe functions
local Env = {}
local safe_funcs = {}

-- No getmetatable()
if rawget(_G, 'getmetatable') then
safe_funcs[getmetatable] = function() end
end

-- Get the current value of string.rep in case other CSMs decide to break
do
local rep = string.rep
safe_funcs[string.rep] = function(str, n)
if #str * n > 1048576 then
error('string.rep: string length overflow', 2)
end
return rep(str, n)
end

local show_formspec = minetest.show_formspec
safe_funcs[show_formspec] = function(formname, ...)
if type(formname) == 'string' and formname:sub(1, 6) ~= 'sscsm:' then
return show_formspec(formname, ...)
end
end

local after = minetest.after
safe_funcs[after] = function(n, ...)
if type(n) == 'number' then return after(n, pcall, ...) end
end

local on_fs_input = minetest.register_on_formspec_input
safe_funcs[on_fs_input] = function(func)
on_fs_input(function(formname, fields)
if formname:sub(1, 6) ~= 'sscsm:' then
pcall(func, formname, copy(fields))
end
end)
end

local wrap = function(n)
local orig = minetest[n] or minetest[n .. 's']
if type(orig) == 'function' then
return function(func)
orig(function(...)
local r = {pcall(func, ...)}
if r[1] then
table.remove(r, 1)
return (table.unpack or unpack)(r)
else
minetest.log('error', '[SSCSM] ' .. tostring(r[2]))
end
end)
end
end
end

for _, k in ipairs({'register_globalstep', 'register_on_death',
'register_on_hp_modification', 'register_on_damage_taken',
'register_on_dignode', 'register_on_punchnode',
'register_on_placenode', 'register_on_item_use',
'register_on_modchannel_message', 'register_on_modchannel_signal',
'register_on_inventory_open', 'register_on_sending_chat_message',
'register_on_receiving_chat_message'}) do
safe_funcs[minetest[k]] = wrap(k)
end
end

-- Environment
function Env.new_empty()
local self = {_raw = {}, _seen = copy(safe_funcs)}
self._raw['_G'] = self._raw
return setmetatable(self, {__index = Env}) or self
end
function Env:get(k) return self._raw[self._seen[k] or k] end
function Env:set(k, v) self._raw[copy(k, self._seen)] = copy(v, self._seen) end
function Env:set_copy(k, v)
self:set(k, v)
self._seen[k] = nil
self._seen[v] = nil
end
function Env:add_globals(...)
for i = 1, select('#', ...) do
local var = select(i, ...)
self:set(var, _G[var])
end
end
function Env:update(data) for k, v in pairs(data) do self:set(k, v) end end
function Env:del(k)
if self._seen[k] then
self._raw[self._seen[k]] = nil
self._seen[k] = nil
end
self._raw[k] = nil
end

function Env:copy()
local new = {_seen = copy(safe_funcs)}
new._raw = copy(self._raw, new._seen)
return setmetatable(new, {__index = Env}) or new
end

-- Load code into a callable function.
function Env:loadstring(code)
if code:byte(1) == 27 then return nil, 'Invalid code!' end
local f, msg = loadstring(code)
if not f then return nil, msg end
setfenv(f, self._raw)
return function(...)
local good, msg = pcall(f, ...)
if good then
return msg
else
minetest.log('error', '[SSCSM] ' .. tostring(msg))
end
end
end

function Env:exec(code)
local f, msg = self:loadstring(code)
if not f then
minetest.log('error', '[SSCSM] Syntax error: ' .. tostring(msg))
return false
end
f()
return true
end

-- Create the "base" environment
local base_env = Env:new_empty()
function Env.new() return base_env:copy() end

-- Clone everything
base_env:add_globals('dump', 'dump2', 'error', 'ipairs', 'math', 'next',
'pairs', 'pcall', 'select', 'setmetatable', 'string', 'table', 'tonumber',
'tostring', 'type', 'vector', 'xpcall', '_VERSION')

base_env:set_copy('os', {clock = os.clock, difftime = os.difftime,
time = os.time})

-- Create a slightly locked down "minetest" table
do
local t = {
get_mod_storage = false,
global_exists = false,
}

for k, v in pairs(minetest) do
if safe_funcs[v] or (t[k] == nil and k:sub(1, 8) ~= 'register'
and k:sub(1, 10) ~= 'unregister') then
t[tostring(k)] = v
elseif t[k] == false then
t[k] = nil
end
end
base_env:set_copy('minetest', t)
end

-- Add table.unpack
if not table.unpack then
base_env._raw.table.unpack = unpack
end

-- Make sure copy() worked correctly
assert(base_env._raw.minetest.register_on_sending_chat_message ~=
minetest.register_on_sending_chat_message, 'Error in copy()!')

-- SSCSM functions
-- When calling these from an SSCSM, make sure they exist first.
local mod_channel = minetest.mod_channel_join('sscsm:exec_pipe')
local loaded_sscsms = {}
base_env:set('join_mod_channel', function()
if not mod_channel then
mod_channel = minetest.mod_channel_join('sscsm:exec_pipe')
end
end)

base_env:set('leave_mod_channel', function()
if mod_channel then
mod_channel:leave()
mod_channel = false
end
end)

-- Allow other CSMs to access the new Environment type
sscsm.Env = Env

-- exec() code sent by the server.
minetest.register_on_modchannel_message(function(channel_name, sender, message)
if channel_name ~= 'sscsm:exec_pipe' or (sender and sender ~= '') then
return
end

-- The first character is currently a version code, currently 0.
-- Do not change unless absolutely necessary.
local version = message:sub(1, 1)
local name, code
if version == '0' then
local s, e = message:find('\n')
if not s or not e then return end
local target = message:sub(2, s - 1)
if target ~= minetest.localplayer:get_name() then return end
message = message:sub(e + 1)
local s, e = message:find('\n')
if not s or not e then return end
name = message:sub(1, s - 1)
code = message:sub(e + 1)
else
return
end

-- Create the environment
if not sscsm.env then sscsm.env = Env:new() end

-- Don't load the same SSCSM twice
if not loaded_sscsms[name] then
print('Loading ' .. name)
loaded_sscsms[name] = true
sscsm.env:exec(code)
end
end)

-- Send "0"
local function request_csms(c)
c = c or 10
if c <= 0 then return end
if minetest.localplayer and mod_channel:is_writeable() then
mod_channel:send_all('0')
else
minetest.after(0.1, request_csms, c - 1)
end
end
minetest.after(0, request_csms)

+ 176
- 0
init.lua View File

@@ -0,0 +1,176 @@
--
-- SSCSM: Server-Sent Client-Side Mods proof-of-concept
--
-- © 2019 by luk3yx
--

local sscsm = {minify=true}
local modname = minetest.get_current_modname()
_G[modname] = sscsm
local modpath = minetest.get_modpath(modname)

-- Remove excess whitespace from code to allow larger files to be sent.
if sscsm.minify then
local f = loadfile(modpath .. '/minify.lua')
if f then
sscsm.minify_code = f()
else
minetest.log('warning', '[SSCSM] Could not load minify.lua!')
end
end

if not sscsm.minify_code then
function sscsm.minify_code(code)
assert(type(code) == 'string')
return code
end
end

-- Register code
sscsm.registered_csms = {}
initial_code = nil
local csm_order = false

-- Recalculate the CSM loading order
-- TODO: Make this nicer
local function recalc_csm_order()
local loaded = {[':init'] = true, [':cleanup'] = true}
local not_loaded = {}
local order = {':init'}
for k, v in pairs(sscsm.registered_csms) do
if k:sub(1, 1) ~= ':' then
table.insert(not_loaded, v)
end
end
while #not_loaded > 0 do
local def = not_loaded[1]
g = not def.depends or #def.depends == 0
if not g then
g = true
for _, mod in ipairs(def.depends) do
if not sscsm.registered_csms[mod] then
minetest.log('error', '[SSCSM] SSCSM "' .. def.name ..
'" has an unsatisfied dependency: ' .. mod)
g = false
break
elseif not loaded[mod] then
table.insert(not_loaded, def)
g = false
break
end
end
end

if g then
table.insert(order, def.name)
loaded[def.name] = true
end
table.remove(not_loaded, 1)
end

-- Set csm_order
table.insert(order, ':cleanup')
csm_order = order
end

-- Register SSCSMs
-- TODO: Automatically minify code (remove whitespace+comments)
local block_colon = false
sscsm.registered_csms = {}
function sscsm.register(def)
-- Read files now in case MT decides to block access later.
if not def.code and def.file then
local f = io.open(def.file, 'rb')
if not f then
error('Invalid "file" parameter passed to sscsm.register_csm.', 2)
end
def.code = f:read('*a')
f:close()
def.file = nil
end

if type(def.name) ~= 'string' or def.name:find('\n')
or (def.name:sub(1, 1) == ':' and block_colon) then
error('Invalid "name" parameter passed to sscsm.register_csm.', 2)
end

if type(def.code) ~= 'string' then
error('Invalid "code" parameter passed to sscsm.register_csm.', 2)
end

def.code = sscsm.minify_code(def.code)
if (#def.name + #def.code) > 65300 then
error('The code (or name) passed to sscsm.register_csm is too large.'
.. ' Consider refactoring your SSCSM code.', 2)
end

-- Copy the table to prevent mods from betraying our trust.
sscsm.registered_csms[def.name] = table.copy(def)
if csm_order then recalc_csm_order() end
end

function sscsm.unregister(name)
sscsm.registered_csms[name] = nil
if csm_order then recalc_csm_order() end
end

-- Recalculate the CSM order once all other mods are loaded
minetest.register_on_mods_loaded(recalc_csm_order)

-- Handle players joining
local mod_channel = minetest.mod_channel_join('sscsm:exec_pipe')
minetest.register_on_modchannel_message(function(channel_name, sender, message)
if channel_name ~= 'sscsm:exec_pipe' or not sender or
not mod_channel:is_writeable() or message ~= '0' or
sender:find('\n') then
return
end
minetest.log('action', '[SSCSM] Sending CSMs on request for ' .. sender
.. '...')
for _, name in ipairs(csm_order) do
mod_channel:send_all('0' .. sender .. '\n' .. name
.. '\n' .. sscsm.registered_csms[name].code)
end
end)

-- Register the SSCSM "builtins"
sscsm.register({
name = ':init',
file = modpath .. '/sscsm_init.lua'
})

sscsm.register({
name = ':cleanup',
code = 'sscsm._done_loading_()'
})

block_colon = true

-- Testing
minetest.after(1, function()
local c = 0
for k, v in pairs(sscsm.registered_csms) do
c = c + 1
if c > 2 then break end
end
if c == 2 then
minetest.log('warning', '[SSCSM] Testing mode enabled.')

sscsm.register({
name = 'sscsm:testing_cmds',
file = modpath .. '/sscsm_testing.lua'
})

sscsm.register({
name = 'sscsm:another_test',
code = 'yay()',
depends = {'sscsm:testing_cmds'},
})

sscsm.register({
name = 'sscsm:badtest',
code = 'error("Oops, badtest loaded!")',
depends = {':init', ':cleanup', ':no'}
})
end
end)

+ 87
- 0
minify.lua View File

@@ -0,0 +1,87 @@
--
-- A primitive code minifier
--
-- © 2019 by luk3yx
--

return function(code)
assert(type(code) == 'string')

local res, last, ws1, ws2, escape = '', false, '\n', '\n', false
local sp = {['"'] = true, ["'"] = true}

for i = 1, #code do
local char = code:sub(i, i)
if char == '\r' then char = '\n' end
if last == '--' or last == '--.' or last == '--[' then
ws1 = ws2
if char == '\n' then
if ws1 ~= '\n' then res = res .. '\n' end
last = false
ws1 = '\n'
elseif char == '[' and last ~= '--.' then
last = last .. char
else
last = '--.'
end
elseif last == '--[[' or last == '-]' then
ws1 = ws2
if last == '-]' then
if char == ']' then
last = false
else
last = '--[['
end
elseif char == ']' then
last = '-]'
end
elseif last == '[[' or last == ']' then
if last == ']' then
if char == ']' then
last = false
else
last = '[['
end
elseif char == ']' then
last = ']'
end
res = res .. char
elseif escape then
res = res .. '\\' .. char
escape = false
elseif char == '\\' then
escape = true
elseif last == '"' or last == "'" then
if char == last then last = false end
res = res .. char
elseif last == '-' then
if char == '-' then
last, ws1 = '--', ws2
else
res = res .. '-' .. char
last, ws1 = false, false
end
elseif char == '-' then
last = char
ws1 = ws2
elseif char == '\n' then
if ws2 == ' ' then
res = res:sub(1, #res - 1) .. '\n'
elseif ws2 ~= '\n' then
res = res .. '\n'
end
ws1 = '\n'
elseif char == ' ' or char == '\t' then
if not ws2 then res = res .. ' ' end
ws1 = ws2 or ' '
else
if sp[char] then last = char end
res = res .. char
end

ws2 = ws1
ws1 = false
end

return res
end

+ 114
- 0
sscsm_init.lua View File

@@ -0,0 +1,114 @@
--
-- SSCSM: Server-Sent Client-Side Mods proof-of-concept
-- Initial code sent to the client
--
-- © 2019 by luk3yx
--

-- Make sure table.unpack exists
if table.unpack then
unpack = table.unpack
else
table.unpack = unpack
end

-- Make sure a few basic functions exist, these may have been blocked because
-- of security or laziness.
if not rawget then function rawget(n, name) return n[name] end end
if not rawset then function rawset(n, k, v) n[k] = v end end
if not rawequal then function rawequal(a, b) return a == b end end
if not assert then
function assert(value, err)
if not value then
error(err or 'Assertion failed!', 2)
end
return value
end
end

-- Create the API
sscsm = {}
function sscsm.global_exists(name)
return rawget(_G, name) ~= nil
end
minetest.global_exists = sscsm.global_exists

-- Check if join_mod_channel and leave_mod_channel exist.
if sscsm.global_exists('join_mod_channel')
and sscsm.global_exists('leave_mod_channel') then
sscsm.join_mod_channel = join_mod_channel
sscsm.leave_mod_channel = leave_mod_channel
join_mod_channel, leave_mod_channel = nil, nil
else
local dummy = function() end
sscsm.join_mod_channel = dummy
sscsm.leave_mod_channel = dummy
end

-- Add print()
function print(...)
local msg = '[SSCSM] '
for i = 1, select('#', ...) do
if i > 1 then msg = msg .. '\t' end
msg = msg .. tostring(select(i, ...))
end
minetest.log('none', msg)
end
print('Hello from the server-sent CSMs!')

-- Add register_on_mods_loaded
do
local funcs = {}
function sscsm.register_on_mods_loaded(callback)
table.insert(funcs, callback)
end

function sscsm._done_loading_()
sscsm._done_loading_ = nil
for _, func in ipairs(funcs) do func() end
end
end

sscsm.register_on_mods_loaded(function()
print('SSCSMs loaded, leaving mod channel.')
sscsm.leave_mod_channel()
end)

-- Register "server-side" chatcommands
-- Can allow instantaneous responses in some cases.
sscsm.registered_chatcommands = {}
local function on_chat_message(msg)
if msg:sub(1, 1) ~= '/' then return false end

local cmd, param = msg:match('^/([^ ]+) *(.*)')
if not cmd then
minetest.display_chat_message('-!- Empty command')
return true
end

if not sscsm.registered_chatcommands[cmd] then return false end

local _, res = sscsm.registered_chatcommands[cmd].func(param or '')
if res then minetest.display_chat_message(tostring(res)) end

return true
end

function sscsm.register_chatcommand(cmd, def)
if type(def) == 'function' then
def = {func = def}
elseif type(def.func) ~= 'function' then
error('Invalid definition passed to sscsm.register_chatcommand.')
end

sscsm.registered_chatcommands[cmd] = def

if on_chat_message then
minetest.register_on_sending_chat_message(on_chat_message)
on_chat_message = false
end
end

function sscsm.unregister_chatcommand(cmd)
sscsm.registered_chatcommands[cmd] = nil
end

+ 62
- 0
sscsm_testing.lua View File

@@ -0,0 +1,62 @@
--
-- SSCSM: Server-Sent Client-Side Mods proof-of-concept
-- Testing code
--
-- © 2019 by luk3yx
--

-- Make sure the minifier is sane
a = 0
--[[
a = a + 1
--]]

-- [[
a = a + 2
--]]

--;a = a + 4

a = a + #('Test message with \'"quotes"\' .')

assert(a == 37, 'The minifier is breaking code!')

-- Create a few chatcommands
sscsm.register_chatcommand('error_test', function(param)
error('Testing: ' .. param)
end)

sscsm.register_chatcommand('sscsm', {
func = function(param)
return true, 'Hello from the SSCSM!'
end,
})

sscsm.register_chatcommand('slap', function(param)
if param:gsub(' ', '') == '' then
return false, 'Invalid usage. Usage: ' .. minetest.colorize('#00ffff',
'/slap <victim>') .. '.'
end

minetest.run_server_chatcommand('me', 'slaps ' .. param ..
' around a bit with a large trout.')
end)

-- A potentially useful example
sscsm.register_chatcommand('msg', function(param)
-- If you're actually using this, remove this.
assert(param ~= '<error>', 'Test')

local sendto, msg = param:match('^(%S+)%s(.+)$')
if not sendto then
return false, 'Invalid usage, see ' .. minetest.colorize('#00ffff',
'/help msg') .. '.'
end

minetest.run_server_chatcommand('msg', param)
end)

-- Create yay() to test dependencies
function yay()
print('yay() called')
end

Loading…
Cancel
Save