Skip to content

Commit 6f2330f

Browse files
rudolfcursoragent
andcommitted
fix(saved-objects): guard against source commands in ComposerQuery pipeline
Restores the runtime check that rejects pipelines starting with FROM, ROW, SHOW, METRICS, or TS. The check now runs against toRequest().query rather than the raw string, since pipeline is now always a ComposerQuery. Without this a developer writing esql`FROM .kibana | LIMIT 10` would get an opaque ES|QL syntax error from Elasticsearch instead of a clear message. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 48ed521 commit 6f2330f

6 files changed

Lines changed: 100 additions & 129 deletions

File tree

dev_docs/tutorials/saved_objects_esql.mdx

Lines changed: 43 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -7,178 +7,97 @@ date: 2026-03-02
77
tags: ['kibana', 'dev', 'tutorials', 'saved-objects', 'esql']
88
---
99

10-
`SavedObjectsClientContract.esql` allows you to query Saved Objects using [ES|QL (Elasticsearch Query Language)](https://www.elastic.co/docs/explore-analyze/query-filter/languages/esql). It returns tabular results (columns and values) directly from Elasticsearch, which can be useful for analytics, aggregations, and cross-type queries that don't fit the `find` or `search` methods.
11-
12-
## Relationship to `find` and `search`
10+
`SavedObjectsClientContract.esql` allows you to query Saved Objects using [ES|QL (Elasticsearch Query Language)](https://www.elastic.co/docs/explore-analyze/query-filter/languages/esql). It returns tabular results (columns and values) directly from Elasticsearch, useful for analytics, aggregations, and cross-type queries that don't fit `find` or `search`.
1311

1412
| Method | Use case | Response format |
1513
|--------|----------|----------------|
16-
| `find` | Simple filtering and pagination of saved objects | Structured `SavedObject[]` |
17-
| `search` | Complex queries using Elasticsearch Query DSL | Raw Elasticsearch search hits |
18-
| `esql` | Tabular queries using ES|QL syntax | Tabular columns + values |
19-
20-
Use `esql` when you need ES|QL-specific features like `STATS`, `EVAL`, `ENRICH`, or pipe-based query composition.
14+
| `find` | Simple filtering and pagination | Structured `SavedObject[]` |
15+
| `search` | Elasticsearch Query DSL | Raw search hits |
16+
| `esql` | ES|QL — `STATS`, `EVAL`, `ENRICH`, pipes | Tabular columns + values |
2117

2218
<DocCallOut title="With great power comes great responsibility">
23-
While the `esql` method is powerful, it can increase code complexity, introduce performance issues and introduce security risks (like injection attacks). Carefully consider how you would like to use this method in your plugin to unlock value for users.
19+
The `esql` method gives direct access to saved object indices and executes with elevated `kibana_system` privileges. Always construct pipelines server-side and use parameterized values for any user-supplied input.
2420
</DocCallOut>
2521

26-
## The `pipeline` concept
22+
## Basic usage
2723

28-
Like `search` and `find`, you specify saved object **types** as a dedicated parameter — you never need to know or write index names. The `esql` method resolves the correct Elasticsearch indices from the `type` parameter and auto-generates the `FROM` clause. Security filters (namespace + type restriction) are injected via the `filter` parameter, so you don't need `WHERE type ==` either.
24+
You specify saved object **types** never index names. The `FROM` clause, namespace filter, and type restriction are all injected automatically. You write only the ES|QL **processing pipeline**.
2925

30-
You write only the ES|QL **processing pipeline** — everything after `FROM`:
26+
Build the pipeline with the `esql` tagged template from `@elastic/esql`:
3127

3228
```ts
29+
import { esql } from '@elastic/esql';
3330
import { isResponseError } from '@kbn/es-errors';
3431
import { MY_TYPE } from './saved_objects';
3532

36-
/** ...inside a route handler: */
37-
async (ctx, req, res) => {
38-
const core = await ctx.core;
39-
const savedObjectsClient = core.savedObjects.client;
40-
try {
41-
const result = await savedObjectsClient.esql({
42-
type: [MY_TYPE],
43-
namespaces: ['default'],
44-
pipeline: `| KEEP ${MY_TYPE}.title, ${MY_TYPE}.description
45-
| SORT ${MY_TYPE}.title
46-
| LIMIT 100`,
47-
});
48-
return res.ok({ body: { columns: result.columns, values: result.values } });
49-
} catch (e) {
50-
if (isResponseError(e)) {
51-
log.error(JSON.stringify(e.meta.body, null, 2));
52-
}
53-
throw e;
54-
}
55-
}
56-
```
57-
58-
To include METADATA fields on the auto-generated FROM clause, use the `metadata` option:
33+
const titleCol = esql.col(`${MY_TYPE}.title`);
34+
const descCol = esql.col(`${MY_TYPE}.description`);
5935

60-
```ts
6136
const result = await savedObjectsClient.esql({
6237
type: [MY_TYPE],
6338
namespaces: ['default'],
64-
metadata: ['_id', '_source'],
65-
// generates: FROM .kibana METADATA _id, _source | WHERE ...
66-
pipeline: '| WHERE my_type.title LIKE "test*" | LIMIT 100',
39+
pipeline: esql`
40+
KEEP ${titleCol}, ${descCol}
41+
| SORT ${titleCol}
42+
| LIMIT 100
43+
`,
6744
});
6845
```
6946

70-
See the full example in the Kibana repository at `examples/saved_objects`.
71-
72-
## Safe pipeline construction with the `esql` composer
73-
74-
Instead of a raw pipeline string, you can pass a `ComposerQuery` built with the `esql` tagged
75-
template from `@elastic/esql`. Template holes (`${{ name: value }}`) are automatically promoted
76-
to ES|QL named parameters — values are passed to Elasticsearch at the protocol level and never
77-
appear in the query string.
47+
To include `METADATA` fields (e.g. `_id`, `_source`), use the `metadata` option:
7848

7949
```ts
80-
import { esql } from '@elastic/esql';
81-
82-
const searchTerm = req.body.title; // user-supplied input
83-
8450
const result = await savedObjectsClient.esql({
8551
type: [MY_TYPE],
8652
namespaces: ['default'],
87-
// ${{ title }} → ?title param; value sent separately over the wire
88-
// The esql tag does not take a leading '|' — the pipe is added when joining with FROM.
89-
pipeline: esql`WHERE my_type.title LIKE ${{ title: searchTerm }} | LIMIT 100`,
53+
metadata: ['_id', '_source'],
54+
pipeline: esql`KEEP _id, ${esql.col(`${MY_TYPE}.title`)} | LIMIT 100`,
9055
});
9156
```
9257

93-
The `esql` tag accepts a pipeline-only template (no `FROM` required). It temporarily wraps the
94-
template in `FROM a | …` to satisfy the parser, then strips the source command, leaving a
95-
`ComposerQuery` whose `.toRequest()` returns the `?param` placeholder string alongside a
96-
`params` array. The SO client calls `.toRequest()` internally.
97-
98-
You can also mix composer params with a raw `params` array — they are merged before the request
99-
is sent, with composer params first.
100-
101-
## Safe pipeline construction with ES|QL params
102-
103-
When interpolating user input into ES|QL pipelines, **never** use string concatenation. Instead, use ES|QL's native parameterization — named params (`?paramName`) or positional params (`?`) — to separate code from data at the protocol level.
58+
See the full example in the Kibana repository at `examples/saved_objects`.
10459

105-
### Named params `?paramName` (recommended for user input)
60+
## Handling user input safely
10661

107-
Use `?paramName` placeholders in the pipeline string and pass the values via the `params` array as `{ name: value }` entries. This is true parameterization — the values are never interpolated into the query string, preventing injection attacks.
62+
Use `${{ name: value }}` holes in the `esql` template for any user-supplied value. The value becomes a named ES|QL parameter — it is forwarded to Elasticsearch separately and never appears in the query string.
10863

10964
```ts
110-
import type { estypes } from '@elastic/elasticsearch';
111-
112-
const userInput = req.body.searchTerm;
65+
const searchTerm = req.body.title; // user-supplied input
66+
const titleCol = esql.col(`${MY_TYPE}.title`);
11367

11468
const result = await savedObjectsClient.esql({
115-
type: ['my_type'],
69+
type: [MY_TYPE],
11670
namespaces: ['default'],
117-
pipeline: '| WHERE my_type.title LIKE ?searchTerm | LIMIT 100',
118-
// Named params are supported by ES at runtime, but the ES client TypeScript types
119-
// only define positional params — cast through unknown to bridge the type gap.
120-
params: [{ searchTerm: userInput }] as unknown as estypes.EsqlESQLParam[],
71+
pipeline: esql`
72+
WHERE ${titleCol} LIKE ${{ title: searchTerm }}
73+
| LIMIT 100
74+
`,
12175
});
12276
```
12377

124-
The pipeline sent to Elasticsearch will be `| WHERE my_type.title LIKE ?searchTerm | LIMIT 100` — with `searchTerm` as a separate parameter, never interpolated into the pipeline string.
125-
126-
### Positional params `?` (alternative)
127-
128-
ES|QL also supports positional `?` placeholders. Params are plain values (string, number, boolean, or null) matched by position:
129-
130-
```ts
131-
const result = await savedObjectsClient.esql({
132-
type: ['my_type'],
133-
namespaces: ['default'],
134-
pipeline: '| WHERE my_type.title LIKE ? | LIMIT 100',
135-
params: [userInput],
136-
});
137-
```
78+
**Never pass user input via `esql.exp(userInput)`** — that injects a raw ES|QL expression and bypasses parameterization entirely.
13879

13980
## Security model
14081

141-
### Execution context — `kibana_system` user
142-
143-
The ES|QL pipeline executes with the privileges of the `kibana_system` Elasticsearch user, which has elevated access including `manage_enrich` and broad index monitoring permissions. This means the pipeline can access resources that the end user may not be authorized to see.
144-
145-
**Never inject arbitrary or untrusted user input directly into the pipeline string.** If a user can control the full pipeline, they could use commands like `ENRICH` to join against enrich policies whose source data they would not normally have access to — this is a privilege escalation. Always construct the pipeline server-side and use parameterized values (see [Safe pipeline construction](#safe-pipeline-construction-with-esql-params)) for any user-provided input.
146-
147-
Using `ENRICH` in your pipeline is perfectly fine when you control the pipeline and are enriching from a policy whose data is appropriate for all users who will see the results.
148-
149-
### Index resolution from types
82+
### Execution context
15083

151-
Like `search` (which passes `index: getIndicesForTypes(types)` internally), the `esql` method resolves the correct Elasticsearch indices from the `type` parameter and auto-generates the `FROM` clause. You never need to know the index name — it is an implementation detail handled by the saved objects system.
84+
The pipeline executes as the `kibana_system` Elasticsearch user, which has elevated privileges. A user who controls the pipeline could use `ENRICH` to access data they are not authorized to see. Always construct the pipeline server-side; never let user input determine the pipeline structure.
15285

15386
### Space and type filtering
15487

155-
When you call `esql()`, namespace (space) and type filters are automatically injected into the `filter` parameter of the ES|QL request. The filter restricts results to the specified types and namespaces, so you don't need `WHERE type == "..."` in your pipeline. This works the same way as the `search` method:
88+
Namespace and type filters are automatically injected into every request. You never need `WHERE type == "..."` in your pipeline. If the caller is not authorized to access the requested namespaces or types, an empty response is returned.
15689

157-
1. `spacesExtension.getSearchableNamespaces()` resolves which namespaces the user can access
158-
2. `securityExtension.authorizeFind()` checks RBAC permissions
159-
3. A namespace bool filter (including type restriction) is constructed and merged with any user-provided `filter`
90+
### User-provided filters
16091

161-
If the user is not authorized to access any of the requested namespaces or types, an empty response is returned.
162-
163-
### User-provided filters are merged, not replaced
164-
165-
If you provide a `filter` in the options, it is merged with the security filter using `{ bool: { must: [securityFilter, yourFilter] } }`. Your filter is never used in isolation.
92+
If you supply a `filter` option it is merged with the security filter as `{ bool: { must: [securityFilter, yourFilter] } }` — never used in isolation.
16693

16794
## Encrypted attributes
16895

169-
Encrypted saved object attributes are handled differently depending on whether `_source` is present in the response:
170-
171-
- **With `_source` (via `metadata: ['_id', '_source']`):** The full document in `_source` contains all attributes needed for AAD (Additional Authenticated Data) reconstruction. Encrypted attributes are by default stripped from `_source`, or are **decrypted** in `_source`, using the same path as `find` and `search`, if registered with the `dangerouslyExposeValue` option. If decryption fails (e.g., key rotation), all encrypted attributes are stripped from `_source`.
172-
- **Standalone scalar columns (e.g., `connector.secrets`):** Always replaced with `null`, regardless of `_source` decryption. These columns contain raw ciphertext that cannot be used outside the document context.
173-
174-
For example, if a `connector` type has an encrypted `secrets` attribute:
175-
- `connector.secrets` column → always `null`
176-
- `_source` column → contains the full document with `secrets` decrypted (or stripped on failure)
96+
- **Standalone scalar columns** (e.g. `connector.secrets`): always replaced with `null`.
97+
- **`_source` column** (requires `metadata: ['_id', '_source']`): encrypted attributes are decrypted using the same path as `find` and `search`. If decryption fails, encrypted attributes are stripped from that row's `_source`.
17798

17899
## Response structure
179100

180-
The `esql` method returns the raw ES|QL response with `columns` and `values`:
181-
182101
```json
183102
{
184103
"columns": [
@@ -194,13 +113,12 @@ The `esql` method returns the raw ES|QL response with `columns` and `values`:
194113

195114
## When to use
196115

197-
- You need ES|QL-specific operations like `STATS`, `EVAL`, `ENRICH`, `DISSECT`, or `GROK`
198-
- You want tabular results for analytics or reporting
199-
- You need to compute aggregations across saved object types
116+
- ES|QL-specific operations: `STATS`, `EVAL`, `ENRICH`, `DISSECT`, `GROK`
117+
- Tabular results for analytics or reporting
118+
- Aggregations across saved object types
200119

201120
## When not to use
202121

203-
- You want structured `SavedObject` instances with `id`, `attributes`, `references` - use `find` instead
204-
- You need Elasticsearch Query DSL features like runtime mappings or aggregation trees - use `search` instead
205-
- Simple filtering and pagination - use `find` instead
206-
- You need ES|QL source commands like `ROW`, `SHOW`, or `METRICS` - use the raw Elasticsearch client directly
122+
- You want structured `SavedObject` instances — use `find`
123+
- You need Elasticsearch Query DSL — use `search`
124+
- You need ES|QL source commands like `ROW` or `SHOW` — use the raw Elasticsearch client

examples/saved_objects/server/esql_example_routes.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ export function registerEsqlExampleRoutes(router: IRouter, log: Logger) {
4343
const result = await savedObjectsClient.esql({
4444
type: [TYPE_A, TYPE_B],
4545
namespaces: ['default'],
46-
pipeline: esql`KEEP type, \`type-a.myField\`, \`type-b.anotherField\` | SORT type | LIMIT 100`,
46+
pipeline: esql`
47+
KEEP type, \`type-a.myField\`, \`type-b.anotherField\`
48+
| SORT type
49+
| LIMIT 100
50+
`,
4751
});
4852
return res.ok({
4953
body: {
@@ -89,7 +93,10 @@ export function registerEsqlExampleRoutes(router: IRouter, log: Logger) {
8993
const result = await savedObjectsClient.esql({
9094
type: TYPE_A,
9195
namespaces: ['default'],
92-
pipeline: esql`WHERE ${esql.exp(`${TYPE_A}.myField`)} == ${{ searchTerm }} | LIMIT 10`,
96+
pipeline: esql`
97+
WHERE ${esql.exp(`${TYPE_A}.myField`)} == ${{ searchTerm }}
98+
| LIMIT 10
99+
`,
93100
});
94101
return res.ok({
95102
body: {

src/core/packages/saved-objects/api-server-internal/src/lib/apis/esql.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,22 @@ describe('esql', () => {
136136
expect(request.query).toMatch(/^FROM .+ METADATA _id, _source \| LIMIT 10$/);
137137
});
138138

139+
it('should throw if pipeline starts with a source command', async () => {
140+
await expect(
141+
repository.esql({ ...options, pipeline: esql`FROM .kibana | LIMIT 10` })
142+
).rejects.toThrowError('options.pipeline must not start with a source command');
143+
144+
await expect(repository.esql({ ...options, pipeline: esql`ROW x = 1` })).rejects.toThrowError(
145+
'options.pipeline must not start with a source command'
146+
);
147+
148+
await expect(repository.esql({ ...options, pipeline: esql`SHOW INFO` })).rejects.toThrowError(
149+
'options.pipeline must not start with a source command'
150+
);
151+
152+
expect(client.esql.query).not.toHaveBeenCalled();
153+
});
154+
139155
it('should merge user-provided filter with namespace filter', async () => {
140156
const userFilter = { term: { 'index-pattern.title': 'my-pattern' } };
141157
await repository.esql({ ...options, filter: userFilter });

src/core/packages/saved-objects/api-server-internal/src/lib/apis/esql.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ export async function performEsql(
104104
: esql.from(indices).print();
105105

106106
const req = pipeline.toRequest();
107+
108+
if (/^\s*(FROM|ROW|SHOW|METRICS|TS)\b/i.test(req.query)) {
109+
throw SavedObjectsErrorHelpers.createBadRequestError(
110+
'options.pipeline must not start with a source command (FROM, ROW, SHOW, METRICS, TS). ' +
111+
'The FROM clause is auto-generated from the type parameter.'
112+
);
113+
}
114+
107115
// toRequest() prints pipeline-only queries without a leading '|';
108116
// we supply the separator when joining with the FROM clause.
109117
const query = `${fromClause} | ${req.query}`;

src/core/packages/saved-objects/api-server/src/apis/esql.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import type { ComposerQuery } from '@elastic/esql';
2424
* @remarks
2525
* **Security:** Template holes (`${{ name: value }}`) in the `esql` tag are automatically
2626
* promoted to ES|QL named parameters — values are forwarded to Elasticsearch at the protocol
27-
* level and never appear in the query string.
27+
* level and never appear in the query string. Never pass user input via `esql.exp(userInput)`,
28+
* which injects a raw expression and bypasses parameterization entirely.
2829
*
2930
* Standalone encrypted scalar columns are always replaced with `null`. When `_source` is
3031
* present (via `metadata: ['_id', '_source']`), encrypted attributes within `_source` are
@@ -52,7 +53,10 @@ export interface SavedObjectsEsqlOptions
5253
* ```ts
5354
* import { esql } from '@elastic/esql';
5455
*
55-
* pipeline: esql`WHERE dashboard.title LIKE ${{ title: searchTerm }} | LIMIT 100`,
56+
* pipeline: esql`
57+
* WHERE dashboard.title LIKE ${{ title: searchTerm }}
58+
* | LIMIT 100
59+
* `,
5660
* ```
5761
*/
5862
pipeline: ComposerQuery;

src/core/server/integration_tests/saved_objects/service/lib/esql.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,24 @@ describe('SOR - esql API', () => {
213213
expect(result.values[0][0]).toBe('charlie.brown@example.com');
214214
});
215215

216+
it('should reject pipelines starting with a source command', async () => {
217+
await expect(
218+
savedObjectsRepository.esql({
219+
type: 'esql-test-type',
220+
namespaces: ['default'],
221+
pipeline: esql`FROM .kibana | LIMIT 10`,
222+
})
223+
).rejects.toThrow('options.pipeline must not start with a source command');
224+
225+
await expect(
226+
savedObjectsRepository.esql({
227+
type: 'esql-test-type',
228+
namespaces: ['default'],
229+
pipeline: esql`ROW x = 1`,
230+
})
231+
).rejects.toThrow('options.pipeline must not start with a source command');
232+
});
233+
216234
it('should return empty response for unknown types', async () => {
217235
const result = await savedObjectsRepository.esql({
218236
type: 'unknown-type',

0 commit comments

Comments
 (0)