Building a Safe, Validated Configuration DSL with Lua 5.4
Lua is expressive for hierarchical configuration, but “the configuration is Lua” means it can loop, allocate, access opened libraries, and invoke every capability the host exposes. A safer DSL does not pretend scripts are data. It creates a narrow language with allowlisted constructors, converts the returned value into ordinary host-owned data, and performs complete structural and semantic validation. The examples use Lua 5.4.8.
Require one returned value
A configuration chunk should return a value rather than mutate globals:
return service {
name = "catalog",
replicas = 3,
endpoints = {
endpoint { path = "/health", method = "GET" },
endpoint { path = "/items", method = "POST" }
}
}
service and endpoint are pure host-provided constructors. They copy permitted fields, attach an internal type marker if needed, and reject unknown keys early. The final value still needs another validation pass because a script can return a table it created directly. Attractive DSL syntax is not proof that the result is trusted.
Do not let constructors register objects globally as they run. Parsing must be all-or-nothing. Side effects before a later validation failure produce a partially applied configuration that is hard to undo.
Create a fresh environment for each load
load accepts an environment. Populate it only with DSL constructors and required pure constants; do not use __index = _G as a fallback to the real global environment:
local function make_environment()
local env = {
service = build_service,
endpoint = build_endpoint,
true_value = true
}
return setmetatable(env, {
__newindex = function(_, key)
error("global assignment is not allowed: " .. tostring(key), 2)
end
})
end
local function parse(source, name)
local chunk, err = load(source, name, "t", make_environment())
if not chunk then return nil, err end
local ok, value = pcall(chunk)
if not ok then return nil, value end
return validate_and_normalize(value)
end
Mode "t" accepts text rather than precompiled binary chunks. A fresh environment prevents one configuration from leaking values into another. In this example error and tostring are captured by host closures; they do not need to be exposed to the configuration.
The environment table should not be returned to the script through an accidental constructor argument. Freeze or copy any tables you expose so a configuration cannot modify the vocabulary for later calls.
Keep allowlisted functions pure
Constructors should not read files, environment variables, networks, or current time, and should not mutate host global state. Otherwise identical text produces different results across machines and review cannot establish the true input. When environments differ, choose an explicit profile outside the DSL or pass a small set of already validated, recorded constants.
Do not expose os, io, package, debug, require, dofile, or load. Removing those names is only the first layer. A host userdata or innocent-looking function can indirectly expose filesystem or process capability. Audit the complete reachable object graph and avoid shared tables with powerful metatables.
Capability minimization also improves reproducibility. A configuration should be a function of its text, declared DSL version, and explicit input constants. Hash those inputs and the normalized output for deployment records.
Validate structure and semantics
The validator checks root type, required and unknown fields, string lengths, number ranges, dense arrays, and nesting depth. Then it checks cross-field rules: endpoint paths are unique, a POST endpoint cannot be declared read-only, and replicas stay within an environment limit. Error messages include the configuration name and field path without echoing an entire sensitive document.
Normalize the output into host-owned immutable structures. Fill defaults, normalize enum spelling, copy tables, and discard metatables and functions. Runtime code consumes only the normalized result and no longer needs to defend against Lua dynamic behavior.
Reject ambiguous arrays with holes and mixed key types. Decide whether duplicate declarations are errors rather than allowing “last write wins” accidentally. Stable ordering makes diffs and generated documentation easier to review.
Apply resource budgets
Even without I/O, a script can run while true do end or allocate a huge table. An embedding host can meter memory with a custom allocator and check instruction budget or cancellation through a hook. In a pure Lua process, reliably isolating hostile code is harder; high-risk input belongs in a separate OS-restricted process. A controlled _ENV alone is not a complete security sandbox.
Limit source size, parse time, nesting depth, and number of produced objects. Validate before deployment. If production loading fails, retain a known-good previous configuration and report failure rather than applying a partial result.
Version and test the DSL
Golden tests convert source text to normalized structures. Negative tests cover unknown globals, assignments, syntax errors, budget exhaustion, duplicate paths, excessive values, and misleading metatables. Give the DSL an explicit version. When a field changes or disappears, return a migration-oriented error instead of silently changing meaning. A formatter and JSON export can help reviewers inspect the effective configuration.
A safe Lua configuration system minimizes capability: fresh environment, pure allowlisted constructors, text-only loading, protected execution, resource budgets, and full validation. Copy output into normalized host data and keep the known-good configuration on failure. Lua supplies readable expression; the host still owns security, determinism, and lifecycle.