From 6bf0f450dcd735a7b5272ad14fd37c5dcf221638 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 11 Apr 2026 20:45:16 +0200 Subject: [PATCH] Security hardening: body size limit, JSON depth limit, timing-safe auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- json.lua | 21 ++++++++++----------- lmcp.lua | 27 +++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/json.lua b/json.lua index 6f2eb28..27ff534 100644 --- a/json.lua +++ b/json.lua @@ -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 diff --git a/lmcp.lua b/lmcp.lua index f6f713d..dd16fa0 100644 --- a/lmcp.lua +++ b/lmcp.lua @@ -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' },