From a7f8efcbfb18709b17157dcc528cc2fac29db1e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 22:23:15 +0000 Subject: [PATCH] fix(pickled-core): close IPv6 wildcard bypass of --allow-public guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP HTTP transport refused to bind '0.0.0.0' without --allow-public to prevent accidental public exposure, but only checked that one literal string. Binding to '::' / '[::]' (the IPv6 wildcard, accepted by uvicorn and FastMCP) silently exposed the MCP server on every IPv6 interface, and on dual-stack hosts effectively on IPv4 as well. The safety check gave a false sense of protection. Replace the string-equality test with a structural unspecified-address check via stdlib 'ipaddress', covering: * the canonical and bracketed IPv6 wildcard ('::' / '[::]') * the fully-expanded form ('0:0:0:0:0:0:0:0') * the IPv4-mapped wildcard ('::ffff:0.0.0.0') * trivial whitespace padding around any of the above * the original '0.0.0.0' literal (regression coverage retained) Loopback ('::1', '[::1]', '127.0.0.1'), localhost, specific LAN/public IPs, and IPv4-mapped loopback ('::ffff:127.0.0.1') are intentionally left alone: the guard only blocks wildcard binds, matching the original docstring. All pickled-* MCP CLIs (pickled-bdd, pickled-rules, pickled-data, pickled-iac, pickled-schema, pickled-diff) go through resolve_transport and inherit the fix. Co-authored-by: Bartłomiej Rosa --- .../src/pickled_core/mcp/transport.py | 36 +++++++++++++++- .../pickled-core/tests/test_mcp_transport.py | 42 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/pickled-core/src/pickled_core/mcp/transport.py b/packages/pickled-core/src/pickled_core/mcp/transport.py index 8ccfde2..e7d94f4 100644 --- a/packages/pickled-core/src/pickled_core/mcp/transport.py +++ b/packages/pickled-core/src/pickled_core/mcp/transport.py @@ -2,9 +2,40 @@ from __future__ import annotations +import ipaddress from typing import Any, Literal +def _is_unspecified_address(host: str) -> bool: + """Return True iff ``host`` is an IPv4/IPv6 wildcard ("unspecified") address. + + The previous implementation only rejected the literal string ``0.0.0.0``, + which let callers bind to ``::`` / ``[::]`` (the IPv6 wildcard) and bypass + the "no public binding without --allow-public" guard. That is a real + accidental-exposure footgun on any host with a routable IPv6 address: the + OS happily listens on every IPv6 interface, including public ones, and + typically accepts IPv4 traffic too via dual-stack mapping. + + We accept the bracketed form ``[::]`` (uvicorn / FastMCP take it as well), + fully-expanded forms such as ``0:0:0:0:0:0:0:0``, and the IPv4-mapped + wildcard ``::ffff:0.0.0.0``. Whitespace is stripped so the heuristic is + not defeated by trivial copy-paste artifacts. Hostnames are intentionally + left untouched — we deliberately do not resolve DNS in this guard. + """ + stripped = host.strip() + if stripped.startswith("[") and stripped.endswith("]"): + stripped = stripped[1:-1] + try: + addr = ipaddress.ip_address(stripped) + except ValueError: + return False + if addr.is_unspecified: + return True + if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None: + return addr.ipv4_mapped.is_unspecified + return False + + def resolve_transport( name: Literal["stdio", "http"], host: str | None, @@ -16,9 +47,10 @@ def resolve_transport( return {"transport": "stdio"} if name == "http": actual_host = host or "127.0.0.1" - if actual_host == "0.0.0.0" and not allow_public: + if _is_unspecified_address(actual_host) and not allow_public: raise RuntimeError( - "refusing to bind 0.0.0.0 without --allow-public (irreversible network exposure)" + f"refusing to bind wildcard address {actual_host!r} without " + "--allow-public (irreversible network exposure)" ) return { "transport": "streamable-http", diff --git a/packages/pickled-core/tests/test_mcp_transport.py b/packages/pickled-core/tests/test_mcp_transport.py index 76127fe..0420fb1 100644 --- a/packages/pickled-core/tests/test_mcp_transport.py +++ b/packages/pickled-core/tests/test_mcp_transport.py @@ -24,3 +24,45 @@ def test_allow_public_bind() -> None: kw = resolve_transport("http", "0.0.0.0", 9000, True) assert kw["host"] == "0.0.0.0" assert kw["port"] == 9000 + + +@pytest.mark.parametrize( + "host", + [ + "::", + "[::]", + "0:0:0:0:0:0:0:0", + "::ffff:0.0.0.0", + " 0.0.0.0 ", + " :: ", + ], +) +def test_refuse_ipv6_and_padded_wildcards_without_flag(host: str) -> None: + """Any IPv4/IPv6 wildcard ('unspecified') host must require --allow-public. + + The previous guard only blocked the literal ``0.0.0.0``; binding to ``::`` + silently exposed the MCP server on every IPv6 interface, defeating the + intent of the safety check. + """ + with pytest.raises(RuntimeError, match="wildcard"): + resolve_transport("http", host, None, False) + + +@pytest.mark.parametrize( + "host", + ["::", "[::]", "::ffff:0.0.0.0"], +) +def test_allow_ipv6_wildcards_with_flag(host: str) -> None: + kw = resolve_transport("http", host, 7802, True) + assert kw["host"] == host + assert kw["port"] == 7802 + + +@pytest.mark.parametrize( + "host", + ["127.0.0.1", "::1", "[::1]", "localhost", "192.168.1.10", "::ffff:127.0.0.1"], +) +def test_allow_loopback_and_specific_hosts_without_flag(host: str) -> None: + """Loopback addresses and specific hostnames must not be misidentified as wildcards.""" + kw = resolve_transport("http", host, None, False) + assert kw["host"] == host