From abc53199d284302e150da10be9fb04cfacf5c3c1 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Mon, 2 Nov 2026 10:03:22 +0000 Subject: [PATCH 1/3] feat(mcp): component_mcp validates the Origin header The MCP Streamable HTTP transport requires servers to validate Origin against DNS rebinding: a web page can make a browser send requests to 127.0.0.1, with the page's Origin. A request with an Origin header that mcp_http.allowed_origins (new, read-only, default none; exact match, ASCII case-insensitive) does not list now gets 403 before the token is checked. Requests without Origin (non-browser clients) are not affected. test-mcp-endpoint.sh: foreign and localhost origins refused by default, listed origins pass (case, spaces), a prefix is refused. 36 checks. Part of #256 (RFC 0001 Phase 5). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/MCP_COMPONENTS.md | 1 + scripts/test-mcp-endpoint.sh | 13 ++++++++- src/mcp/endpoint/component_mcp.cc | 44 ++++++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe7b149c..560d6056 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. `scripts/test-mcp-endpoint.sh` checks refusal, allowed origins, case and prefix matching. - **`component_mcp` (RFC 0001 Phase 4 spike, MySQL 26.10).** An MCP Streamable HTTP endpoint inside `mysqld` on MySQL Router's HTTP server (the libraries `component_mysql_rest_service` links), built in the server tree with `MYVECTOR_BUILD_MCP_ENDPOINT=ON ./scripts/build-component-26.10-docker.sh`. It is not for production. - **Endpoint:** loopback, plain HTTP, one bearer token (`mcp_http.token_sha256`) mapped to one account (`mcp_http.account`). - **Tools:** `list_databases` (tool pack) and `run_query` (one statement). diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index 4b112f96..6990376e 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -225,6 +225,7 @@ INSTALL COMPONENT 'file://component_mcp' | `mcp_http.port` | TCP port on `127.0.0.1` (read-only, default 33480) | | `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 | ### How a tool call runs diff --git a/scripts/test-mcp-endpoint.sh b/scripts/test-mcp-endpoint.sh index db9b1049..02bff654 100755 --- a/scripts/test-mcp-endpoint.sh +++ b/scripts/test-mcp-endpoint.sh @@ -24,6 +24,10 @@ 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"; } call() { rpc tok-123 "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":$2}}"; } alive() { docker ps -q -f "name=$C" | grep -q . && echo up || echo DOWN; } @@ -46,6 +50,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"' @@ -70,7 +77,11 @@ expect "no MCP session left" "$(sql "SHOW GLOBAL STATUS LIKE 'mcp.sessions_conne # 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":\{\}' +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 1854d2da..cff169ea 100644 --- a/src/mcp/endpoint/component_mcp.cc +++ b/src/mcp/endpoint/component_mcp.cc @@ -44,7 +44,9 @@ #include #include +#include #include +#include #include #include #include @@ -90,7 +92,9 @@ namespace { unsigned long opt_port = 33480; char *opt_account = nullptr; // user@host the token maps to char *opt_token_sha256 = nullptr; // hex SHA-256 of the bearer token -bool port_registered = false, account_registered = false, token_registered = false; +char *opt_allowed_origins = nullptr; // comma-separated Origin values browsers may send +bool port_registered = false, account_registered = false, token_registered = false, + origins_registered = false; bool register_sysvars() { INTEGRAL_CHECK_ARG(ulong) port_arg; @@ -126,6 +130,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; } @@ -138,6 +154,7 @@ bool unregister_sysvars() { else registered = false; }; + drop(origins_registered, "allowed_origins"); drop(token_registered, "token_sha256"); drop(account_registered, "account"); drop(port_registered, "port"); @@ -176,6 +193,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 @@ -462,6 +500,10 @@ class McpHandler : public http::base::RequestHandler { req.send_reply(HttpStatusCode::MethodNotAllowed); return; } + if (!origin_ok(req.get_input_headers().find("Origin"))) { + req.send_reply(HttpStatusCode::Forbidden); + return; + } if (!token_ok(req.get_input_headers().find("Authorization"))) { req.get_output_headers().add("WWW-Authenticate", "Bearer"); req.send_reply(HttpStatusCode::Unauthorized); From a8d5a44715d810d47d6362dfddccbd3fdc848262 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Tue, 3 Nov 2026 09:37:14 +0000 Subject: [PATCH 2/3] feat(mcp): CORS for allowed origins (preflight and response headers) Review of #260 (Codex and CodeRabbit): allowing an Origin did not let a browser client connect: its OPTIONS preflight got 405, and replies had no Access-Control-Allow-Origin. For an origin in mcp_http.allowed_origins: - OPTIONS is answered with 204, allowing POST with Authorization, Content-Type, Mcp-Session-Id and MCP-Protocol-Version (max age 600 s), without the token, as browsers send preflights without credentials; - every reply (401 included) carries Access-Control-Allow-Origin for it, with Vary: Origin. The Origin check now runs before the method check, so a foreign origin gets 403 for any method. test-mcp-endpoint.sh: preflight, response headers, 401 readable, foreign preflight 403 without CORS headers, OPTIONS without Origin 405. 67 checks. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/MCP_COMPONENTS.md | 2 +- scripts/test-mcp-endpoint.sh | 11 +++++++++++ src/mcp/endpoint/component_mcp.cc | 24 ++++++++++++++++++++---- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bca8ba59..22f97c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +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. `scripts/test-mcp-endpoint.sh` checks refusal, allowed origins, case and prefix matching. +- **`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 e30b641f..09eb5fdf 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -228,7 +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 | +| `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 133f5362..3997da1f 100755 --- a/scripts/test-mcp-endpoint.sh +++ b/scripts/test-mcp-endpoint.sh @@ -28,6 +28,9 @@ rpc() { docker run --rm --network "container:$C" "$CURL_IMAGE" -s -m 20 -w '\n%{ 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". @@ -157,6 +160,14 @@ sql "UNINSTALL COMPONENT 'file://component_mcp'" >/dev/null 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$" +expect "preflight from another Origin: 403, no CORS headers" "$(curl_i -X OPTIONS -H 'Origin: http://evil.example' -H 'Access-Control-Request-Method: POST' | grep -ciE '^HTTP/1.1 403|^access-control-')" "^1$" +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' diff --git a/src/mcp/endpoint/component_mcp.cc b/src/mcp/endpoint/component_mcp.cc index 5aac774b..9b30377c 100644 --- a/src/mcp/endpoint/component_mcp.cc +++ b/src/mcp/endpoint/component_mcp.cc @@ -774,15 +774,31 @@ 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); return; } - if (!origin_ok(req.get_input_headers().find("Origin"))) { - req.send_reply(HttpStatusCode::Forbidden); - return; - } if (!token_ok(req.get_input_headers().find("Authorization"))) { req.get_output_headers().add("WWW-Authenticate", "Bearer"); req.send_reply(HttpStatusCode::Unauthorized); From dae1b8d9f35e4aadeeedca6041f859c2d4a5a418 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Tue, 3 Nov 2026 10:35:48 +0000 Subject: [PATCH 3/3] test(mcp): check the foreign-origin preflight status and headers apart Review of #260 (a8d5a44): one count over the status line and the CORS headers passed for a 200 with one Access-Control-* header. The status (403) and the absence of CORS headers (0) are now separate checks. 68 checks. Co-Authored-By: Claude Opus 5.5 --- scripts/test-mcp-endpoint.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/test-mcp-endpoint.sh b/scripts/test-mcp-endpoint.sh index 3997da1f..b5b73f71 100755 --- a/scripts/test-mcp-endpoint.sh +++ b/scripts/test-mcp-endpoint.sh @@ -166,7 +166,9 @@ expect "preflight from an allowed Origin: 204" "$(printf '%s\n' "$preflight" | h 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$" -expect "preflight from another Origin: 403, no CORS headers" "$(curl_i -X OPTIONS -H 'Origin: http://evil.example' -H 'Access-Control-Request-Method: POST' | grep -ciE '^HTTP/1.1 403|^access-control-')" "^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$"