Security hardening: body size limit, JSON depth limit, timing-safe auth

- Add MAX_BODY_SIZE (64KB) check before reading body — prevents pre-auth
  OOM on internet-facing deployments
- Add JSON nesting depth limit (64 levels) — prevents C stack overflow
  that bypasses pcall and crashes the process
- Timing-safe token comparison via XOR accumulate — prevents timing
  oracle on Bearer token
- Auth token from LMCP_TOKEN env var (highest priority) — avoids storing
  token in a file readable by the read_file tool
- Silent handling of unknown JSON-RPC notifications (spec compliance)
- Exact path matching on /mcp endpoint (was prefix-based)
- Remove dead json.array() function

Findings from architecture review + security audit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 20:45:16 +02:00
parent abd9db30f2
commit 6bf0f450dc
2 changed files with 33 additions and 15 deletions
+10 -11
View File
@@ -89,6 +89,7 @@ end
-- Decode --
local decode_value
local MAX_DEPTH = 64
local ws_chars = { [' '] = true, ['\t'] = true, ['\n'] = true, ['\r'] = true }
local function skip_ws(s, pos)
@@ -143,14 +144,14 @@ local function decode_number(s, pos)
return n, pos
end
local function decode_array(s, pos)
local function decode_array(s, pos, depth)
pos = pos + 1 -- skip [
local arr = {}
pos = skip_ws(s, pos)
if s:sub(pos, pos) == ']' then return arr, pos + 1 end
while true do
local val
val, pos = decode_value(s, pos)
val, pos = decode_value(s, pos, (depth or 0) + 1)
arr[#arr + 1] = val
pos = skip_ws(s, pos)
local c = s:sub(pos, pos)
@@ -160,7 +161,7 @@ local function decode_array(s, pos)
end
end
local function decode_object(s, pos)
local function decode_object(s, pos, depth)
pos = pos + 1 -- skip {
local obj = {}
pos = skip_ws(s, pos)
@@ -174,7 +175,7 @@ local function decode_object(s, pos)
if s:sub(pos, pos) ~= ':' then error('expected : at ' .. pos) end
pos = skip_ws(s, pos + 1)
local val
val, pos = decode_value(s, pos)
val, pos = decode_value(s, pos, (depth or 0) + 1)
obj[key] = val
pos = skip_ws(s, pos)
local c = s:sub(pos, pos)
@@ -184,12 +185,14 @@ local function decode_object(s, pos)
end
end
decode_value = function(s, pos)
decode_value = function(s, pos, depth)
depth = depth or 0
if depth > MAX_DEPTH then error("JSON nesting too deep") end
pos = skip_ws(s, pos)
local c = s:sub(pos, pos)
if c == '"' then return decode_string(s, pos)
elseif c == '{' then return decode_object(s, pos)
elseif c == '[' then return decode_array(s, pos)
elseif c == '{' then return decode_object(s, pos, depth)
elseif c == '[' then return decode_array(s, pos, depth)
elseif c == 't' then
if s:sub(pos, pos + 3) == 'true' then return true, pos + 4 end
elseif c == 'f' then
@@ -210,9 +213,5 @@ end
-- Sentinel for JSON null
json.null = setmetatable({}, { __tostring = function() return 'null' end })
-- Helper: encode a table as a JSON array even if empty
function json.array(t)
return setmetatable(t or {}, { __is_array = true })
end
return json