No Bearer Token Required: Flyto2’s MCP Route Skipped the Auth Check Every REST Route Had

NVD published two CVEs against flytohub/flyto-core within ten seconds of each other on 14 October 2026 (19:17:17Z), both sourced to security-advisories@github.com — meaning both arrived via GitHub-reviewed advisories, not third-party CNA minting. Flyto2 Core is a small project (484 stars, 84 forks, Apache-2.0, pip package flyto-core) that describes itself as an execution kernel for automation and AI-agent workflows. The pair is worth covering together because the two defects are the same architectural mistake in opposite directions: a security control applied at one entry point and missing at the adjacent one.

  • CVE-2026-55786 (CVSS 3.1 8.4, AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H, CWE-78 OS Command Injection, versions 2.26.2 through before 2.26.4). The HTTP MCP route POST /mcp accepts unauthenticated JSON-RPC tools/call requests and forwards execute_module arguments to the module registry without the require_auth dependency the REST execution routes enforce. An unauthenticated caller can select sandbox.execute_shell and supply its command parameter, which reaches asyncio.create_subprocess_shell with the privileges of the server process.
  • CVE-2026-55787 (CVSS 3.1 7.1, AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:N, CWE-918 SSRF, prior to 2.26.3). validate_url_ssrf and is_private_ip in src/core/utils.py test the resolved address only against native private IPv4/IPv6 ranges and never unwrap IPv4-mapped, IPv4-compatible, 6to4, or NAT64 transition addresses. An authenticated workflow author can smuggle a loopback, RFC 1918, or cloud-metadata destination through the guard in transition form and have the response body returned to the workflow caller.

The docstring said the quiet part out loud

We read src/core/api/routes/mcp.py first-hand at tag v2.26.3 — which still carries the defect, since the MCP fix landed one release later. The module docstring states the vulnerability as a design note:

Implements MCP Streamable HTTP transport (2025-03-26 spec).

No Bearer token required — MCP clients don't send auth headers.

That sentence is true about MCP clients in the abstract and false as a security decision for this server. The GitHub advisory (GHSA-h9f9-h6gm-wc85, published 30 May 2026) lays out the asymmetry precisely: the route handler at src/core/api/routes/mcp.py:65-66 declares @router.post("") with no Depends(require_auth), while the analogous REST execution route at src/core/api/routes/modules.py:93 enforces both authentication and a module denylist. One router enforces the control; the router mounted two files away does not. The advisory confirms dynamic reproduction: command execution as root inside a Docker container with no Authorization header.

The CVSS vector deserves a careful read rather than a dismissal. AV:L with a loopback default bind is doing real work here — out of the box this is a local-privilege problem, not a remote one — but the advisory is explicit that starting the server with --host 0.0.0.0 makes the same endpoint remotely reachable with no other change. Any deployment that put this kernel behind a container port mapping or a reverse proxy without its own auth promoted an 8.4 local into a remote unauthenticated RCE. The fix commit (db7f116b, 30 May 2026, 12:48:58Z, “harden MCP HTTP transport authz (fail-closed)”) requires the Execution-API bearer token on the POST/DELETE /mcp transport deny-by-default and refuses a non-loopback bind without it, touching mcp.py, security.py and server.py plus three test files. Fail-closed is the right word and, given the docstring above, a deliberate reversal.

The guard checked the literal address, not the destination

We read src/core/utils.py first-hand at tag v2.26.0. The vulnerable is_private_ip is short:

def is_private_ip(ip_str: str) -> bool:
    try:
        ip = ipaddress.ip_address(ip_str)
        for network in PRIVATE_IP_RANGES:
            if ip in network:
                return True
        return False
    except ValueError:
        # Invalid IP, treat as potentially unsafe
        return True

