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
+23 -4
View File
@@ -21,8 +21,20 @@ local function read_conf(path)
end
-- Protocol constants
-- Constant-time string comparison (prevents timing oracle on auth token)
local function constant_time_eq(a, b)
if type(a) ~= "string" or type(b) ~= "string" then return false end
if #a ~= #b then return false end -- length leak is acceptable (token length is not secret)
local diff = 0
for i = 1, #a do
diff = diff + (a:byte(i) ~ b:byte(i)) -- Lua 5.4 bitwise XOR
end
return diff == 0
end
local MCP_VERSION = "2025-03-26"
local JSONRPC = "2.0"
local MAX_BODY_SIZE = 65536 -- 64KB, generous for MCP JSON-RPC
function lmcp.new(name, opts)
opts = opts or {}
@@ -34,7 +46,9 @@ function lmcp.new(name, opts)
self.tools = {}
self._session_id = nil
-- Auth: explicit opt > conf file > nil (no auth)
if opts.auth_token then
if os.getenv("LMCP_TOKEN") then
self._auth_token = os.getenv("LMCP_TOKEN")
elseif opts.auth_token then
self._auth_token = opts.auth_token
elseif opts.conf then
local conf = read_conf(opts.conf)
@@ -131,6 +145,8 @@ function lmcp:handle_request(req)
end
else
-- Unknown notifications must be silently ignored (JSON-RPC spec)
if id == nil then return nil end
return jsonrpc_error(id, -32601, "Method not found: " .. tostring(method))
end
end
@@ -157,6 +173,9 @@ local function parse_http_request(client)
-- Read body
local body = ''
local content_length = tonumber(headers['content-length'] or 0)
if content_length > MAX_BODY_SIZE then
return nil, "body too large"
end
if content_length > 0 then
body, err = client:receive(content_length)
if not body then return nil, err end
@@ -202,7 +221,7 @@ function lmcp:serve_request(client)
if self._auth_token and req.method ~= 'OPTIONS' then
local auth = req.headers['authorization'] or ''
local token = auth:match('^Bearer%s+(.+)$')
if token ~= self._auth_token then
if not constant_time_eq(token, self._auth_token) then
send_response(client, '401 Unauthorized',
{ ['Content-Type'] = 'application/json',
['WWW-Authenticate'] = 'Bearer' },
@@ -213,7 +232,7 @@ function lmcp:serve_request(client)
end
-- GET /mcp — SSE endpoint (for session establishment)
if req.method == 'GET' and path:match('^/mcp') then
if req.method == 'GET' and (path:match('^/mcp$') or path:match('^/mcp%?')) then
-- SSE stream — send headers and keep alive briefly
local sse_headers = {
'HTTP/1.1 200 OK',
@@ -240,7 +259,7 @@ function lmcp:serve_request(client)
end
-- POST /mcp — JSON-RPC endpoint
if req.method == 'POST' and path:match('^/mcp') then
if req.method == 'POST' and (path:match('^/mcp$') or path:match('^/mcp%?')) then
if req.body == '' then
send_response(client, '400 Bad Request',
{ ['Content-Type'] = 'application/json' },