From 8461a32a07c20693f4e128e372f63a739ec0cb25 Mon Sep 17 00:00:00 2001 From: Kang Tu Date: Sat, 25 Jul 2026 17:56:17 -0700 Subject: [PATCH 1/3] test MCP HTTP request validation --- test/test_ai-code-mcp-http-hardening.el | 97 +++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 test/test_ai-code-mcp-http-hardening.el diff --git a/test/test_ai-code-mcp-http-hardening.el b/test/test_ai-code-mcp-http-hardening.el new file mode 100644 index 00000000..7eeedf9c --- /dev/null +++ b/test/test_ai-code-mcp-http-hardening.el @@ -0,0 +1,97 @@ +;;; test_ai-code-mcp-http-hardening.el --- MCP HTTP validation tests -*- lexical-binding: t; -*- + +;; Author: Kang Tu +;; SPDX-License-Identifier: Apache-2.0 + +;;; Commentary: +;; Focused tests for local MCP HTTP request validation. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'ai-code-mcp-http-server) + +(defun ai-code-test-mcp-http--request (path body &optional content-type) + "Build a POST request for PATH and BODY with CONTENT-TYPE." + (list :method "POST" + :path path + :headers `(("content-type" . ,(or content-type "application/json"))) + :body body)) + +(ert-deftest ai-code-test-mcp-http-hardening-rejects-non-session-path () + "POST endpoints outside an exact MCP session path should be rejected." + (let (response dispatched) + (cl-letf (((symbol-function 'ai-code-mcp-dispatch) + (lambda (&rest _args) (setq dispatched t))) + ((symbol-function 'ai-code-mcp-http-server--send-response) + (lambda (_process code content-type body) + (setq response (list code content-type body))))) + (ai-code-mcp-http-server--handle-post + 'process + (ai-code-test-mcp-http--request + "/other/session" "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}"))) + (should (equal (car response) 404)) + (should-not dispatched))) + +(ert-deftest ai-code-test-mcp-http-hardening-rejects-unregistered-session () + "An exact MCP path should still require a registered session." + (let (response) + (cl-letf (((symbol-function 'ai-code-mcp-get-session-context) + (lambda (_session-id) nil)) + ((symbol-function 'ai-code-mcp-http-server--send-response) + (lambda (_process code content-type body) + (setq response (list code content-type body))))) + (ai-code-mcp-http-server--handle-post + 'process + (ai-code-test-mcp-http--request + "/mcp/missing" "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}"))) + (should (equal (car response) 404)))) + +(ert-deftest ai-code-test-mcp-http-hardening-requires-json-content-type () + "MCP POST requests should require JSON content." + (let (response) + (cl-letf (((symbol-function 'ai-code-mcp-get-session-context) + (lambda (_session-id) '(:project-dir "/tmp"))) + ((symbol-function 'ai-code-mcp-http-server--send-response) + (lambda (_process code content-type body) + (setq response (list code content-type body))))) + (ai-code-mcp-http-server--handle-post + 'process + (ai-code-test-mcp-http--request + "/mcp/live" "{}" "text/plain"))) + (should (equal (car response) 415)))) + +(ert-deftest ai-code-test-mcp-http-hardening-rejects-invalid-jsonrpc-envelope () + "Requests should declare JSON-RPC 2.0 and a string method." + (let (response) + (cl-letf (((symbol-function 'ai-code-mcp-get-session-context) + (lambda (_session-id) '(:project-dir "/tmp"))) + ((symbol-function 'ai-code-mcp-http-server--send-json-error) + (lambda (_process id code message &optional http-code) + (setq response (list id code message http-code))))) + (ai-code-mcp-http-server--handle-post + 'process + (ai-code-test-mcp-http--request + "/mcp/live" "{\"jsonrpc\":\"1.0\",\"id\":7,\"method\":42}"))) + (should (equal (nth 0 response) 7)) + (should (equal (nth 1 response) -32600)) + (should (equal (nth 3 response) 400)))) + +(ert-deftest ai-code-test-mcp-http-hardening-rejects-oversized-body () + "The local HTTP transport should cap request body size." + (let ((ai-code-mcp-http-server-max-body-bytes 4) + response) + (cl-letf (((symbol-function 'ai-code-mcp-get-session-context) + (lambda (_session-id) '(:project-dir "/tmp"))) + ((symbol-function 'ai-code-mcp-http-server--send-response) + (lambda (_process code content-type body) + (setq response (list code content-type body))))) + (ai-code-mcp-http-server--handle-post + 'process + (ai-code-test-mcp-http--request "/mcp/live" "12345"))) + (should (equal (car response) 413)))) + +(provide 'test_ai-code-mcp-http-hardening) + +;;; test_ai-code-mcp-http-hardening.el ends here From 93b8ef3078aac1a839754b25a8083f0f75048d39 Mon Sep 17 00:00:00 2001 From: Kang Tu Date: Sat, 25 Jul 2026 17:57:08 -0700 Subject: [PATCH 2/3] harden MCP HTTP request validation --- ai-code-mcp-http-server.el | 106 ++++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 25 deletions(-) diff --git a/ai-code-mcp-http-server.el b/ai-code-mcp-http-server.el index b7c05aeb..0b2774bc 100644 --- a/ai-code-mcp-http-server.el +++ b/ai-code-mcp-http-server.el @@ -25,6 +25,14 @@ When nil, an available port is selected automatically." integer) :group 'ai-code-mcp-http-server) +(defcustom ai-code-mcp-http-server-max-body-bytes (* 1024 1024) + "Maximum accepted MCP HTTP request body size in bytes." + :type 'natnum + :group 'ai-code-mcp-http-server) + +(define-error 'ai-code-mcp-http-server-request-too-large + "MCP HTTP request body is too large") + (defvar ai-code-mcp-http-server--server nil "Server process for the local MCP HTTP transport.") @@ -80,12 +88,17 @@ When nil, an available port is selected automatically." (defun ai-code-mcp-http-server--filter (process chunk) "Accumulate CHUNK for PROCESS and handle a full request." - (let* ((data (concat (or (process-get process :data) "") chunk)) - (request (ai-code-mcp-http-server--parse-request data))) - (process-put process :data data) - (when request - (process-put process :data nil) - (ai-code-mcp-http-server--handle-request process request)))) + (let ((data (concat (or (process-get process :data) "") chunk))) + (condition-case err + (let ((request (ai-code-mcp-http-server--parse-request data))) + (process-put process :data data) + (when request + (process-put process :data nil) + (ai-code-mcp-http-server--handle-request process request))) + (ai-code-mcp-http-server-request-too-large + (process-put process :data nil) + (ai-code-mcp-http-server--send-response + process 413 "text/plain" (error-message-string err)))))) (defun ai-code-mcp-http-server--parse-request (data) "Parse DATA when it includes a full HTTP request." @@ -100,6 +113,11 @@ When nil, an available port is selected automatically." (content-length (string-to-number (or (cdr (assoc "content-length" headers)) "0")))) + (when (or (< content-length 0) + (> content-length ai-code-mcp-http-server-max-body-bytes)) + (signal 'ai-code-mcp-http-server-request-too-large + (list (format "Request body exceeds %d bytes" + ai-code-mcp-http-server-max-body-bytes)))) (when (<= (+ body-start content-length) (string-bytes data)) (pcase-let ((`(,method ,path) (ai-code-mcp-http-server--parse-request-line request-line))) @@ -136,28 +154,61 @@ When nil, an available port is selected automatically." -32603 (format "Internal error: %s" (error-message-string err)))))) +(defun ai-code-mcp-http-server--json-content-type-p (request) + "Return non-nil when REQUEST declares JSON content." + (when-let* ((value (cdr (assoc "content-type" (plist-get request :headers))))) + (string-match-p "\\`application/json\\(?:[ \t]*;\\|\\'\\)" (downcase value)))) + +(defun ai-code-mcp-http-server--valid-json-rpc-envelope-p (json-object) + "Return non-nil when JSON-OBJECT is a valid JSON-RPC request envelope." + (and (equal (alist-get 'jsonrpc json-object) "2.0") + (stringp (alist-get 'method json-object)))) + (defun ai-code-mcp-http-server--handle-post (process request) "Handle POST REQUEST on PROCESS." - (let ((response - (ai-code-mcp-http-server--json-rpc-response - (plist-get request :path) - (plist-get request :body)))) - (if (null response) - (ai-code-mcp-http-server--send-accepted process) - (ai-code-mcp-http-server--send-json - process - 200 - response)))) - -(defun ai-code-mcp-http-server--json-rpc-response (path body) + (let* ((path (plist-get request :path)) + (body (or (plist-get request :body) "")) + (session-id (ai-code-mcp-http-server--session-id-from-path path))) + (cond + ((not session-id) + (ai-code-mcp-http-server--send-response process 404 "text/plain" "Not Found")) + ((not (ai-code-mcp-get-session-context session-id)) + (ai-code-mcp-http-server--send-response process 404 "text/plain" "Unknown MCP session")) + ((not (ai-code-mcp-http-server--json-content-type-p request)) + (ai-code-mcp-http-server--send-response + process 415 "text/plain" "Content-Type must be application/json")) + ((> (string-bytes body) ai-code-mcp-http-server-max-body-bytes) + (ai-code-mcp-http-server--send-response process 413 "text/plain" "Request body too large")) + (t + (condition-case err + (let* ((json-object (json-parse-string body :object-type 'alist)) + (id (alist-get 'id json-object))) + (if (not (ai-code-mcp-http-server--valid-json-rpc-envelope-p json-object)) + (ai-code-mcp-http-server--send-json-error + process id -32600 "Invalid JSON-RPC request" 400) + (let ((response + (ai-code-mcp-http-server--json-rpc-response + path body json-object session-id))) + (if (null response) + (ai-code-mcp-http-server--send-accepted process) + (ai-code-mcp-http-server--send-json process 200 response))))) + (json-parse-error + (ai-code-mcp-http-server--send-json-error + process nil -32700 + (format "Parse error: %s" (error-message-string err)) + 400))))))) + +(defun ai-code-mcp-http-server--json-rpc-response (path body &optional json-object session-id) "Return a JSON-RPC response alist for PATH and BODY. +JSON-OBJECT and SESSION-ID may be supplied when REQUEST was already validated. Returns nil for notifications." - (let* ((json-object (json-parse-string body :object-type 'alist)) + (let* ((json-object (or json-object + (json-parse-string body :object-type 'alist))) (id (alist-get 'id json-object)) (method (alist-get 'method json-object)) (params (alist-get 'params json-object)) (ai-code-mcp--current-session-id - (ai-code-mcp-http-server--session-id-from-path path))) + (or session-id (ai-code-mcp-http-server--session-id-from-path path)))) (when id `((jsonrpc . "2.0") (id . ,id) @@ -172,15 +223,17 @@ Returns nil for notifications." (error nil)))) (defun ai-code-mcp-http-server--session-id-from-path (path) - "Extract session ID from PATH." - (when (string-match "\\`/mcp/\\([^/?]+\\)" path) + "Extract session ID from exact MCP PATH." + (when (and (stringp path) + (string-match "\\`/mcp/\\([^/?]+\\)\\'" path)) (match-string 1 path))) -(defun ai-code-mcp-http-server--send-json-error (process id code message) - "Send a JSON-RPC error response with ID, CODE, and MESSAGE on PROCESS." +(defun ai-code-mcp-http-server--send-json-error (process id code message &optional http-code) + "Send a JSON-RPC error response with ID, CODE, and MESSAGE on PROCESS. +HTTP-CODE defaults to 500." (ai-code-mcp-http-server--send-json process - 500 + (or http-code 500) `((jsonrpc . "2.0") (id . ,id) (error . ((code . ,code) @@ -219,7 +272,10 @@ Returns nil for notifications." "Return the HTTP reason phrase for CODE." (alist-get code '((200 . "OK") (202 . "Accepted") + (400 . "Bad Request") (404 . "Not Found") + (413 . "Payload Too Large") + (415 . "Unsupported Media Type") (500 . "Internal Server Error")) "OK")) From 0540c84ecfc24dbdc390489e3c69a56d88dbf459 Mon Sep 17 00:00:00 2001 From: Kang Tu Date: Sat, 25 Jul 2026 17:57:35 -0700 Subject: [PATCH 3/3] adapt MCP HTTP tests to validated requests --- test/test_ai-code-mcp-http-server.el | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/test_ai-code-mcp-http-server.el b/test/test_ai-code-mcp-http-server.el index 536ed80c..9cd9d68b 100644 --- a/test/test_ai-code-mcp-http-server.el +++ b/test/test_ai-code-mcp-http-server.el @@ -55,7 +55,9 @@ (ert-deftest ai-code-test-mcp-http-server-notification-returns-accepted () "Notification requests should return HTTP 202 with an empty body." (let ((captured-response nil)) - (cl-letf (((symbol-function 'ai-code-mcp-http-server--send-response) + (cl-letf (((symbol-function 'ai-code-mcp-get-session-context) + (lambda (_session-id) '(:project-dir "/tmp"))) + ((symbol-function 'ai-code-mcp-http-server--send-response) (lambda (_process code content-type body) (setq captured-response (list :code code @@ -64,6 +66,7 @@ (ai-code-mcp-http-server--handle-post nil (list :path "/mcp/session-http" + :headers '(("content-type" . "application/json")) :body (json-encode '((jsonrpc . "2.0") (method . "notifications/initialized") @@ -75,7 +78,9 @@ "JSON-RPC errors should preserve the originating request id." (let ((captured-payload nil) (captured-code nil)) - (cl-letf (((symbol-function 'ai-code-mcp-http-server--send-json) + (cl-letf (((symbol-function 'ai-code-mcp-get-session-context) + (lambda (_session-id) '(:project-dir "/tmp"))) + ((symbol-function 'ai-code-mcp-http-server--send-json) (lambda (_process code payload) (setq captured-code code) (setq captured-payload payload)))) @@ -83,6 +88,7 @@ nil (list :method "POST" :path "/mcp/session-http" + :headers '(("content-type" . "application/json")) :body (json-encode '((jsonrpc . "2.0") (id . 17)