diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4c976..22f97c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`component_mcp` validates `Origin`** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). Against DNS rebinding, a request with an `Origin` header that `mcp_http.allowed_origins` (new, read-only, default none) does not list gets 403; requests without `Origin` are not affected. For a listed origin it handles CORS (preflight answered, replies carry `Access-Control-Allow-Origin`), so browser clients such as MCP Inspector connect directly. `scripts/test-mcp-endpoint.sh` checks refusal, allowed origins, case and prefix matching, and the CORS preflight and headers. - **`component_mcp` serves calls concurrently, with session limits** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). `mcp_http.threads` (default 4) I/O workers each serve one call at a time; the spike had one. `mcp_http.max_sessions_per_account` (default 4) caps an account's concurrent endpoint sessions (error 1203), since `max_user_connections` does not apply to them, and the sessions are now subject to `max_connections` (error 1040) instead of bypassing it. `scripts/test-mcp-endpoint.sh` checks overlap of 4 calls, the cap and `max_connections`. - **`component_mcp` refuses locked accounts** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). Impersonated sessions skip the login checks, so the endpoint reads `account_locked` from the session's security context and fails the call with error 3118 before any statement runs. `scripts/test-mcp-endpoint.sh` checks lock, both tools and unlock. - **`component_mcp` bounded row consumer** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). `run_query` sessions use the component's own row and value consumer services, which stop retaining rows past 1001 rows or 4 MiB of values while a statement runs, so a large explicit `LIMIT` or wide rows no longer buffer the whole result in `mysqld`. The budget counts values plus the per-row and per-cell overhead of the default consumer, NULL cells included. `scripts/test-mcp-endpoint.sh` now has 41 checks, including peak RSS for `LIMIT 1000000` and for 1 MB rows, 8000 NULL columns, DOUBLE widths, and errors raised after some rows. diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index 09a4688..09eb5fd 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -228,6 +228,7 @@ INSTALL COMPONENT 'file://component_mcp' | `mcp_http.max_sessions_per_account` | Concurrent endpoint sessions per account (read-only, default 4, 1 to 1024). A call over the cap fails with error 1203. The server's `max_user_connections` does not apply to these sessions | | `mcp_http.account` | The account (`user@host`) every request runs as (read-only) | | `mcp_http.token_sha256` | Hex SHA-256 of the bearer token (read-only, not persisted, sensitive: readable only with `SENSITIVE_VARIABLES_OBSERVER`). Requests without the matching `Authorization: Bearer ` get 401 | +| `mcp_http.allowed_origins` | Comma-separated `Origin` values accepted (read-only, default none; exact match, ASCII case-insensitive, spaces around commas ignored). Against DNS rebinding, as the MCP transport spec requires: a request that has an `Origin` header not in the list gets 403, before the token is checked. Requests without `Origin` (non-browser clients) are not affected. Add a browser-based client's origin here, e.g. `http://localhost:6274` for MCP Inspector. For a listed origin the endpoint also handles CORS: it answers the browser's `OPTIONS` preflight (204, allowing `POST` with `Authorization`, `Content-Type`, `Mcp-Session-Id` and `MCP-Protocol-Version`, cached 10 minutes) without the token, and every reply carries `Access-Control-Allow-Origin` for it, so the page can connect directly | ### How a tool call runs diff --git a/scripts/test-mcp-endpoint.sh b/scripts/test-mcp-endpoint.sh index bd23225..b5b73f7 100755 --- a/scripts/test-mcp-endpoint.sh +++ b/scripts/test-mcp-endpoint.sh @@ -24,6 +24,13 @@ sql() { docker exec -i "$C" mysql -h127.0.0.1 -uroot -ptestpass -N -e "$1" 2>&1 # rpc TOKEN JSON: POST to the endpoint from a sidecar in the server's network namespace. rpc() { docker run --rm --network "container:$C" "$CURL_IMAGE" -s -m 20 -w '\n%{http_code}' -X POST \ http://127.0.0.1:33480/mcp -H 'Content-Type: application/json' ${1:+-H "Authorization: Bearer $1"} -d "$2"; } +# rpc_origin ORIGIN JSON: as rpc with the token, sending an Origin header (as a browser does). +rpc_origin() { docker run --rm --network "container:$C" "$CURL_IMAGE" -s -m 20 -w '\n%{http_code}' -X POST \ + http://127.0.0.1:33480/mcp -H 'Content-Type: application/json' -H 'Authorization: Bearer tok-123' \ + -H "Origin: $1" -d "$2"; } +# curl_i ARGS...: a request from a sidecar, printing the status line and headers (CR stripped). +curl_i() { docker run --rm --network "container:$C" "$CURL_IMAGE" -s -m 20 -o /dev/null -D - "$@" \ + http://127.0.0.1:33480/mcp | tr -d '\r'; } call() { rpc tok-123 "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":$2}}"; } # par N SQL: N concurrent run_query calls from one sidecar; prints the replies (they can # interleave, so count matches with grep -o), then the wall time in seconds as "elapsed=S". @@ -56,6 +63,9 @@ expect "install component_mcp" "$(sql "INSTALL COMPONENT 'file://component_mcp' expect "the token hash is not readable by other accounts" "$(docker exec -i "$C" mysql -h127.0.0.1 -umcp -pp -N -e "SELECT @@mcp_http.token_sha256" 2>&1 | grep -v "Using a password")" "ERROR.*SENSITIVE_VARIABLES_OBSERVER" expect "no token: 401" "$(rpc '' '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^401$" expect "wrong token: 401" "$(rpc nope '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^401$" +# DNS rebinding: a browser request carries its page's Origin; none is allowed by default. +expect "a browser Origin is refused: 403, even with the token" "$(rpc_origin http://evil.example '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^403$" +expect "a localhost Origin is refused too unless allowed" "$(rpc_origin http://localhost:6274 '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^403$" expect "initialize" "$(rpc tok-123 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}')" '"serverInfo":\{"name":"component_mcp"' expect "notification: 202" "$(rpc tok-123 '{"jsonrpc":"2.0","method":"notifications/initialized"}')" "^202$" expect "tools/list" "$(rpc tok-123 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}')" '"name":"list_databases".*"name":"run_query"' @@ -147,7 +157,21 @@ expect "no MCP session left after concurrent calls" "$(sql "SHOW GLOBAL STATUS L # An account without MCP_USER is not treated as an MCP session (the token maps to app). sql "UNINSTALL COMPONENT 'file://component_mcp'" >/dev/null -expect "reinstall mapped to app" "$(sql "INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.account = 'app@%', GLOBAL mcp_http.token_sha256 = '$TOKEN_HEX'")" "^$" +expect "reinstall mapped to app, with allowed origins" "$(sql "INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.account = 'app@%', GLOBAL mcp_http.token_sha256 = '$TOKEN_HEX', GLOBAL mcp_http.allowed_origins = 'http://localhost:6274, https://Inspector.example'")" "^$" +expect "an allowed Origin passes" "$(rpc_origin http://localhost:6274 '{"jsonrpc":"2.0","id":1,"method":"ping"}')" '"result":\{\}' +expect "allowed origins match case-insensitively, spaces trimmed" "$(rpc_origin https://inspector.example '{"jsonrpc":"2.0","id":1,"method":"ping"}')" '"result":\{\}' +# CORS for an allowed browser origin: the preflight is answered and replies name the origin. +preflight=$(curl_i -X OPTIONS -H 'Origin: http://localhost:6274' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization, content-type') +expect "preflight from an allowed Origin: 204" "$(printf '%s\n' "$preflight" | head -1)" "^HTTP/1.1 204" +expect "preflight allows POST with Authorization" "$(printf '%s\n' "$preflight" | grep -i '^access-control-' | tr 'A-Z' 'a-z' | sort | tr '\n' ' ')" "access-control-allow-headers: authorization, content-type.*access-control-allow-methods: post.*access-control-allow-origin: http://localhost:6274" +expect "a POST reply names the allowed Origin" "$(curl_i -X POST -H 'Origin: http://localhost:6274' -H 'Authorization: Bearer tok-123' -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' | grep -i '^access-control-allow-origin')" "^[Aa]ccess-[Cc]ontrol-[Aa]llow-[Oo]rigin: http://localhost:6274$" +expect "a 401 for an allowed Origin is readable by the page" "$(curl_i -X POST -H 'Origin: http://localhost:6274' -H 'Content-Type: application/json' -d '{}' | grep -ci '^access-control-allow-origin: http://localhost:6274')" "^1$" +foreign=$(curl_i -X OPTIONS -H 'Origin: http://evil.example' -H 'Access-Control-Request-Method: POST') +expect "preflight from another Origin: 403" "$(printf '%s\n' "$foreign" | head -1)" "^HTTP/1.1 403" +expect "preflight from another Origin: no CORS headers" "$(printf '%s\n' "$foreign" | grep -ci '^access-control-')" "^0$" +expect "OPTIONS without Origin: 405" "$(curl_i -X OPTIONS | head -1)" "^HTTP/1.1 405" +expect "a prefix of an allowed Origin is refused" "$(rpc_origin http://localhost:62 '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^403$" +expect "another Origin is still refused" "$(rpc_origin http://evil.example '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^403$" expect "guard: an account without MCP_USER is not restricted" "$(call run_query '{"sql":"INSERT INTO d1.t VALUES (7)"}')" '"isError":false' expect "the guard cannot be uninstalled while the endpoint is installed" "$(sql "UNINSTALL COMPONENT 'file://component_mcp_guard'")" "ERROR" expect "uninstall component_mcp" "$(sql "UNINSTALL COMPONENT 'file://component_mcp'")" "^$" diff --git a/src/mcp/endpoint/component_mcp.cc b/src/mcp/endpoint/component_mcp.cc index 7d00cc3..9b30377 100644 --- a/src/mcp/endpoint/component_mcp.cc +++ b/src/mcp/endpoint/component_mcp.cc @@ -47,7 +47,9 @@ #include #include +#include #include +#include #include #include #include @@ -108,8 +110,9 @@ unsigned long opt_threads = 4; // I/O workers: calls served at unsigned long opt_max_sessions_per_account = 4; // concurrent calls per account char *opt_account = nullptr; // user@host the token maps to char *opt_token_sha256 = nullptr; // hex SHA-256 of the bearer token +char *opt_allowed_origins = nullptr; // comma-separated Origin values browsers may send bool port_registered = false, threads_registered = false, max_sessions_registered = false, - account_registered = false, token_registered = false; + account_registered = false, token_registered = false, origins_registered = false; bool register_sysvars() { INTEGRAL_CHECK_ARG(ulong) port_arg; @@ -172,6 +175,18 @@ bool register_sysvars() { return true; token_registered = true; } + { + STR_CHECK_ARG(str) origins_arg; + origins_arg.def_val = nullptr; + if (mysql_service_component_sys_variable_register->register_variable( + "mcp_http", "allowed_origins", + PLUGIN_VAR_STR | PLUGIN_VAR_MEMALLOC | PLUGIN_VAR_READONLY, + "Comma-separated Origin values accepted from browsers (exact match); a request " + "with any other Origin gets 403", + nullptr, nullptr, &origins_arg, &opt_allowed_origins)) + return true; + origins_registered = true; + } return false; } @@ -184,6 +199,7 @@ bool unregister_sysvars() { else registered = false; }; + drop(origins_registered, "allowed_origins"); drop(token_registered, "token_sha256"); drop(account_registered, "account"); drop(max_sessions_registered, "max_sessions_per_account"); @@ -224,6 +240,27 @@ bool token_ok(const std::string *authorization) { CRYPTO_memcmp(got.data(), want.data(), got.size()) == 0; } +// DNS rebinding: a web page can make a browser send requests to 127.0.0.1, but the browser +// then sends the page's Origin. Requests without one (non-browser clients) pass; one with an +// Origin passes only if mcp_http.allowed_origins lists it (ASCII case-insensitive). +bool origin_ok(const std::string *origin) { + if (origin == nullptr) return true; + if (opt_allowed_origins == nullptr) return false; + const auto same = [&](const char *b, const char *e) { + while (b < e && *b == ' ') ++b; + while (e > b && e[-1] == ' ') --e; + return static_cast(e - b) == origin->size() && + std::equal(b, e, origin->begin(), [](char x, char y) { + return std::tolower(static_cast(x)) == + std::tolower(static_cast(y)); + }); + }; + const char *p = opt_allowed_origins; + for (const char *comma; (comma = std::strchr(p, ',')) != nullptr; p = comma + 1) + if (same(p, comma)) return true; + return same(p, p + std::strlen(p)); +} + // ----- SQL in an impersonated local session ---------------------------------------------- // Result bounds: the reply keeps at most kMaxRows rows and kMaxBytes of serialized rows (the @@ -737,6 +774,26 @@ void rpc_error(Json &out, int code, const char *message) { class McpHandler : public http::base::RequestHandler { public: void handle_request(http::base::Request &req) override { + const std::string *origin = req.get_input_headers().find("Origin"); + if (!origin_ok(origin)) { + req.send_reply(HttpStatusCode::Forbidden); + return; + } + // CORS for an allowed browser origin: its replies name it, so the page may read them, and + // its preflight (OPTIONS, sent without credentials) is answered here. + if (origin != nullptr) { + req.get_output_headers().add("Access-Control-Allow-Origin", origin->c_str()); + req.get_output_headers().add("Vary", "Origin"); + } + if (req.get_method() == HttpMethod::Options && origin != nullptr) { + req.get_output_headers().add("Access-Control-Allow-Methods", "POST"); + req.get_output_headers().add("Access-Control-Allow-Headers", + "Authorization, Content-Type, Mcp-Session-Id, " + "MCP-Protocol-Version"); + req.get_output_headers().add("Access-Control-Max-Age", "600"); + req.send_reply(HttpStatusCode::NoContent); + return; + } if (req.get_method() != HttpMethod::Post) { req.get_output_headers().add("Allow", "POST"); req.send_reply(HttpStatusCode::MethodNotAllowed);