Entornos personalizados: la base de todo sandbox
Cambiar _ENV en un bloque o en una función, cargar un chunk con su propio entorno mediante load, construir entornos con metatabla para herencia, modo estricto y solo lectura, y conocer con precisión los límites reales de un sandbox en Lua.
Si un nombre libre significa _ENV.nombre y _ENV es una variable léxica corriente, entonces darle a un trozo de código su propio universo de globales no requiere ninguna función de biblioteca: requiere declarar una variable. De esa observación sale todo lo que Lua ofrece en materia de aislamiento —los sandboxes de los servidores de juegos, los entornos de configuración de nginx, el modo estricto de los proyectos serios, la separación de espacios de nombres entre plugins— y también sus límites, que conviene conocer con el mismo detalle que sus capacidades, porque un sandbox mal entendido es peor que ninguno.
- Sustituir
_ENVen un bloque o en una función y prever exactamente qué deja de ser visible. - Cargar chunks con
loadyloadfiledándoles un entorno propio y un modo seguro. - Construir entornos con metatabla: herencia del entorno externo, modo estricto y solo lectura.
- Enumerar las vías de escape reales de un sandbox de Lua y cómo se cierran.
Cambiar _ENV en un bloque o en una función
Sombrear _ENV con una local cambia el significado de todos los nombres libres del resto del bloque. Es inmediato y es brutal: lo que no esté en la tabla nueva, deja de existir.
local print = print -- capturado ANTES de cambiar el entorno
do
local _ENV = { x = 10 } -- desde aqui, otro universo
print(x) --> 10 ('print' es una local, sobrevive)
y = 20 -- crea _ENV.y, no toca el entorno global
print(math) --> nil ('math' no esta en la tabla nueva)
end
print(y) --> nil (fuera del bloque, el mundo de antes)
La disciplina es siempre la misma: captura primero como locales lo que necesites conservar y cambia el entorno después. De ahí sale un idioma de módulo muy limpio, donde las funciones se declaran sin prefijo y aterrizan solas en la tabla del módulo:
local setmetatable, ipairs = setmetatable, ipairs -- lo que el modulo usara
local M = {}
local _ENV = M -- a partir de aqui, global significa campo de M
function suma (a, b) return a + b end -- define M.suma
function doble (n) return suma(n, n) end -- 'suma' se resuelve en M
return M
Ningún M. repetido, ningún riesgo de fugar una global por descuido: el descuido ahora cae dentro de M. También puedes hacerlo a nivel de función, con un parámetro llamado _ENV, como vimos en 9.3, o cambiándolo desde fuera con debug.setupvalue(f, 1, entorno) cuando el upvalue número uno es _ENV.
Cargar un chunk con su propio entorno
La vía canónica para código ajeno es load, cuya firma completa es load(chunk, nombre, modo, entorno). El cuarto argumento fija directamente el primer upvalue del chunk resultante, es decir, su _ENV:
local entorno = {
print = print,
pairs = pairs,
ipairs = ipairs,
tostring = tostring,
math = math, -- ojo: es la MISMA tabla que la tuya
string = string,
}
local codigo = [[ print("dentro:", math.pi); secreto = 1 ]]
local f, err = load(codigo, "config", "t", entorno)
if not f then error(err) end
local ok, err2 = pcall(f)
print(entorno.secreto) --> 1 quedo confinado en el entorno
print(secreto) --> nil nunca toco el nuestro
Tres detalles no son opcionales. El modo "t" rechaza chunks binarios: el verificador de bytecode no existe en Lua desde 5.2, y un fichero de bytecode manipulado puede corromper la máquina virtual y salir del sandbox de forma trivial, así que nunca cargues código no confiable en modo "b" ni en el "bt" por defecto. El nombre de chunk aparece en los mensajes de error y en las trazas; ponlo siempre. Y toda ejecución de código ajeno va envuelta en pcall, porque un error no capturado en el chunk se propaga hasta tu programa.
Poner math = math en el entorno entrega la misma tabla que usa tu programa. El código aislado puede escribir math.floor = function () return 0 end y romper a todos los demás. Si el código no es de fiar, entrega copias superficiales, o un proxy de solo lectura, o solo las funciones sueltas que necesite. Lo mismo vale para string y table.
Entornos con metatabla: herencia, estricto y solo lectura
Un entorno no tiene por qué ser una tabla plana: es un valor cualquiera que se indexa, así que la metatabla convierte el mecanismo en programable. Tres patrones cubren casi todo el terreno.
Herencia del entorno externo. Lecturas que caen al global, escrituras que quedan confinadas:
local sandbox = setmetatable({}, { __index = _G }) -- lee de _G, escribe en si mismo
local f = load(codigo, "plugin", "t", sandbox)
Modo estricto. El precursor directo de lo que Lua 5.5 llevó a la sintaxis del lenguaje. Es el clásico strict.lua que acompaña a la distribución desde hace décadas:
local declaradas = {}
setmetatable(_G, {
__newindex = function (t, k, v)
if not declaradas[k] then
error("asignacion a global no declarada: " .. tostring(k), 2)
end
rawset(t, k, v)
end,
__index = function (_, k)
if not declaradas[k] then
error("lectura de global no declarada: " .. tostring(k), 2)
end
return nil
end,
})
function declarar (nombre) declaradas[nombre] = true end
Funciona, y durante veinte años fue la única defensa disponible, pero paga un precio evidente: los errores llegan en tiempo de ejecución, solo si esa línea llega a ejecutarse, y cada acceso a global pasa por una función Lua. Recuerda 9.2: aquí el coste de leer un global deja de estar acotado, literalmente.
Solo lectura. Un proxy vacío con la tabla real escondida en la metatabla:
local function solo_lectura (t)
return setmetatable({}, {
__index = t,
__newindex = function () error("entorno de solo lectura", 2) end,
__metatable = false, -- impide getmetatable y setmetatable
__len = function () return #t end,
})
end
El campo __metatable es esencial: sin él, el código aislado hace getmetatable(entorno).__index y recupera la tabla original intacta.
Los límites reales de un sandbox
Un entorno restringido controla qué nombres ve el código. No controla nada más, y ahí está el catálogo de escapes que hay que cerrar a mano.
loaddentro del sandbox es una puerta abierta. Si el chunk aislado puede llamar aload, el chunk que él cargue recibe por omisión el entorno global de verdad, no el del llamante. Es la fuga más elegante y la más olvidada. O quitasload,loadstring,dofileyrequire, o los envuelves para forzar tu entorno.requirecarga módulos C. Un módulo binario ejecuta código nativo y el sandbox deja de significar nada. Fuerapackageentero.debuglo desmonta todo. Condebug.getupvalueydebug.setupvaluese alcanza el_ENVde cualquier función accesible, incluidas las que le prestaste. Nunca entra en un sandbox.- La metatabla de las cadenas es global y compartida.
getmetatable("").__indexes la tablastringdel proceso. Un chunk aislado que llegue a ella puede reescribirstring.formatpara todo el programa. Se cierra quitandogetmetatableo dando un entorno sin acceso a cadenas literales manipulables. - No hay límite de tiempo ni de memoria.
while true do endcuelga el proceso y una tabla que crece sin fin lo agota. La contención esdebug.sethookcon un gancho de conteo que aborte pasadas N instrucciones —instalado desde fuera, en una corrutina—, y para la memoria, un asignador propio en la API C. - Los errores filtran información. Rutas de fichero y trazas dicen mucho de tu sistema; sanea los mensajes que devuelvas.
flowchart TD A[Codigo no confiable] --> B[load con modo t y entorno propio] B --> C[Entorno restringido] C --> D[Metatabla con index al entorno externo] C --> E[Sin load sin require sin debug sin io] B --> F[Ejecutar dentro de pcall] F --> G[Gancho de conteo que aborta] G --> H[Limite de tiempo] E --> I[Sin escape por modulos nativos]
Merece la pena detenerse en lo que acaba de ocurrir. En la mayoría de los lenguajes, aislar código ajeno es un subsistema: un cargador de clases con permisos, un gestor de seguridad, un proceso separado, un intérprete embebido dentro del intérprete. En Lua es la aplicación directa de una regla de ámbito que ya existía por otros motivos. No hay API de sandbox, no hay modo seguro, no hay bandera de compilación: hay una variable léxica llamada _ENV, y darle otro valor es todo el mecanismo. Esa economía explica por qué media industria del videojuego, los servidores web con nginx y OpenResty, los routers, las bases de datos con scripting y tu propio editor ejecutan código de terceros escrito en Lua: el coste de aislar es cercano a cero porque el aislamiento no es una capa añadida, es la semántica normal del lenguaje leída con atención. Pero la misma economía impone la lección más importante de esta página, y es una lección de humildad de ingeniería: un sandbox de _ENV controla visibilidad de nombres, y absolutamente nada más. No controla el tiempo, ni la memoria, ni el estado compartido que le prestaste al entregar la tabla math, ni la metatabla de las cadenas que vive en el proceso entero, ni el load que le dejaste a mano y que devuelve al invitado al entorno global de verdad. Cada una de esas fugas es obvia una vez la conoces y absolutamente invisible mientras no. Por eso la regla profesional es doble y no negociable: usa _ENV para separar espacios de nombres y aislar plugins de código semiconfiable, donde es una herramienta magnífica; y cuando el código sea genuinamente hostil, pon un proceso, un contenedor o un límite del sistema operativo por debajo, porque ninguna regla de ámbito, por elegante que sea, ha detenido jamás un bucle infinito.
- Escribe un bloque con
local _ENV = {}y comprueba qué funciones dejan de existir y cuáles sobreviven por haberlas capturado antes. - Convierte un módulo tuyo al idioma
local _ENV = My verifica que todas sus funciones quedaron en la tabla del módulo. - Carga un chunk con
load(codigo, "test", "t", entorno)y confirma que sus asignaciones no tocan tu entorno global. - Instala
strict.luasobre_Gy comprueba que un error tipográfico salta en ejecución, no al compilar. - Dale
loada tu sandbox y escapa desde dentro: demuestra que el chunk cargado recibe el entorno global real. - Cierra la fuga anterior envolviendo
loadpara que imponga siempre el entorno restringido.