Moonshark/core/runner/sandbox.lua

624 lines
16 KiB
Lua

--[[
Moonshark Lua Sandbox Environment
This file contains all the Lua code needed for the sandbox environment,
including core modules and utilities. It's designed to be embedded in the
Go binary at build time.
]]--
__http_response = {}
__module_paths = {}
__module_bytecode = {}
__ready_modules = {}
__EXIT_SENTINEL = {} -- Unique object for exit identification
-- ======================================================================
-- CORE SANDBOX FUNCTIONALITY
-- ======================================================================
function exit()
error(__EXIT_SENTINEL)
end
-- Create environment inheriting from _G
function __create_env(ctx)
local env = setmetatable({}, {__index = _G})
if ctx then
env.ctx = ctx
end
if __setup_require then
__setup_require(env)
end
return env
end
-- Execute script with clean environment
function __execute_script(fn, ctx)
__http_response = nil
local env = __create_env(ctx)
env.exit = exit
setfenv(fn, env)
local ok, result = pcall(fn)
if not ok then
if result == __EXIT_SENTINEL then
return
end
error(result, 0)
end
return result
end
-- Ensure __http_response exists, then return it
function __ensure_response()
if not __http_response then
__http_response = {}
end
return __http_response
end
-- ======================================================================
-- HTTP MODULE
-- ======================================================================
local http = {
-- Set HTTP status code
set_status = function(code)
if type(code) ~= "number" then
error("http.set_status: status code must be a number", 2)
end
local resp = __ensure_response()
resp.status = code
end,
-- Set HTTP header
set_header = function(name, value)
if type(name) ~= "string" or type(value) ~= "string" then
error("http.set_header: name and value must be strings", 2)
end
local resp = __ensure_response()
resp.headers = resp.headers or {}
resp.headers[name] = value
end,
-- Set content type; set_header helper
set_content_type = function(content_type)
http.set_header("Content-Type", content_type)
end,
-- Set metadata (arbitrary data to be returned with response)
set_metadata = function(key, value)
if type(key) ~= "string" then
error("http.set_metadata: key must be a string", 2)
end
local resp = __ensure_response()
resp.metadata = resp.metadata or {}
resp.metadata[key] = value
end,
-- HTTP client submodule
client = {
-- Generic request function
request = function(method, url, body, options)
if type(method) ~= "string" then
error("http.client.request: method must be a string", 2)
end
if type(url) ~= "string" then
error("http.client.request: url must be a string", 2)
end
-- Call native implementation
local result = __http_request(method, url, body, options)
return result
end,
-- Shorthand function to directly get JSON
get_json = function(url, options)
options = options or {}
local response = http.client.get(url, options)
if response.ok and response.json then
return response.json
end
return nil, response
end,
-- Utility to build a URL with query parameters
build_url = function(base_url, params)
if not params or type(params) ~= "table" then
return base_url
end
local query = {}
for k, v in pairs(params) do
if type(v) == "table" then
for _, item in ipairs(v) do
table.insert(query, k .. "=" .. tostring(item))
end
else
table.insert(query, k .. "=" .. tostring(v))
end
end
if #query > 0 then
if base_url:find("?") then
return base_url .. "&" .. table.concat(query, "&")
else
return base_url .. "?" .. table.concat(query, "&")
end
end
return base_url
end
}
}
local function make_method(method, needs_body)
return function(url, body_or_options, options)
if needs_body then
options = options or {}
return http.client.request(method, url, body_or_options, options)
else
body_or_options = body_or_options or {}
return http.client.request(method, url, nil, body_or_options)
end
end
end
http.client.get = make_method("GET", false)
http.client.delete = make_method("DELETE", false)
http.client.head = make_method("HEAD", false)
http.client.options = make_method("OPTIONS", false)
http.client.post = make_method("POST", true)
http.client.put = make_method("PUT", true)
http.client.patch = make_method("PATCH", true)
http.redirect = function(url, status)
if type(url) ~= "string" then
error("http.redirect: url must be a string", 2)
end
status = status or 302 -- Default to temporary redirect
local resp = __ensure_response()
resp.status = status
resp.headers = resp.headers or {}
resp.headers["Location"] = url
exit()
end
-- ======================================================================
-- COOKIE MODULE
-- ======================================================================
local cookie = {
-- Set a cookie
set = function(name, value, options)
if type(name) ~= "string" then
error("cookie.set: name must be a string", 2)
end
local resp = __ensure_response()
resp.cookies = resp.cookies or {}
local opts = options or {}
local cookie = {
name = name,
value = value or "",
path = opts.path or "/",
domain = opts.domain
}
if opts.expires then
if type(opts.expires) == "number" then
if opts.expires > 0 then
cookie.max_age = opts.expires
local now = os.time()
cookie.expires = now + opts.expires
elseif opts.expires < 0 then
cookie.expires = 1
cookie.max_age = 0
end
-- opts.expires == 0: Session cookie (omitting both expires and max-age)
end
end
cookie.secure = (opts.secure ~= false)
cookie.http_only = (opts.http_only ~= false)
if opts.same_site then
local valid_values = {none = true, lax = true, strict = true}
local same_site = string.lower(opts.same_site)
if not valid_values[same_site] then
error("cookie.set: same_site must be one of 'None', 'Lax', or 'Strict'", 2)
end
-- If SameSite=None, the cookie must be secure
if same_site == "none" and not cookie.secure then
cookie.secure = true
end
cookie.same_site = opts.same_site
else
cookie.same_site = "Lax"
end
table.insert(resp.cookies, cookie)
return true
end,
-- Get a cookie value
get = function(name)
if type(name) ~= "string" then
error("cookie.get: name must be a string", 2)
end
local env = getfenv(2)
if env.ctx and env.ctx.cookies then
return env.ctx.cookies[name]
end
if env.ctx and env.ctx._request_cookies then
return env.ctx._request_cookies[name]
end
return nil
end,
-- Remove a cookie
remove = function(name, path, domain)
if type(name) ~= "string" then
error("cookie.remove: name must be a string", 2)
end
return cookie.set(name, "", {expires = 0, path = path or "/", domain = domain})
end
}
-- ======================================================================
-- SESSION MODULE
-- ======================================================================
local session = {
get = function(key)
if type(key) ~= "string" then
error("session.get: key must be a string", 2)
end
local env = getfenv(2)
if env.ctx and env.ctx.session and env.ctx.session.data then
return env.ctx.session.data[key]
end
return nil
end,
set = function(key, value)
if type(key) ~= "string" then
error("session.set: key must be a string", 2)
end
if type(value) == nil then
error("session.set: value cannot be nil", 2)
end
local resp = __ensure_response()
resp.session = resp.session or {}
resp.session[key] = value
end,
id = function()
local env = getfenv(2)
if env.ctx and env.ctx.session then
return env.ctx.session.id
end
return nil
end,
get_all = function()
local env = getfenv(2)
if env.ctx and env.ctx.session then
return env.ctx.session.data
end
return nil
end,
delete = function(key)
if type(key) ~= "string" then
error("session.delete: key must be a string", 2)
end
local resp = __ensure_response()
resp.session = resp.session or {}
resp.session[key] = "__SESSION_DELETE_MARKER__"
local env = getfenv(2)
if env.ctx and env.ctx.session and env.ctx.session.data then
env.ctx.session.data[key] = nil
end
end,
clear = function()
local env = getfenv(2)
if env.ctx and env.ctx.session and env.ctx.session.data then
for k, _ in pairs(env.ctx.session.data) do
env.ctx.session.data[k] = nil
end
end
local resp = __ensure_response()
resp.session = {}
resp.session["__clear_all"] = true
end
}
-- ======================================================================
-- CSRF MODULE
-- ======================================================================
local csrf = {
generate = function()
local token = util.generate_token(32)
session.set("_csrf_token", token)
return token
end,
field = function()
local token = session.get("_csrf_token")
if not token then
token = csrf.generate()
end
return string.format('<input type="hidden" name="_csrf_token" value="%s" />', token)
end,
validate = function()
local env = getfenv(2)
local token = false
if env.ctx and env.ctx.session and env.ctx.session.data then
token = env.ctx.session.data["_csrf_token"]
end
if not token then
http.set_status(403)
__http_response.body = "CSRF validation failed"
exit()
end
local request_token = nil
if env.ctx and env.ctx.form then
request_token = env.ctx.form._csrf_token
end
if not request_token and env.ctx and env.ctx._request_headers then
request_token = env.ctx._request_headers["x-csrf-token"] or
env.ctx._request_headers["csrf-token"]
end
if not request_token or request_token ~= token then
http.set_status(403)
__http_response.body = "CSRF validation failed"
exit()
end
return true
end
}
-- ======================================================================
-- UTIL MODULE
-- ======================================================================
-- Utility module implementation
local util = {
generate_token = function(length)
return __generate_token(length or 32)
end,
-- Deep copy of tables
deep_copy = function(obj)
if type(obj) ~= 'table' then return obj end
local res = {}
for k, v in pairs(obj) do res[k] = util.deep_copy(v) end
return res
end,
-- Merge tables
merge_tables = function(t1, t2)
if type(t1) ~= 'table' or type(t2) ~= 'table' then
error("Both arguments must be tables", 2)
end
local result = util.deep_copy(t1)
for k, v in pairs(t2) do
if type(v) == 'table' and type(result[k]) == 'table' then
result[k] = util.merge_tables(result[k], v)
else
result[k] = v
end
end
return result
end,
-- String utilities
string = {
-- Trim whitespace
trim = function(s)
return (s:gsub("^%s*(.-)%s*$", "%1"))
end,
-- Split string
split = function(s, delimiter)
delimiter = delimiter or ","
local result = {}
for match in (s..delimiter):gmatch("(.-)"..delimiter) do
table.insert(result, match)
end
return result
end
}
}
-- ======================================================================
-- TEMPLATE RENDER FUNCTION
-- ======================================================================
_G.render = function(template_str, env)
local OPEN_TAG, CLOSE_TAG = "<?", "?>"
-- Helper functions
local function escape_html(s)
local entities = {['&']='&amp;', ['<']='&lt;', ['>']='&gt;', ['"']='&quot;', ["'"]='&#039;'}
return (s:gsub([=[["><'&]]=], entities))
end
local function get_line(s, ln)
for line in s:gmatch("([^\n]*)\n?") do
if ln == 1 then return line end
ln = ln - 1
end
end
local function pos_to_line(s, pos)
local line = 1
for _ in s:sub(1, pos):gmatch("\n") do line = line + 1 end
return line
end
-- Parse template
local pos, chunks = 1, {}
while pos <= #template_str do
local start, stop = template_str:find(OPEN_TAG, pos, true)
if not start then
table.insert(chunks, template_str:sub(pos))
break
end
if start > pos then
table.insert(chunks, template_str:sub(pos, start-1))
end
pos = stop + 1
local modifier = template_str:match("^[=-]", pos)
if modifier then pos = pos + 1 end
local close_start, close_stop = template_str:find(CLOSE_TAG, pos, true)
if not close_start then
error("Failed to find closing tag at position " .. pos)
end
local trim_newline = false
if template_str:sub(close_start-1, close_start-1) == "-" then
close_start = close_start - 1
trim_newline = true
end
local code = template_str:sub(pos, close_start-1)
table.insert(chunks, {modifier or "code", code, pos})
pos = close_stop + 1
if trim_newline and template_str:sub(pos, pos) == "\n" then
pos = pos + 1
end
end
-- Compile chunks to Lua code
local buffer = {"local _tostring, _escape, _b, _b_i = ...\n"}
for _, chunk in ipairs(chunks) do
local t = type(chunk)
if t == "string" then
table.insert(buffer, "_b_i = _b_i + 1\n")
table.insert(buffer, "_b[_b_i] = " .. string.format("%q", chunk) .. "\n")
else
t = chunk[1]
if t == "code" then
table.insert(buffer, "--[[" .. chunk[3] .. "]] " .. chunk[2] .. "\n")
elseif t == "=" or t == "-" then
table.insert(buffer, "_b_i = _b_i + 1\n")
table.insert(buffer, "--[[" .. chunk[3] .. "]] _b[_b_i] = ")
if t == "=" then
table.insert(buffer, "_escape(_tostring(" .. chunk[2] .. "))\n")
else
table.insert(buffer, "_tostring(" .. chunk[2] .. ")\n")
end
end
end
end
table.insert(buffer, "return _b")
-- Load the compiled code
local fn, err = loadstring(table.concat(buffer))
if not fn then error(err) end
-- Create execution environment
env = env or {}
local runtime_env = setmetatable({}, {__index = function(_, k) return env[k] or _G[k] end})
setfenv(fn, runtime_env)
local output_buffer = {}
fn(tostring, escape_html, output_buffer, 0)
return table.concat(output_buffer)
end
-- ======================================================================
-- PASSWORD MODULE
-- ======================================================================
local password = {}
-- Hash a password using Argon2id
-- Options:
-- memory: Amount of memory to use in KB (default: 64MB)
-- iterations: Number of iterations (default: 3)
-- parallelism: Number of threads (default: 4)
-- salt_length: Length of salt in bytes (default: 16)
-- key_length: Length of the derived key in bytes (default: 32)
function password.hash(plain_password, options)
if type(plain_password) ~= "string" then
error("password.hash: expected string password", 2)
end
return __password_hash(plain_password, options)
end
-- Verify a password against a hash
function password.verify(plain_password, hash_string)
if type(plain_password) ~= "string" then
error("password.verify: expected string password", 2)
end
if type(hash_string) ~= "string" then
error("password.verify: expected string hash", 2)
end
return __password_verify(plain_password, hash_string)
end
-- ======================================================================
-- REGISTER MODULES GLOBALLY
-- ======================================================================
_G.http = http
_G.session = session
_G.csrf = csrf
_G.cookie = cookie
_G.util = util
_G.password = password