Summary
Kestra's opt-in HTTP egress control (kestra.tasks.http.allowed-list / kestra.tasks.http.denied-list) is enforced by a host-string matcher that performs no IP-literal normalization and no DNS resolution, and fail-opens (returns "not matched") when java.net.URI.getHost() returns null. A flow author can therefore defeat a configured deny-list and reach cloud metadata / internal-only services using either a decimal-encoded IPv4 literal or an authority containing an underscore (which forces getHost() to null). This is an incomplete-fix residual of the getHost()-based matcher (commit 0354ddf8), not the original SSRF (GHSA-r56g-q4p6-m3p6) which merely introduced the opt-in list.
Root Cause
The deny/allow list is applied at exactly one chokepoint: HttpClient.validateUri -> isListEntryMatch (core/src/main/java/io/kestra/core/http/client/HttpClient.java ~589-636). The matcher reads String host = uri.getHost(); if (host == null) return false; and compares that string against each list entry with no IP normalization and no DNS resolution. The socket target, however, is chosen by a different path: Micronaut UriBuilder preserves the host verbatim, HttpRequest.to() hands the identical URI to new HttpUriRequestBase(method, uri), and Apache HttpClient5 URIUtils.extractHost derives the target from the raw authority; the JDK resolver then interprets alternative IP encodings. This guard/connect divergence lets equivalent hosts slip past the deny match while still connecting.
Impact
A flow author (unauthenticated in the OSS default no-auth posture; low-privilege authenticated flow editor in a hardened deployment) defeats a deployed deny-list and reaches the cloud-metadata endpoint (IAM credentials), localhost, and internal services. http() returns the response body to the flow, so fetched data / credentials are exfiltrated as the expression value. This is the opposite of a "disabled control" false positive: the operator explicitly ENABLED an egress control that fails open.
Proof of Concept
Instance configured with a deny-list containing the cloud metadata address (the 169.254.169.254 link-local IP). Flow expression:
{{ http(uri='http://2852039166/latest/meta-data/iam/security-credentials/') }}
2852039166 is the 32-bit decimal encoding of the metadata link-local address. URI.getHost() returns the string "2852039166", which does not equal the dotted-quad deny entry, so the deny match fails; Apache/JDK still connect to the metadata service and the credentials are returned. Underscore/null-host variant: http://kestra_internal_db:8080/... makes getHost() return null, so isListEntryMatch returns false for EVERY deny entry.
Attack Chain
- Entry: flow author submits
{{ http(uri='http://2852039166/latest/meta-data/iam/security-credentials/') }}.
- Guard:
validateUri deny-list at HttpClient.java:541.
- Bypass proof: entries are matched only by
isListEntryMatch, which reads uri.getHost().
- Check:
isListEntryMatch(<metadata-ip>, uri).
- Guard: string comparison against
getHost().
- Bypass proof:
URI.create("http://2852039166/...").getHost() = "2852039166"; that string does not equal the dotted-quad deny entry. (Null-host variant: getHost() == null -> returns false at :591 for every entry.)
- Processing:
UriBuilder.of(uri).build() preserves host verbatim; HttpRequest.to() passes the identical URI to new HttpUriRequestBase(method, uri) — no canonicalization.
- Guard: any host canonicalization before connect.
- Bypass proof (Micronaut 4.10.18 + httpclient5 5.6.4):
getAuthority() -> 2852039166; URIUtils.extractHost(uri) -> http://2852039166.
- Sink: Apache opens the socket to the
URIUtils.extractHost result; JDK resolver parses the decimal literal.
- Bypass proof:
InetAddress.getByName("2852039166") resolves to the metadata link-local address (JDK 21, verified).
- Impact: metadata/IAM credentials returned as the Pebble expression value -> credential exfiltration. Post-redirect
ssrf-guard re-parses with the same URI and is bypassed identically; no redirect is required.
Bypass Evidence
URI.create("http://2852039166/...").getHost() = "2852039166" (non-null; string compare fails against dotted-quad deny entry).
URI.create("http://foo_bar.internal/").getHost() = null -> isListEntryMatch returns false at :591 for every entry (no deny-list can ever match an authority containing an underscore).
- Connect side (verified offline, exact shipped libs):
UriBuilder.of(uri).build() preserves host; URIUtils.extractHost yields the raw-authority host; InetAddress.getByName("2852039166") resolves to the metadata address.
- Matcher present unchanged on latest release v2.0.2:
git show v2.0.2:core/.../HttpClient.java -> isListEntryMatch at :589-636 with if (host == null) return false; and no IP/DNS normalization.
Affected Versions
io.kestra:kestra-core >= 2.0.1, <= 2.0.2. The getHost() matcher this finding shows to be incomplete was introduced by 0354ddf8 (first shipped in 2.0.1); latest release 2.0.2 still ships it unchanged, including the null-host fail-open. (2.0.0 shipped a weaker raw startsWith matcher that the decimal-IP vector also bypasses, but the clean incomplete-fix framing anchors at 2.0.1+.)
Suggested Fix
Resolve the request host to InetAddress and match/deny on the resolved IP with CIDR support (so operators can deny the link-local, loopback, and RFC-1918 ranges), and treat an unparseable / null authority as fail-closed for the deny-list.
Severity
- This is not a privilege boundary. Anyone who can author a flow already has code execution on a worker via script tasks and can reach the metadata endpoint directly, with no encoding trick and no HTTP task involved. denied-list has never been a sandbox against a flow author.
allowed-list already fails closed against every vector here: a null host and 2852039166 match no entry, so noneMatch rejects. That is the enforcement boundary. denied-list is a guardrail, shipped off by default, and cannot be made complete regardless of this fix: 169.254.169.254.nip.io resolves to the metadata address today with no encoding trick, as does any attacker-controlled A record, plus DNS rebinding between check and connect.
Our position: Low, CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N, affected >= 2.0.0.
Reported by zx (Jace) — GitHub: @manus-use
Summary
Kestra's opt-in HTTP egress control (
kestra.tasks.http.allowed-list/kestra.tasks.http.denied-list) is enforced by a host-string matcher that performs no IP-literal normalization and no DNS resolution, and fail-opens (returns "not matched") whenjava.net.URI.getHost()returnsnull. A flow author can therefore defeat a configured deny-list and reach cloud metadata / internal-only services using either a decimal-encoded IPv4 literal or an authority containing an underscore (which forcesgetHost()tonull). This is an incomplete-fix residual of thegetHost()-based matcher (commit0354ddf8), not the original SSRF (GHSA-r56g-q4p6-m3p6) which merely introduced the opt-in list.Root Cause
The deny/allow list is applied at exactly one chokepoint:
HttpClient.validateUri->isListEntryMatch(core/src/main/java/io/kestra/core/http/client/HttpClient.java~589-636). The matcher readsString host = uri.getHost(); if (host == null) return false;and compares that string against each list entry with no IP normalization and no DNS resolution. The socket target, however, is chosen by a different path: MicronautUriBuilderpreserves the host verbatim,HttpRequest.to()hands the identicalURItonew HttpUriRequestBase(method, uri), and Apache HttpClient5URIUtils.extractHostderives the target from the raw authority; the JDK resolver then interprets alternative IP encodings. This guard/connect divergence lets equivalent hosts slip past the deny match while still connecting.Impact
A flow author (unauthenticated in the OSS default no-auth posture; low-privilege authenticated flow editor in a hardened deployment) defeats a deployed deny-list and reaches the cloud-metadata endpoint (IAM credentials), localhost, and internal services.
http()returns the response body to the flow, so fetched data / credentials are exfiltrated as the expression value. This is the opposite of a "disabled control" false positive: the operator explicitly ENABLED an egress control that fails open.Proof of Concept
Instance configured with a deny-list containing the cloud metadata address (the 169.254.169.254 link-local IP). Flow expression:
2852039166is the 32-bit decimal encoding of the metadata link-local address.URI.getHost()returns the string"2852039166", which does not equal the dotted-quad deny entry, so the deny match fails; Apache/JDK still connect to the metadata service and the credentials are returned. Underscore/null-host variant:http://kestra_internal_db:8080/...makesgetHost()returnnull, soisListEntryMatchreturns false for EVERY deny entry.Attack Chain
{{ http(uri='http://2852039166/latest/meta-data/iam/security-credentials/') }}.validateUrideny-list atHttpClient.java:541.isListEntryMatch, which readsuri.getHost().isListEntryMatch(<metadata-ip>, uri).getHost().URI.create("http://2852039166/...").getHost()="2852039166"; that string does not equal the dotted-quad deny entry. (Null-host variant:getHost()== null -> returns false at :591 for every entry.)UriBuilder.of(uri).build()preserves host verbatim;HttpRequest.to()passes the identical URI tonew HttpUriRequestBase(method, uri)— no canonicalization.getAuthority()->2852039166;URIUtils.extractHost(uri)->http://2852039166.URIUtils.extractHostresult; JDK resolver parses the decimal literal.InetAddress.getByName("2852039166")resolves to the metadata link-local address (JDK 21, verified).ssrf-guardre-parses with the same URI and is bypassed identically; no redirect is required.Bypass Evidence
URI.create("http://2852039166/...").getHost()="2852039166"(non-null; string compare fails against dotted-quad deny entry).URI.create("http://foo_bar.internal/").getHost()=null->isListEntryMatchreturns false at :591 for every entry (no deny-list can ever match an authority containing an underscore).UriBuilder.of(uri).build()preserves host;URIUtils.extractHostyields the raw-authority host;InetAddress.getByName("2852039166")resolves to the metadata address.git show v2.0.2:core/.../HttpClient.java->isListEntryMatchat :589-636 withif (host == null) return false;and no IP/DNS normalization.Affected Versions
io.kestra:kestra-core>= 2.0.1, <= 2.0.2. ThegetHost()matcher this finding shows to be incomplete was introduced by0354ddf8(first shipped in 2.0.1); latest release 2.0.2 still ships it unchanged, including the null-host fail-open. (2.0.0 shipped a weaker rawstartsWithmatcher that the decimal-IP vector also bypasses, but the clean incomplete-fix framing anchors at 2.0.1+.)Suggested Fix
Resolve the request host to
InetAddressand match/deny on the resolved IP with CIDR support (so operators can deny the link-local, loopback, and RFC-1918 ranges), and treat an unparseable /nullauthority as fail-closed for the deny-list.Severity
allowed-listalready fails closed against every vector here: a null host and 2852039166 match no entry, so noneMatch rejects. That is the enforcement boundary.denied-listis a guardrail, shipped off by default, and cannot be made complete regardless of this fix: 169.254.169.254.nip.io resolves to the metadata address today with no encoding trick, as does any attacker-controlled A record, plus DNS rebinding between check and connect.Our position: Low, CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N, affected >= 2.0.0.
Reported by zx (Jace) — GitHub: @manus-use