Summary
Numerous HTTP-emitting modules (core.api.http_get, core.api.http_post, graphql.query/graphql.mutation, monitor.http_check, communication.slack_send, notification.{discord,slack,teams}.send_message, ai.vision_analyze [anthropic path], verify.visual_diff, browser.proxy_rotate, and the agent/llm inline base_url branch) perform outbound requests to a fully client-controlled URL without calling the project's own SSRF guard (validate_url_with_env_config) that their sibling modules apply. An authenticated workflow-author can point the URL at the cloud metadata IP (169.254.169.254), a loopback/RFC1918 host, or any internal host and read the response, yielding cloud-metadata credential theft and internal service read/write.
Root Cause
The SSRF guard is per-module (there is NO global egress interception). Each module must call validate_url_with_env_config before issuing a request. The listed modules never call it — they only carry an ssrf_protected metadata tag string which enforces nothing. Exemplar: src/core/modules/third_party/developer/http/requests.py — grepping for validate_url|ssrf|is_private in requests.py returns 0 guard calls; session.get(url) fires at :85 (HTTPGetModule) and session.post at :188 (HTTPPostModule). SECURITY.md incorrectly lists api.http_get as SSRF-protected.
Impact
Readable SSRF: full {status_code, headers, body} returned to the caller (requests.py:96-108). Enables theft of cloud IAM credentials from the metadata endpoint and read/write access to internal-only APIs. Scope Changed (S:C) — the request crosses into cloud-metadata / internal-network authority the workflow layer does not otherwise have.
Proof of Concept
Verified live this session: core.api.http_get with url pointed at a loopback internal server returned the internal body INTERNAL-SECRET-IAM-CREDENTIALS, while the guarded sibling http.get returned NETWORK_ERROR: Hostname blocked: 127.0.0.1 on the same input — proving the branch-asymmetry is real (not a port artifact).
POST /mcp {"method":"tools/call","params":{"name":"execute_module",
"arguments":{"module_id":"core.api.http_get",
"params":{"url":"http://<cloud-metadata-ip>/latest/meta-data/iam/security-credentials/"}}}}
Attack Chain
- Entry: authenticated MCP client →
POST /mcp execute_module core.api.http_get, url set to the cloud metadata endpoint. Guard: require_auth (mcp.py:71). Bypass proof: passes with a valid workflow-author bearer token (PR:L).
- Check: capability denylist /
enforce_module_policy (base.py:240). Bypass proof: core.api.* not in _DEFAULT_DENYLIST (module_policy.py:45-67) → is_allowed=True (runtime-registration verified: core.api.http_get -> HTTPGetModule).
- Check: SSRF validation. Bypass proof:
requests.py has ZERO validate_url/ssrf calls — only the ssrf_protected tag string at :23/:116. session.get(url) fires at :85.
- Sink: aiohttp GET/POST to the cloud metadata IP.
- Impact: full response body returned → IAM credential theft, internal read/write.
Bypass Evidence
Grepping validate_url|ssrf|is_private in requests.py → 0 guard calls (2 hits are both the inert tag string). Live PoC returned internal body directly; guarded sibling blocked the same input. Direct IP works — no IPv6 transition trick needed (AC:L), unlike the seed CVE-2026-55787.
Affected Versions
<= 2.26.6 — code present on latest release tag v2.26.6 (requests.py:19,112).
Suggested Fix
Call validate_url_with_env_config(url) in each listed module before issuing the outbound request, matching the guarded siblings (e.g. ai.model:157, http.get). Best: route all outbound HTTP through a single guarded client wrapper so new modules inherit the guard.
Credit
Vulnerability discovered by zx (Jace).
References
Summary
Numerous HTTP-emitting modules (
core.api.http_get,core.api.http_post,graphql.query/graphql.mutation,monitor.http_check,communication.slack_send,notification.{discord,slack,teams}.send_message,ai.vision_analyze[anthropic path],verify.visual_diff,browser.proxy_rotate, and theagent/llminline base_url branch) perform outbound requests to a fully client-controlled URL without calling the project's own SSRF guard (validate_url_with_env_config) that their sibling modules apply. An authenticated workflow-author can point the URL at the cloud metadata IP (169.254.169.254), a loopback/RFC1918 host, or any internal host and read the response, yielding cloud-metadata credential theft and internal service read/write.Root Cause
The SSRF guard is per-module (there is NO global egress interception). Each module must call
validate_url_with_env_configbefore issuing a request. The listed modules never call it — they only carry anssrf_protectedmetadata tag string which enforces nothing. Exemplar:src/core/modules/third_party/developer/http/requests.py— grepping forvalidate_url|ssrf|is_privatein requests.py returns 0 guard calls;session.get(url)fires at:85(HTTPGetModule) andsession.postat:188(HTTPPostModule). SECURITY.md incorrectly listsapi.http_getas SSRF-protected.Impact
Readable SSRF: full
{status_code, headers, body}returned to the caller (requests.py:96-108). Enables theft of cloud IAM credentials from the metadata endpoint and read/write access to internal-only APIs. Scope Changed (S:C) — the request crosses into cloud-metadata / internal-network authority the workflow layer does not otherwise have.Proof of Concept
Verified live this session:
core.api.http_getwithurlpointed at a loopback internal server returned the internal bodyINTERNAL-SECRET-IAM-CREDENTIALS, while the guarded siblinghttp.getreturnedNETWORK_ERROR: Hostname blocked: 127.0.0.1on the same input — proving the branch-asymmetry is real (not a port artifact).Attack Chain
POST /mcpexecute_modulecore.api.http_get, url set to the cloud metadata endpoint. Guard:require_auth(mcp.py:71). Bypass proof: passes with a valid workflow-author bearer token (PR:L).enforce_module_policy(base.py:240). Bypass proof:core.api.*not in_DEFAULT_DENYLIST(module_policy.py:45-67) →is_allowed=True(runtime-registration verified:core.api.http_get -> HTTPGetModule).requests.pyhas ZEROvalidate_url/ssrf calls — only thessrf_protectedtag string at :23/:116.session.get(url)fires at :85.Bypass Evidence
Grepping
validate_url|ssrf|is_privatein requests.py → 0 guard calls (2 hits are both the inert tag string). Live PoC returned internal body directly; guarded sibling blocked the same input. Direct IP works — no IPv6 transition trick needed (AC:L), unlike the seed CVE-2026-55787.Affected Versions
<= 2.26.6— code present on latest release tag v2.26.6 (requests.py:19,112).Suggested Fix
Call
validate_url_with_env_config(url)in each listed module before issuing the outbound request, matching the guarded siblings (e.g.ai.model:157,http.get). Best: route all outbound HTTP through a single guarded client wrapper so new modules inherit the guard.Credit
Vulnerability discovered by zx (Jace).
References