--- 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 ` 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.