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:
@@ -89,6 +89,7 @@ end
|
|||||||
-- Decode --
|
-- Decode --
|
||||||
|
|
||||||
local decode_value
|
local decode_value
|
||||||
|
local MAX_DEPTH = 64
|
||||||
local ws_chars = { [' '] = true, ['\t'] = true, ['\n'] = true, ['\r'] = true }
|
local ws_chars = { [' '] = true, ['\t'] = true, ['\n'] = true, ['\r'] = true }
|
||||||
|
|
||||||
local function skip_ws(s, pos)
|
local function skip_ws(s, pos)
|
||||||
@@ -143,14 +144,14 @@ local function decode_number(s, pos)
|
|||||||
return n, pos
|
return n, pos
|
||||||
end
|
end
|
||||||
|
|
||||||
local function decode_array(s, pos)
|
local function decode_array(s, pos, depth)
|
||||||
pos = pos + 1 -- skip [
|
pos = pos + 1 -- skip [
|
||||||
local arr = {}
|
local arr = {}
|
||||||
pos = skip_ws(s, pos)
|
pos = skip_ws(s, pos)
|
||||||
if s:sub(pos, pos) == ']' then return arr, pos + 1 end
|
if s:sub(pos, pos) == ']' then return arr, pos + 1 end
|
||||||
while true do
|
while true do
|
||||||
local val
|
local val
|
||||||
val, pos = decode_value(s, pos)
|
val, pos = decode_value(s, pos, (depth or 0) + 1)
|
||||||
arr[#arr + 1] = val
|
arr[#arr + 1] = val
|
||||||
pos = skip_ws(s, pos)
|
pos = skip_ws(s, pos)
|
||||||
local c = s:sub(pos, pos)
|
local c = s:sub(pos, pos)
|
||||||
@@ -160,7 +161,7 @@ local function decode_array(s, pos)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function decode_object(s, pos)
|
local function decode_object(s, pos, depth)
|
||||||
pos = pos + 1 -- skip {
|
pos = pos + 1 -- skip {
|
||||||
local obj = {}
|
local obj = {}
|
||||||
pos = skip_ws(s, pos)
|
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
|
if s:sub(pos, pos) ~= ':' then error('expected : at ' .. pos) end
|
||||||
pos = skip_ws(s, pos + 1)
|
pos = skip_ws(s, pos + 1)
|
||||||
local val
|
local val
|
||||||
val, pos = decode_value(s, pos)
|
val, pos = decode_value(s, pos, (depth or 0) + 1)
|
||||||
obj[key] = val
|
obj[key] = val
|
||||||
pos = skip_ws(s, pos)
|
pos = skip_ws(s, pos)
|
||||||
local c = s:sub(pos, pos)
|
local c = s:sub(pos, pos)
|
||||||
@@ -184,12 +185,14 @@ local function decode_object(s, pos)
|
|||||||
end
|
end
|
||||||
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)
|
pos = skip_ws(s, pos)
|
||||||
local c = s:sub(pos, pos)
|
local c = s:sub(pos, pos)
|
||||||
if c == '"' then return decode_string(s, pos)
|
if c == '"' then return decode_string(s, pos)
|
||||||
elseif c == '{' then return decode_object(s, pos)
|
elseif c == '{' then return decode_object(s, pos, depth)
|
||||||
elseif c == '[' then return decode_array(s, pos)
|
elseif c == '[' then return decode_array(s, pos, depth)
|
||||||
elseif c == 't' then
|
elseif c == 't' then
|
||||||
if s:sub(pos, pos + 3) == 'true' then return true, pos + 4 end
|
if s:sub(pos, pos + 3) == 'true' then return true, pos + 4 end
|
||||||
elseif c == 'f' then
|
elseif c == 'f' then
|
||||||
@@ -210,9 +213,5 @@ end
|
|||||||
-- Sentinel for JSON null
|
-- Sentinel for JSON null
|
||||||
json.null = setmetatable({}, { __tostring = function() return 'null' end })
|
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
|
return json
|
||||||
|
|||||||
@@ -21,8 +21,20 @@ local function read_conf(path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Protocol constants
|
-- 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 MCP_VERSION = "2025-03-26"
|
||||||
local JSONRPC = "2.0"
|
local JSONRPC = "2.0"
|
||||||
|
local MAX_BODY_SIZE = 65536 -- 64KB, generous for MCP JSON-RPC
|
||||||
|
|
||||||
function lmcp.new(name, opts)
|
function lmcp.new(name, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
@@ -34,7 +46,9 @@ function lmcp.new(name, opts)
|
|||||||
self.tools = {}
|
self.tools = {}
|
||||||
self._session_id = nil
|
self._session_id = nil
|
||||||
-- Auth: explicit opt > conf file > nil (no auth)
|
-- 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
|
self._auth_token = opts.auth_token
|
||||||
elseif opts.conf then
|
elseif opts.conf then
|
||||||
local conf = read_conf(opts.conf)
|
local conf = read_conf(opts.conf)
|
||||||
@@ -131,6 +145,8 @@ function lmcp:handle_request(req)
|
|||||||
end
|
end
|
||||||
|
|
||||||
else
|
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))
|
return jsonrpc_error(id, -32601, "Method not found: " .. tostring(method))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -157,6 +173,9 @@ local function parse_http_request(client)
|
|||||||
-- Read body
|
-- Read body
|
||||||
local body = ''
|
local body = ''
|
||||||
local content_length = tonumber(headers['content-length'] or 0)
|
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
|
if content_length > 0 then
|
||||||
body, err = client:receive(content_length)
|
body, err = client:receive(content_length)
|
||||||
if not body then return nil, err end
|
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
|
if self._auth_token and req.method ~= 'OPTIONS' then
|
||||||
local auth = req.headers['authorization'] or ''
|
local auth = req.headers['authorization'] or ''
|
||||||
local token = auth:match('^Bearer%s+(.+)$')
|
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',
|
send_response(client, '401 Unauthorized',
|
||||||
{ ['Content-Type'] = 'application/json',
|
{ ['Content-Type'] = 'application/json',
|
||||||
['WWW-Authenticate'] = 'Bearer' },
|
['WWW-Authenticate'] = 'Bearer' },
|
||||||
@@ -213,7 +232,7 @@ function lmcp:serve_request(client)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- GET /mcp — SSE endpoint (for session establishment)
|
-- 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
|
-- SSE stream — send headers and keep alive briefly
|
||||||
local sse_headers = {
|
local sse_headers = {
|
||||||
'HTTP/1.1 200 OK',
|
'HTTP/1.1 200 OK',
|
||||||
@@ -240,7 +259,7 @@ function lmcp:serve_request(client)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- POST /mcp — JSON-RPC endpoint
|
-- 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
|
if req.body == '' then
|
||||||
send_response(client, '400 Bad Request',
|
send_response(client, '400 Bad Request',
|
||||||
{ ['Content-Type'] = 'application/json' },
|
{ ['Content-Type'] = 'application/json' },
|
||||||
|
|||||||
Reference in New Issue
Block a user