The function is correct for exactly the inputs its author pictured: literal dotted quads and plain IPv6 addresses. ::ffff:127.0.0.1 parses as an IPv6 address, is not a member of any native private range in the list, and sails through — on any dual-stack host it connects to 127.0.0.1. The advisory (GHSA-794r-5rp2-fpg8, published 30 May 2026, reported by tonghuaroot) notes the bypass is against the project’s own stated model: SECURITY.md documents the resolved-IP check as a control, and the release notes for v2.26.3 (30 May 2026, 12:33:30Z) name the transition forms explicitly — ::ffff:127.0.0.1 and 64:ff9b::a9fe:a9fe, the NAT64 encoding of 169.254.169.254. The fix commit (519e0403, PR #20) adds an _extract_embedded_ipv4() unwrapper and range-checks the embedded address, with a dedicated test_ssrf_ipv6_transition.py suite. The reachable surface is broad for an authenticated flaw: the Execution API POST /v1/execute route, http.get, and sibling guarded outbound-request modules all sit behind the same guard, and the response body comes back to the workflow caller — read-SSRF with the cloud-metadata address as the prize.

Disclosure timeline: fixed in May, recorded in October

Both advisories were published 30 May 2026, both fixes shipped the same day (2.26.3 at 12:33:30Z, the MCP fix commit at 12:48:58Z, 2.26.4 following), and the NVD records arrived 14 October — four and a half months later. The current release is v2.28.1 (14 August), so anyone floating on recent tags was never exposed. The exposed cohort is the pinned kind: deployments that froze on a 2.26.x image in spring and change-control their upgrades. Note the ordering trap inside the batch itself: 2.26.3 closes only the SSRF record. A deployment that upgraded to 2.26.3 in June and stopped has been carrying the unauthenticated MCP shell ever since. The remediation version for both records is 2.26.4 or later.

What to do

  • Upgrade to at least 2.26.4 (2.28.1 is current). 2.26.3 alone leaves CVE-2026-55786 open. The MCP authz change is deny-by-default, so clients that relied on unauthenticated POST /mcp will need the Execution-API bearer token after upgrading — inventory those callers before you cut over.
  • Do not bind the server to a non-loopback interface without the bearer token in place. The pre-fix MCP route’s severity is a direct function of the bind address; treat --host 0.0.0.0 on any pre-2.26.4 instance as remote unauthenticated RCE and check access logs accordingly.
  • Audit every route when you add a transport, not just the transport. This batch’s generalisable rule: the REST routes had auth and a denylist; the MCP transport re-exposed the same registry without either. Any second transport, webhook, or debug interface that reaches the same dispatch table must re-enforce the same controls — enforcement belongs at the registry, not at each parser.
  • Test SSRF guards with transition-form addresses specifically. ::ffff:127.0.0.1, ::ffff:0:169.254.169.254, 6to4 2002:a9fe:a9fe::, and NAT64 64:ff9b::/96 embeddings belong in every SSRF regression suite alongside decimal, octal, and DNS-rebinding cases.

Verification note. Both CVE identifiers, NVD publication timestamps (14 October 2026, 19:17:17Z), CVSS 3.1 scores and vectors, CWE assignments, affected-version ranges, vulnStatus Received, and the GitHub-CNA source identifier were read first-hand from the NVD 2.0 API. Advisory IDs, publication date (30 May 2026), affected and patched versions, the route/line references, the Docker-root reproduction claim, and the SSRF reporter credit were read first-hand from the two GHSA pages. Fix commit SHAs, messages, timestamps, and file lists were read first-hand from the GitHub Commits API; the v2.26.3 release timestamp and body from the Releases API; the vulnerable mcp.py docstring and route plus the vulnerable is_private_ip/validate_url_ssrf code from raw.githubusercontent.com at tags v2.26.3 and v2.26.0. Repository stars, forks, license, and current release from the GitHub Repository API. Brave Search corroboration found only CVE-mirror pages and no analytical coverage. Nothing was installed, no server was started, no payload was executed, and no third-party request was sent; remediation guidance is our editorial analysis. We have no evidence of exploitation and imply none.

Sources: