Moonshark/runner/lua/sandbox.lua

685 lines
17 KiB
Lua

--[[
sandbox.lua
]]--
__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, util.url_encode(k) .. "=" .. util.url_encode(tostring(item)))
end
else
table.insert(query, util.url_encode(k) .. "=" .. util.url_encode(tostring(v)))
end
end
if #query > 0 then
if string.contains(base_url, "?") 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 same_site = string.trim(opts.same_site):lower()
local valid_values = {none = true, lax = true, strict = true}
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" />',
util.html_special_chars(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
}
-- ======================================================================
-- TEMPLATE RENDER FUNCTIONS
-- ======================================================================
-- Template processing with code execution
_G.render = function(template_str, env)
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
local pos, chunks = 1, {}
while pos <= #template_str do
local unescaped_start = template_str:find("{{{", pos, true)
local escaped_start = template_str:find("{{", pos, true)
local start, tag_type, open_len
if unescaped_start and (not escaped_start or unescaped_start <= escaped_start) then
start, tag_type, open_len = unescaped_start, "-", 3
elseif escaped_start then
start, tag_type, open_len = escaped_start, "=", 2
else
table.insert(chunks, template_str:sub(pos))
break
end
if start > pos then
table.insert(chunks, template_str:sub(pos, start-1))
end
pos = start + open_len
local close_tag = tag_type == "-" and "}}}" or "}}"
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 code = template_str:sub(pos, close_start-1):match("^%s*(.-)%s*$")
-- Check if it's a simple variable name for escaped output
local is_simple_var = tag_type == "=" and code:match("^[%w_]+$")
table.insert(chunks, {tag_type, code, pos, is_simple_var})
pos = close_stop + 1
end
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 == "=" then
if chunk[4] then -- is_simple_var
table.insert(buffer, "_b_i = _b_i + 1\n")
table.insert(buffer, "--[[" .. chunk[3] .. "]] _b[_b_i] = _escape(_tostring(" .. chunk[2] .. "))\n")
else
table.insert(buffer, "--[[" .. chunk[3] .. "]] " .. chunk[2] .. "\n")
end
elseif t == "-" then
table.insert(buffer, "_b_i = _b_i + 1\n")
table.insert(buffer, "--[[" .. chunk[3] .. "]] _b[_b_i] = _tostring(" .. chunk[2] .. ")\n")
end
end
end
table.insert(buffer, "return _b")
local fn, err = loadstring(table.concat(buffer))
if not fn then error(err) end
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, util.html_special_chars, output_buffer, 0)
return table.concat(output_buffer)
end
-- Named placeholder processing
_G.parse = function(template_str, env)
local pos, output = 1, {}
env = env or {}
while pos <= #template_str do
local unescaped_start, unescaped_end, unescaped_name = template_str:find("{{{%s*([%w_]+)%s*}}}", pos)
local escaped_start, escaped_end, escaped_name = template_str:find("{{%s*([%w_]+)%s*}}", pos)
local next_pos, placeholder_end, name, escaped
if unescaped_start and (not escaped_start or unescaped_start <= escaped_start) then
next_pos, placeholder_end, name, escaped = unescaped_start, unescaped_end, unescaped_name, false
elseif escaped_start then
next_pos, placeholder_end, name, escaped = escaped_start, escaped_end, escaped_name, true
else
local text = template_str:sub(pos)
if text and #text > 0 then
table.insert(output, text)
end
break
end
local text = template_str:sub(pos, next_pos - 1)
if text and #text > 0 then
table.insert(output, text)
end
local value = env[name]
local str = tostring(value or "")
if escaped then
str = util.html_special_chars(str)
end
table.insert(output, str)
pos = placeholder_end + 1
end
return table.concat(output)
end
-- Indexed placeholder processing
_G.iparse = function(template_str, values)
local pos, output, value_index = 1, {}, 1
values = values or {}
while pos <= #template_str do
local unescaped_start, unescaped_end = template_str:find("{{{}}}", pos, true)
local escaped_start, escaped_end = template_str:find("{{}}", pos, true)
local next_pos, placeholder_end, escaped
if unescaped_start and (not escaped_start or unescaped_start <= escaped_start) then
next_pos, placeholder_end, escaped = unescaped_start, unescaped_end, false
elseif escaped_start then
next_pos, placeholder_end, escaped = escaped_start, escaped_end, true
else
local text = template_str:sub(pos)
if text and #text > 0 then
table.insert(output, text)
end
break
end
local text = template_str:sub(pos, next_pos - 1)
if text and #text > 0 then
table.insert(output, text)
end
local value = values[value_index]
local str = tostring(value or "")
if escaped then
str = util.html_special_chars(str)
end
table.insert(output, str)
pos = placeholder_end + 1
value_index = value_index + 1
end
return table.concat(output)
end
-- ======================================================================
-- PASSWORD MODULE
-- ======================================================================
local password = {}
-- Hash a password using Argon2id
-- Options:
-- memory: Amount of memory to use in KB (default: 128MB)
-- iterations: Number of iterations (default: 4)
-- 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
-- ======================================================================
-- SEND MODULE
-- ======================================================================
local send = {}
function send.html(content)
http.set_content_type("text/html")
return content
end
function send.json(content)
http.set_content_type("application/json")
return content
end
function send.text(content)
http.set_content_type("text/plain")
return content
end
function send.xml(content)
http.set_content_type("application/xml")
return content
end
function send.javascript(content)
http.set_content_type("application/javascript")
return content
end
function send.css(content)
http.set_content_type("text/css")
return content
end
function send.svg(content)
http.set_content_type("image/svg+xml")
return content
end
function send.csv(content)
http.set_content_type("text/csv")
return content
end
function send.binary(content, mime_type)
http.set_content_type(mime_type or "application/octet-stream")
return content
end
-- ======================================================================
-- REGISTER MODULES GLOBALLY
-- ======================================================================
_G.http = http
_G.session = session
_G.csrf = csrf
_G.cookie = cookie
_G.password = password
_G.send = send