Skip to content

SSRF deny-list bypass via decimal-IP and null-host (underscore authority) in HttpClient egress matcher

Moderate
loicmathieu published GHSA-wvqw-j4rf-jhf8 Oct 6, 2026

Package

maven io.kestra:kestra-core (Maven)

Affected versions

>= 2.0.0

Patched versions

2.0.4

Description

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

  1. 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().
  2. 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.)
  3. 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.
  4. 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).
  5. 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

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
High
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N

CVE ID

No known CVE

Weaknesses

Server-Side Request Forgery (SSRF)

The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination. Learn more on MITRE.

Improper Validation of Syntactic Correctness of Input

The product receives input that is expected to be well-formed - i.e., to comply with a certain syntax - but it does not validate or incorrectly validates that the input complies with the syntax. Learn more on MITRE.

Credits