CVE-2026-84207: a good SSRF guard that two WebSocket paths never called
The SSRF I reported in heym: an egress guard that handles NAT64, 6to4, Teredo and DNS rebinding, and two dials that never ask it anything. The predicate was never the problem — the wiring was.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every connection in it went to a marker service on my own loopback interface. The issue was reported privately through the project’s own security advisory process, and is written up here only after the advisory and the fixed release were public.
The heym project published GHSA-mqw6-g845-w596 on 27 August — a Server-Side Request Forgery (CWE-918) in both of the project’s WebSocket egress paths, rated Medium at CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N, 5.4. It is indexed as CVE-2026-84207, assigned by VulnCheck and published on 1 September. I reported it on 26 August; it was accepted 65 minutes later, and the advisory and the fix in 0.0.98 went out the following morning.
The two records live in different places and neither one gets you to the other. The CVE record references the advisory, the fix commit and both vulnerable source files. The GitHub advisory carries no CVE ID at all, and will not acquire one on its own: a repository advisory only holds a cve_id when the identifier came through GitHub as CNA, and nothing backfills a third-party assignment into it afterwards. So cite both — the advisory for the affected range and the technical detail, the CVE record for the identifier your scanner or your ticket is keyed on.
Upgrade to 0.0.98. Everything up to and including 0.0.97 is affected.
What makes it worth a write-up is not that a workflow engine had an SSRF. It is that this project’s egress guard is a genuinely good one — NAT64, 6to4, Teredo, IPv4-compatible forms, multicast, and DNS rebinding closed by pinning the resolved address at dial time — and none of that mattered, because two of the places that open a socket on a user-supplied URL never called it.
Where the guard sits
Heym is a self-hosted workflow automation runtime: you build a workflow out of nodes, and around sixty node modules ship with it. Several of those nodes reach the network on a URL the workflow author supplies, and in some of them the URL is templated from workflow input rather than fixed when the node is saved.
That is a Server-Side Request Forgery sink by construction, and the project treats it as one. backend/app/services/ssrf_guard.py is two layers, and both are sound. guard_http_url is the pre-connection check: scheme allowlist, then every address the host resolves to must be globally routable, with the exotic IPv6 forms that carry an IPv4 destination unwrapped and re-checked rather than trusted to is_global. get_guarded_http_client returns a client whose network backend re-checks and pins the resolved IP at dial time, so a rebinding answer or a redirect cannot bounce the real connection onto a private address after the check passed, and it is built trust_env=False so an environment proxy cannot dial the target outside the pin. The pin installs fail-closed.
This is not a guard someone bolted on. The project has shipped two advisories on exactly this sink already — CVE-2026-67545 for the HTTP node, and GHSA-6rph-qqcv-jqh4 for the LLM image-edit input loader — and the guard is what came out of them.
It also matters who the caller is, and the project had already written that down. From the guard’s own module docstring, at v0.0.97:
On a multi-tenant or hosted deployment those authors are not necessarily trusted, so without a guard they can be pointed at loopback, private, link-local, or cloud-metadata endpoints (SSRF, CWE-918).
Two call sites
Here is the entire set of places in the tree that consults it:
backend/app/services/node_execution/nodes/http_node.py:24 ssrf_guard.guard_http_url(url)
backend/app/services/llm_service.py:343 guard_http_url(image_input, subject="LLM image input URL")
Two, across sixty node modules. That count is the finding. The question that gets you there is not “is this predicate complete” — it plainly is, and it is better than most — but “how many of the places that dial the network actually go through it?”
And the reason it could not cover the rest is structural rather than an oversight in the predicate. guard_http_url admits http and https only, and the dial-time pin lives on an httpx.Client. Neither of those things reaches a websockets.connect. A WebSocket dial is not a URL the guard declines to bless. It is a URL the guard is never shown.
The two dials that do not call it
The WebSocket Send node. websocket_send_node.py resolves websocketUrl through evaluate_message_template(url_template, inputs, node_id) — so the target is templatable from workflow input — and hands it to send_websocket_message. This is the whole of the validation there, at websocket_utils.py:193:
normalized_url = str(url).strip()
if not normalized_url:
raise ValueError("WebSocket Send node requires a URL")
connect_kwargs = build_websocket_connect_kwargs(headers, subprotocols)
payload, metadata = serialize_websocket_message(message)
websocket = await websockets.connect(normalized_url, **connect_kwargs)
No scheme check, no resolution, no guard call. A non-empty string is the test.
The headers are unfiltered too. build_websocket_connect_kwargs passes the node’s websocketHeaders through merge_outbound_headers, which is {"User-Agent": HEYM_USER_AGENT} | headers with no filter and no reserved-name list. A WebSocket handshake is an HTTP GET carrying Upgrade: websocket, so the caller chooses the host, the port, the path and every header on that request.
The WebSocket Trigger. At websocket_trigger_service.py:261, in a persistent reconnecting loop:
async with websockets.connect(config.url, **connect_kwargs) as websocket:
config.url comes straight from node_data["websocketUrl"]. On onMessage the service feeds parse_websocket_message(message) — decoded text, parsed JSON, and base64 of the raw frame — into the workflow execution the author reads afterwards.
That is the half that matters. The Send node is an outbound reach; the Trigger returns the internal peer’s frames to the person who authored the node, which makes this a read primitive rather than a blind send.
Who can author one: create_workflow and update_workflow (backend/app/api/workflows.py:1376 and :1534) are gated by Depends(get_current_user) and nothing further. No role check, no ownership tier. Any account, which is the lowest privilege the product has.
One thing that is not the explanation: HEYM_HTTP_ALLOW_PRIVATE_URLS, the opt-out for self-hosted operators who deliberately call internal hosts. The WebSocket paths never read settings at all, so the opt-out is neither consulted nor required. Nothing here is a deployment turning a protection off.
Proving it as a differential
A claim that one URL works is weak on its own. A claim that one URL works while the ones the guard was built for do not, in the same process and the same second is a result.
The proof-of-concept imports the project’s own modules unmodified at v0.0.97 and calls guard_http_url, send_websocket_message and build_websocket_connect_kwargs exactly as http_node.py and websocket_trigger_service.py call them — not a stub, and not a reimplementation of the predicate. It binds an ephemeral port on 127.0.0.1 and talks only to itself.
internal service on 127.0.0.1:60359
1. The guarded path refuses this exact host (http_node.py:24)
guard_http_url(http://127.0.0.1:60359/) -> SsrfBlockedError: HTTP node URL is not allowed (resolves to a non-public address)
2. The send node reaches it (websocket_utils.py:199, no guard)
[internal service received] 'payload-chosen-by-the-workflow-author'
send_websocket_message -> sent (text, 37 bytes)
header delivered verbatim: authorization: Bearer attacker-chosen-token
header delivered verbatim: x-forwarded-for: 127.0.0.1
header delivered verbatim: x-internal-admin: true
3. The trigger node reads the response back (websocket_trigger_service.py:261, no guard)
parse_websocket_message -> {"type": "text", "text": "{\"secret\": \"...\"}", "isJson": true, "data": {"secret": "..."}}
Same host, same process, same moment: the http(s) path is refused by the guard and both WebSocket paths connect.
One claim in the report is read rather than demonstrated, and was labelled that way in it. The end-to-end reproduction over the product’s own API — register, POST /api/workflows, PUT a websocketTrigger node whose websocketUrl is ws://127.0.0.1:<port>/, then read the frames out of the execution history — comes from the source, not from a run. The module-level differential above is what I actually executed. Marking that line is what makes the rest of a report worth trusting, and it is the line the score ended up turning on.
The score came down, and it should have
I filed it at CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N — 8.5, High. The published advisory says 5.4, Medium. The gap is mine, and both of the maintainer’s corrections were right.
C:H → C:L. The read requires an internal peer that actually speaks WebSocket. Against an HTTP-only internal service the handshake fails and the primitive degrades to a connect-and-error oracle — still a port and service oracle, but nothing like total disclosure, and cloud metadata endpoints sit on that side of the line. That bound was in my own Out of scope section. The maintainer scored against the limit I had written down rather than around it, which is exactly what that section is there for, and a mildly uncomfortable thing to be shown.
S:C → S:U. I had read reaching a separate internal service as a scope change. They score the same egress boundary as scope-unchanged in CVE-2026-67545, on the HTTP node, and consistency across a project’s own advisories is a better argument than mine was. Worth adding, because it is the part that keeps this honest: contesting S on its own would have moved 5.4 to 6.4 and stayed Medium. Nothing rode on it. Conceding a metric that cheap is not generosity and should not be written up as though it were.
The CVE record scores it independently, in CVSS v4.0: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N, 5.3. Two scoring systems and two organisations’ judgement, landing a tenth of a point apart. If you carry the number anywhere, carry the vector with it — the agreement is in the metrics, not in the arithmetic.
What shipped
They invited an implementation, and I offered a sketch rather than a patch: reusing their own _resolve_host_addresses, _is_public_address and _resolve_pinned_ip so the address policy stayed single-sourced instead of forked, with the parts I could not judge named as theirs — the Trigger’s reconnect and cancellation paths, socket cleanup under CancelledError, and the suite. A patch that passes on my loopback harness is not a patch that passes on theirs.
What merged as 540aade7c74 is 990 additions against the 404 of the sketch. The maintainer reviewed and extended rather than taking it as filed, and three of the differences are worth recording, because two of them are places I was wrong.
They dropped a header I had added. I put Origin into the reserved set alongside Host, Connection, Upgrade and Sec-WebSocket-*, and flagged it as the one of the five most likely to break a node that works today. The shipped set is:
_RESERVED_WEBSOCKET_HEADERS = frozenset({"host", "connection", "upgrade"})
_RESERVED_WEBSOCKET_HEADER_PREFIX = "sec-websocket-"
with a note that Origin reaches the handshake through websockets.connect’s own parameter instead. Refusing it would have been a behaviour change for no security gain.
They pinned to every validated address, not the first one. My sketch dialled _resolve_pinned_ip(host), the first public answer. The shipped _resolve_pinned_addresses returns all of them, so the connection keeps normal IPv4/IPv6 fallback while every address it might attempt is one the guard already inspected. Mine would have turned a dual-stack host whose first answer is dead into a failed dial, which is the kind of thing that gets a guard disabled in production.
They closed something I had not thought about. open_guarded_websocket refuses redirects outright while the guard is enabled, with the reason in the docstring: a caller-provided socket cannot be safely reused for a different target. A 3xx on a WebSocket handshake is precisely the shape the HTTP client’s pin exists to stop, and my sketch pinned the first dial and had nothing to say about the second.
One thing did go the other way, and it turned up while testing rather than while reading. websockets closes a caller-supplied socket on cancellation, but not when the handshake fails or times out. Left alone, that leaks one file descriptor per retry against any host that accepts TCP without speaking WebSocket — indefinitely, since the Trigger’s reconnect loop keeps retrying. It was not in the original report; I raised it in the thread and did not ask for a rescore, since A:N had been agreed before either of us knew about it. open_guarded_websocket now owns the socket until the handshake succeeds and closes it on every failure path.
Timeline
| 26 Aug 2026 | Reported through the project’s private vulnerability reporting; accepted 65 minutes later |
| 27 Aug 2026 | GHSA-mqw6-g845-w596 published; patch merged as 540aade7c74 and 0.0.98 released with the fix |
| 1 Sep 2026 | CVE-2026-84207 assigned and published by VulnCheck as a third-party CNA |
Sixty-five minutes, against a SECURITY.md that promises acknowledgement in three business days. In that time the maintainer reproduced all three legs against v0.0.97, accepted the report, opened the private fork, and came back with a scoring counter-argument — not “we’ll look into it”, an actual technical disagreement with reasons attached. The advisory published the next morning with the fixed release in the same minute, my credit on the record, and the 0.0.98 release notes thanking me by name. Then they took a sketch offered as raw material and shipped something better than it. I have no caveat to attach to any of that.
The one thing the advisory does not carry is the CVE ID, and that is not something the maintainer withheld. It is a property of where the identifier came from, and the reason both links are in this post twice.
If you maintain a guard like this one, the audit is not “is our predicate right”. It very probably is — theirs classified encodings most guards have never heard of. It is: write down every place in the tree that opens a socket on a value a user supplied, write down the call sites of your guard, and read the two lists against each other. Here the second list had two entries. The first had four, plus one more the project deliberately excludes and documents as excluded, which is the version of this that is fine.
Eurico Nicacio — @h3llh0und