Files
Questie-X/.agent/rules/lua-security.md
T

480 lines
16 KiB
Markdown

---
paths:
- "**/*.lua"
---
# Lua Security
> This file extends [common/security.md](../common/security.md) with Lua specific content.
## Mandatory Security Checks (Lua)
Before ANY commit of Lua code:
- [ ] No hardcoded secrets (API keys, passwords, tokens, webhook URLs)
- [ ] All user/external inputs validated before processing
- [ ] No `loadstring` / `load` with untrusted input
- [ ] No `os.execute` / `io.popen` with user-controlled strings
- [ ] No unintentional global variables (verified by `luacheck` with `std = "none"`)
- [ ] Error messages don't leak file paths, stack traces, or internal state
- [ ] SavedVariables don't store sensitive data in plaintext
- [ ] Plugin sandbox restricts access to dangerous libraries
## Secrets Management
- NEVER hardcode API keys, tokens, or credentials in Lua source files
- Use environment variables (`os.getenv("API_KEY")`) for CLI/server Lua
- Use secure SavedVariables with obfuscation for addon credentials (if absolutely necessary)
- Fail fast if required secrets are missing at startup
- Keep `.env` files and SavedVariables files in `.gitignore`
```lua
-- BAD: Hardcoded secret
local API_KEY = "sk-abc123secretkey"
-- GOOD: Environment variable with early validation
local function loadApiKey()
local key = os.getenv("API_KEY")
if not key or key == "" then
error("API_KEY environment variable must be set")
end
return key
end
```
## `loadstring` and Dynamic Code Execution
`loadstring` (Lua 5.0/5.1) / `load` (Lua 5.2+) execute arbitrary strings as code. This is the **single most critical attack surface** in Lua.
**Version note**: `loadstring` exists in Lua 5.0 and 5.1. In Lua 5.2+, `loadstring` was removed and its functionality was merged into `load`. In Lua 5.0, `loadlib` (not `loadstring`) is also available for loading C libraries — it was moved to `package.loadlib` in 5.1 and later.
### Never Do This
```lua
-- CRITICAL RISK: Arbitrary code execution from user input
local fn = loadstring(userInput)
if fn then fn() end
-- CRITICAL RISK: Loading from untrusted file path
local fn = loadfile(userSuppliedPath)
if fn then fn() end
-- CRITICAL RISK: Dynamic code from network data
local fn = loadstring(httpResponse.body)
```
### When It Is Acceptable
- Deserializing data from a **trusted, internal source** (e.g., `AceSerializer` output from your own SavedVariables written by your own addon)
- Compile-time / build tooling code that never runs in production
- MUST have a `-- SECURITY: loadstring used here because ...` comment
### Alternatives to loadstring
| Problem | Use Instead |
|---------|-------------|
| Dynamic function dispatch | Function lookup tables |
| Computed field access | `rawget(table, key)` |
| Data deserialization | `AceSerializer`, JSON parser, custom binary format |
| Template expansion | `string.format` or `gsub` with controlled patterns |
```lua
-- BAD: Dynamic dispatch via loadstring
local fn = loadstring("return " .. actionName .. "()")
-- GOOD: Function dispatch table
local actions = {
attack = function() return doAttack() end,
defend = function() return doDefend() end,
heal = function() return doHeal() end,
}
local fn = actions[actionName]
if fn then fn() end
```
### Bytecode Loading (5.2+ Security Risk)
Starting in Lua 5.2, bytecode verification was removed. Loading untrusted binary data via `load()` or `loadfile()` can execute arbitrary code even without `loadstring`. **Always restrict to text-only mode when loading untrusted input:**
```lua
-- CRITICAL: restrict to text mode when source is untrusted
-- The 4th arg 't' = text only, 'b' = binary only, 'bt' = both (default, unsafe)
-- In 5.2: mode is 3rd arg; in 5.3+: mode is 4th arg (after chunk name)
local fn, err = load(untrusted_source, "=(untrusted)", nil, "t")
-- Safe alternative: explicit source validation before loading
if not isKnownTrustedSource(source) then
return nil, "Refused to load untrusted source"
end
-- In Lua 5.1 and earlier, load() does not accept a mode parameter.
-- loadstring() only accepts text, so binary injection is not a risk via loadstring.
-- However, loadfile() can load binary chunks in all versions — validate files first.
```
*Source: Lua 5.2 Manual §8.2 — "Lua does not have bytecode verification anymore. So, all functions that load code (load and loadfile) are potentially insecure when loading untrusted binary data."*
## Global Namespace Leakage
Every variable written without `local` is an implicit global in Lua. This has severe consequences:
1. **Cross-addon contamination**: Any addon can read or overwrite your globals
2. **Information leak**: Internal data structures become publicly visible
3. **Taint**: In WoW, global writes from a tainted call stack propagate taint to the written variable, which then propagates to anything that reads it
4. **Silent bugs**: Typos in variable names silently create new globals instead of erroring
### Prevention
```lua
-- Use luacheck with std = "none" to catch ALL undeclared globals
-- See hooks.md for .luacheckrc configuration
-- BAD: Leaks NPC data into the global namespace
QuestieX_WotLKDB_npc = addonTable.npcData -- Visible to every addon!
-- GOOD: Share via Plugin API (private channel)
local plugin = QuestiePluginAPI:RegisterPlugin("WotLKDB")
plugin.data = addonTable -- Only accessible through the registry
```
### Runtime Global Access Monitoring (Development)
For debugging, use a `__newindex` hook on `_G` to detect unexpected global writes:
```lua
-- WARNING: Development only — remove before release
if DEBUG_MODE then
setmetatable(_G, {
__newindex = function(t, k, v)
local info = debug.getinfo(2, "Sl")
print(string.format(
"WARNING: Global write: %s = %s at %s:%d",
tostring(k), tostring(v),
info.short_src, info.currentline
))
rawset(t, k, v)
end
})
end
```
## Input Validation at System Boundaries
Validate all external input — user text, network data, saved variable files, addon communication — before processing:
```lua
-- GOOD: Validate before use, return nil + error for invalid input
local function safeGetNPC(npcId)
if type(npcId) ~= "number" then
return nil, "npcId must be a number, got: " .. type(npcId)
end
if npcId <= 0 or npcId ~= math.floor(npcId) then
return nil, "npcId must be a positive integer, got: " .. tostring(npcId)
end
return QuestieDB:GetNPC(npcId)
end
-- GOOD: Validate deserialized data structure
local function validateConfig(config)
if type(config) ~= "table" then return nil, "config must be a table" end
if type(config.version) ~= "string" then return nil, "config.version must be a string" end
if type(config.maxLevel) ~= "number" then return nil, "config.maxLevel must be a number" end
if config.maxLevel < 1 or config.maxLevel > 100 then
return nil, "config.maxLevel out of range: " .. config.maxLevel
end
return config
end
```
### SavedVariables Validation
Always validate SavedVariables on load — they can be manually edited by users or corrupted:
```lua
function Questie:LoadSavedVariables()
local sv = QuestieSV
if type(sv) ~= "table" then
-- Corrupted or missing — reset to defaults
QuestieSV = self:GetDefaults()
return
end
-- Validate schema version
if type(sv.version) ~= "number" or sv.version < MIN_SV_VERSION then
-- Schema too old — migrate or reset
QuestieSV = self:MigrateSV(sv)
end
end
```
## File I/O (Standalone Lua)
In sandboxed environments (WoW), `io` is not available. In standalone scripts:
### Path Traversal Prevention
```lua
-- BAD: User-controlled path — allows directory traversal
local f = io.open(userPath, "r")
-- GOOD: Validate path is within allowed directory
local function safeOpen(filename, mode)
-- Strip path traversal attempts
if filename:find("%.%.") or filename:find("[/\\]") then
return nil, "Invalid filename: path traversal detected"
end
local fullPath = SAFE_DIRECTORY .. "/" .. filename
return io.open(fullPath, mode)
end
```
### Resource Management
Always close file handles to prevent resource exhaustion:
```lua
-- BAD: Handle leak on error
local f = io.open("data.txt", "r")
local content = f:read("*a") -- If this errors, f is never closed
f:close()
-- GOOD: Protected read with guaranteed close
local function readFile(path)
local f, err = io.open(path, "r")
if not f then return nil, err end
local ok, content = pcall(f.read, f, "*a")
f:close() -- Always close, even if read failed
if not ok then return nil, content end
return content
end
```
## Shell Injection (`os.execute` / `io.popen`)
NEVER pass user-controlled strings to shell commands:
```lua
-- CRITICAL RISK: Shell injection
os.execute("grep " .. userInput .. " /var/log/app.log")
-- An attacker sends: "; rm -rf / #" as userInput
-- CRITICAL RISK: Same with io.popen
local handle = io.popen("curl " .. userUrl)
-- SAFE: Use validated, sanitized inputs or avoid shell entirely
local function safeLookup(word)
-- Validate: alphanumeric only
if not word:match("^%w+$") then
return nil, "Invalid input: must be alphanumeric"
end
-- Now safe to use in a controlled command
return os.execute("grep -w " .. word .. " dictionary.txt")
end
```
## Sandbox Design for Plugin Systems
When building systems that run third-party plugin code, restrict the execution environment:
### Environment Restriction
```lua
-- Create a restricted environment for plugin execution
local function createSandbox()
return {
-- Safe builtins
print = print,
pairs = pairs,
ipairs = ipairs,
next = next,
type = type,
tostring = tostring,
tonumber = tonumber,
select = select,
unpack = unpack,
error = error,
pcall = pcall,
-- Safe libraries (read-only subsets)
string = {
format = string.format,
find = string.find,
sub = string.sub,
len = string.len,
lower = string.lower,
upper = string.upper,
},
table = {
insert = table.insert,
remove = table.remove,
sort = table.sort,
concat = table.concat,
},
math = {
floor = math.floor,
ceil = math.ceil,
min = math.min,
max = math.max,
abs = math.abs,
},
-- EXPLICITLY EXCLUDED:
-- os (shell access, file system)
-- io (file system access)
-- debug (CRITICAL: debug.getupvalue/setupvalue can read/modify
-- any upvalue in any function, bypassing sandbox entirely.
-- debug.getlocal/setlocal can read/modify locals on the
-- call stack. debug.setmetatable bypasses __metatable guards.
-- Never expose ANY debug library function to untrusted code.)
-- load (arbitrary code execution)
-- loadstring (arbitrary code execution)
-- loadfile (arbitrary code execution)
-- dofile (arbitrary code execution)
-- rawget (bypass metamethod guards)
-- rawset (bypass metamethod guards)
-- setmetatable (override protections)
-- getmetatable (inspect protected tables)
}
end
-- Execute plugin code in sandbox
-- Version-gated: setfenv for 5.0/5.1, _ENV wrapper for 5.2+
local function runInSandbox(code, sandbox)
if setfenv then
-- Lua 5.0 / 5.1: setfenv directly restricts the environment
local fn, err = loadstring(code)
if not fn then return nil, "Compile error: " .. err end
setfenv(fn, sandbox)
return pcall(fn)
else
-- Lua 5.2+: use load() with custom _ENV
-- The 4th arg to load() sets the environment
local fn, err = load(code, "=(sandbox)", "t", sandbox)
if not fn then return nil, "Compile error: " .. err end
return pcall(fn)
end
end
```
**Why this matters**: `setfenv`/`getfenv` were removed in Lua 5.2. Code that relies on `setfenv` for sandboxing will silently fail or error on 5.2+. Always use the version-gated pattern above.
### Resource Limiting
For untrusted code, add execution timeout via `debug.sethook` (available in Lua 5.0+):
```lua
local function runWithTimeout(fn, maxInstructions)
maxInstructions = maxInstructions or 1000000
local count = 0
debug.sethook(function()
count = count + 1
if count > maxInstructions then
error("Execution limit exceeded: suspected infinite loop")
end
end, "", 1) -- Hook every instruction
local ok, result = pcall(fn)
debug.sethook() -- Remove hook
return ok, result
end
```
**Version note**: `debug.sethook` is available in Lua 5.0+ and works identically across all versions. The `debug` library itself may be stripped in sandboxed environments (WoW does not expose the full `debug` library to addons).
## Dependency Security
- Audit third-party Lua libraries (LuaRocks packages) for known CVEs before vendoring
- Pin dependency versions in rockspec or lockfile — never use floating `latest`
- Prefer small, auditable libraries over large frameworks for security-sensitive code
- Vendor critical dependencies (copy into project) rather than relying on external resolution
- Review transitive dependencies: `luarocks show <package>` lists deps
```bash
# List all installed packages and versions
luarocks list
# Show package info including dependencies
luarocks show lpeg
# Install specific version (avoid floating latest)
luarocks install luacheck 1.1.2
```
## Error Message Security
Never expose internal details in user-facing error messages:
```lua
-- BAD: Leaks internal path and database schema
error("Failed to load NPC " .. npcId .. " from "
.. dbPath .. ": column 'rawdata' is nil at index " .. idx)
-- GOOD: Generic user message, detailed internal log
Questie:Debug(Questie.DEBUG_CRITICAL,
format("[QuestieDB] GetNPC failed: npcId=%d, rawdata=nil, source=%s",
npcId, dbPath))
return nil -- Return nil to caller, no internal details
```
## WoW-Specific: Taint and Secure Code
Taint is a security mechanism in the WoW client that prevents addon code from executing protected actions (opening bags during combat, using abilities, etc.). Understanding taint is CRITICAL for WoW addon development.
### Taint Propagation Rules
1. Any variable written from insecure (addon) code is **tainted**
2. Any variable read from tainted state becomes **tainted**
3. Taint propagates through function calls, table reads, and variable assignments
4. Protected API calls from a tainted call stack trigger `ADDON_ACTION_BLOCKED`
### Common Taint Sources and Fixes
| Taint Source | Why It Taints | Fix |
|-------------|---------------|-----|
| `_G.MyVar = value` | Global write from addon code | Use `addonTable` instead |
| `loadstring(code)()` | Compiled code is always tainted | Use function dispatch tables |
| Overwriting Blizzard functions | Replaces secure with insecure | Use `hooksecurefunc()` (post-hook) |
| Writing from `OnUpdate` | Frequent tainted writes | Move to `ADDON_LOADED` or `PLAYER_LOGIN` |
| `rawset(_G, k, v)` | Bypasses metamethods but still taints | Avoid; use local module tables |
| Reading a tainted global | Taint propagates to reader | Read during `ADDON_LOADED` or cache locally |
### Safe WoW API Patterns
```lua
-- GOOD: Post-hook (does not replace the original, does not taint)
hooksecurefunc("QuestLogFrame_Update", function()
-- Your code runs AFTER the original — cannot taint it
end)
-- BAD: Function replacement (replaces secure with insecure = taint)
local original = QuestLogFrame_Update
QuestLogFrame_Update = function(...) -- Now tainted!
original(...)
myCustomLogic()
end
-- GOOD: Combat guard for protected actions
local function safeAction()
if InCombatLockdown() then
-- Queue for after combat
return
end
-- Safe to call protected APIs
end
```
## Security Response Protocol
If a security issue is found in Lua code:
1. **STOP** immediately — do not ship the code
2. Use **security-reviewer** agent
3. Fix CRITICAL issues before continuing
4. If secrets were exposed, rotate them immediately
5. Audit the entire codebase for similar patterns
6. Add `luacheck` rules to prevent recurrence
7. Add regression tests for the specific vulnerability
## References
See skill: `security-review` for general security checklists applicable across all languages.