wandres.dev
ÁMBITO Y GLOBALES · _ENV lo explica todo

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.

⏱ 17 min

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.

🎯 Al terminar esta lección sabrás
  • Sustituir _ENV en un bloque o en una función y prever exactamente qué deja de ser visible.
  • Cargar chunks con load y loadfile dá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.

⚠️
Compartir una tabla no es copiarla

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.

  • load dentro del sandbox es una puerta abierta. Si el chunk aislado puede llamar a load, 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 quitas load, loadstring, dofile y require, o los envuelves para forzar tu entorno.
  • require carga módulos C. Un módulo binario ejecuta código nativo y el sandbox deja de significar nada. Fuera package entero.
  • debug lo desmonta todo. Con debug.getupvalue y debug.setupvalue se alcanza el _ENV de cualquier función accesible, incluidas las que le prestaste. Nunca entra en un sandbox.
  • La metatabla de las cadenas es global y compartida. getmetatable("").__index es la tabla string del proceso. Un chunk aislado que llegue a ella puede reescribir string.format para todo el programa. Se cierra quitando getmetatable o dando un entorno sin acceso a cadenas literales manipulables.
  • No hay límite de tiempo ni de memoria. while true do end cuelga el proceso y una tabla que crece sin fin lo agota. La contención es debug.sethook con 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]
El sandbox de Lua es una consecuencia, no una característica

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.

⚔️ Construye y luego rompe tu propio sandbox
  1. Escribe un bloque con local _ENV = {} y comprueba qué funciones dejan de existir y cuáles sobreviven por haberlas capturado antes.
  2. Convierte un módulo tuyo al idioma local _ENV = M y verifica que todas sus funciones quedaron en la tabla del módulo.
  3. Carga un chunk con load(codigo, "test", "t", entorno) y confirma que sus asignaciones no tocan tu entorno global.
  4. Instala strict.lua sobre _G y comprueba que un error tipográfico salta en ejecución, no al compilar.
  5. Dale load a tu sandbox y escapa desde dentro: demuestra que el chunk cargado recibe el entorno global real.
  6. Cierra la fuga anterior envolviendo load para que imponga siempre el entorno restringido.