Skip to content

Commit 1608581

Browse files
committed
Merge branch 'main' of https://github.com/elastic/kibana into dashboard-api/update-search-schema
2 parents 703a02c + 0e225a3 commit 1608581

70 files changed

Lines changed: 1549 additions & 606 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/platform/packages/shared/controls/controls-schemas/src/controls_group_schema.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const pinnedControlSchema = schema.object({
4848
}),
4949
});
5050

51-
export const getControlsGroupSchema = () => {
51+
export const getControlsGroupSchema = (isInternalReadRequest: boolean = false) => {
5252
const pinnedControl = pinnedControlSchema.getPropSchemas();
5353
return schema.arrayOf(
5454
/**
@@ -119,7 +119,7 @@ export const getControlsGroupSchema = () => {
119119
]),
120120
{
121121
defaultValue: [],
122-
maxSize: 100,
122+
maxSize: isInternalReadRequest ? Number.MAX_SAFE_INTEGER : 100,
123123
meta: { description: 'An array of control panels and their state in the control group.' },
124124
}
125125
);

src/platform/packages/shared/kbn-monaco/src/languages/console/console_errors_provider.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ export const setupConsoleErrorsProvider = (workerProxyService: ConsoleWorkerProx
3232
monaco.editor.setModelMarkers(
3333
model,
3434
CONSOLE_LANG_ID,
35-
errors.map(({ offset, text }) => {
35+
errors.map(({ offset, endOffset, text }) => {
3636
const { column, lineNumber } = model.getPositionAt(offset);
37+
const endPosition =
38+
endOffset !== undefined ? model.getPositionAt(endOffset) : { column, lineNumber };
3739
return {
3840
startLineNumber: lineNumber,
3941
startColumn: column,
40-
endLineNumber: lineNumber,
41-
endColumn: column,
42+
endLineNumber: endPosition.lineNumber,
43+
endColumn: endPosition.column,
4244
message: text,
4345
severity: monaco.MarkerSeverity.Error,
4446
};

src/platform/packages/shared/kbn-monaco/src/languages/console/parser.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,99 @@ describe('console parser', () => {
186186
]);
187187
});
188188

189+
describe('request-line method boundary', () => {
190+
// The first token of a request line must have one canonical method
191+
// interpretation. A valid method prefix followed by non-whitespace
192+
// characters (e.g. `GETT`, `POSTS`, `PUThjjkjoj`) must not be silently
193+
// accepted as the matching method with a malformed url tail.
194+
const methodBoundaryError = 'Expected one of GET/POST/PUT/DELETE/HEAD/PATCH';
195+
196+
it('rejects a valid method prefix followed by extra letters (`GETT _search`)', () => {
197+
const { errors } = parser('GETT _search')!;
198+
expect(errors.length).toBeGreaterThanOrEqual(1);
199+
expect(errors[0].text).toBe(methodBoundaryError);
200+
});
201+
202+
it('rejects a valid method prefix followed by an `S` suffix (`POSTS _search`)', () => {
203+
const { errors } = parser('POSTS _search')!;
204+
expect(errors.length).toBeGreaterThanOrEqual(1);
205+
expect(errors[0].text).toBe(methodBoundaryError);
206+
});
207+
208+
it('rejects a method with arbitrary trailing characters (`PUThjjkjoj /my-index`)', () => {
209+
const { errors } = parser('PUThjjkjoj /my-index')!;
210+
expect(errors.length).toBeGreaterThanOrEqual(1);
211+
expect(errors[0].text).toBe(methodBoundaryError);
212+
});
213+
214+
it('recovers and continues parsing the next request after an invalid method line', () => {
215+
const input = 'GETT _search\nPOST _bulk';
216+
const { requests, errors } = parser(input)!;
217+
// First request is invalid: errors are recorded
218+
expect(errors.length).toBeGreaterThanOrEqual(1);
219+
expect(errors[0].text).toBe(methodBoundaryError);
220+
// Second request is parsed correctly
221+
const validRequest = requests.find(
222+
(r) => r.endOffset !== undefined && r.endOffset >= 'GETT _search\n'.length
223+
);
224+
expect(validRequest).toBeDefined();
225+
});
226+
227+
it('marks every consecutive invalid-method-prefix line, not only the first', () => {
228+
const input = 'PUThjjkjoj /my-index\nGETT _search\nPOSTS _bulk\nGET _ok';
229+
const { requests, errors } = parser(input)!;
230+
// Each of the three invalid-prefix lines must produce its own marker
231+
// so users see them all, not only the first one.
232+
const boundaryErrors = errors.filter((e) => e.text === methodBoundaryError);
233+
expect(boundaryErrors.length).toBe(3);
234+
// The final valid request must also be captured despite the preceding
235+
// boundary errors.
236+
const validRequest = requests.find(
237+
(r) =>
238+
r.startOffset === 'PUThjjkjoj /my-index\nGETT _search\nPOSTS _bulk\n'.length &&
239+
r.endOffset !== undefined
240+
);
241+
expect(validRequest).toBeDefined();
242+
});
243+
244+
// The boundary is "next character separates the method from the URL/body".
245+
// These tables lock in which inputs are accepted vs rejected.
246+
it.each([
247+
['GET_index', 'underscore'],
248+
['GET2 _x', 'digit'],
249+
['GETSomething', 'letter'],
250+
['HEADX _x', 'letter'],
251+
['DELETEX _x', 'letter'],
252+
['PATCHX _x', 'letter'],
253+
['GET/_search', 'slash'],
254+
['GET?q=1', 'question mark'],
255+
['GET-foo', 'dash'],
256+
['GET*', 'star'],
257+
])('rejects invalid method boundary: %s (%s)', (input) => {
258+
const { errors } = parser(input)!;
259+
expect(errors.length).toBe(1);
260+
expect(errors[0].text).toBe(methodBoundaryError);
261+
expect(errors[0].offset).toBe(0);
262+
expect(errors[0].endOffset).toBe(input.split(/[ \t\r\n]/)[0].length);
263+
});
264+
265+
it.each([
266+
['GET _search', 'space'],
267+
['GET\t_search', 'tab'],
268+
['GET', 'end-of-input'],
269+
])('accepts request-line separator after method: %s (%s)', (input) => {
270+
const { errors, requests } = parser(input)!;
271+
expect(errors.filter((e) => e.text === methodBoundaryError)).toEqual([]);
272+
expect(requests.length).toBe(1);
273+
});
274+
275+
it('does not falsely flag a method whose url contains identifier chars (`GET _index_internal`)', () => {
276+
const { errors, requests } = parser('GET _index_internal')!;
277+
expect(errors).toEqual([]);
278+
expect(requests.length).toBe(1);
279+
});
280+
});
281+
189282
it('handles # and // comment lines between requests', () => {
190283
const input = '# c\nGET _search\n// c2\nPOST _test';
191284
const { requests, errors } = parser(input)!;

src/platform/packages/shared/kbn-monaco/src/languages/console/parser.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,20 @@ export const createParser = (): ConsoleParser => {
3232
};
3333
let text = '';
3434
let errors: ErrorAnnotation[] = [];
35-
const addError = function (errorText: string) {
36-
errors.push({ text: errorText, offset: at });
35+
const addError = function (errorText: string, offset = at, endOffset?: number) {
36+
const errorAnnotation: ErrorAnnotation = { text: errorText, offset };
37+
if (endOffset !== undefined) {
38+
errorAnnotation.endOffset = endOffset;
39+
}
40+
errors.push(errorAnnotation);
41+
};
42+
const hasErrorCoveringOffset = function (offset: number) {
43+
return errors.some(
44+
(errorAnnotation) =>
45+
errorAnnotation.endOffset !== undefined &&
46+
errorAnnotation.offset <= offset &&
47+
offset <= errorAnnotation.endOffset
48+
);
3749
};
3850
let requests: ParsedRequest[] = [];
3951
let requestStartOffset: number | undefined;
@@ -57,8 +69,8 @@ export const createParser = (): ConsoleParser => {
5769
lastRequest.endOffset = requestEndOffset;
5870
requests.push(lastRequest);
5971
};
60-
const error = function (m: string): never {
61-
throw Object.assign(new SyntaxError(m), { at, text });
72+
const error = function (m: string, errorAt = at, errorEndAt?: number): never {
73+
throw Object.assign(new SyntaxError(m), { at: errorAt, endAt: errorEndAt, text });
6274
};
6375
const reset = function (newAt: number) {
6476
ch = text.charAt(newAt);
@@ -95,6 +107,16 @@ export const createParser = (): ConsoleParser => {
95107
const peek = function (offset: number) {
96108
return text.charAt(at + offset);
97109
};
110+
const isRequestLineSeparator = function (character: string) {
111+
return character === ' ' || character === '\t' || character === '\n' || character === '\r';
112+
};
113+
const readCurrentLineTokenEndOffset = function (startOffset: number) {
114+
let tokenEndOffset = startOffset;
115+
while (tokenEndOffset < text.length && !isRequestLineSeparator(text[tokenEndOffset])) {
116+
tokenEndOffset++;
117+
}
118+
return tokenEndOffset;
119+
};
98120
const number = function () {
99121
let numString = '';
100122

@@ -234,6 +256,18 @@ export const createParser = (): ConsoleParser => {
234256
}
235257
return error("Unexpected '" + ch + "'");
236258
};
259+
// After consuming method letters, the next character must separate the
260+
// method from the URL/body: horizontal whitespace, a line break, or
261+
// end-of-input. Anything else is part of an invalid method token.
262+
const methodBoundary = function () {
263+
if (ch && !isRequestLineSeparator(ch)) {
264+
error(
265+
'Expected one of GET/POST/PUT/DELETE/HEAD/PATCH',
266+
requestStartOffset ?? at,
267+
readCurrentLineTokenEndOffset(at)
268+
);
269+
}
270+
};
237271
// parses and returns the method
238272
const method = function () {
239273
const upperCaseChar = ch.toUpperCase();
@@ -242,12 +276,14 @@ export const createParser = (): ConsoleParser => {
242276
nextOneOf(['G', 'g']);
243277
nextOneOf(['E', 'e']);
244278
nextOneOf(['T', 't']);
279+
methodBoundary();
245280
return 'GET';
246281
case 'H':
247282
nextOneOf(['H', 'h']);
248283
nextOneOf(['E', 'e']);
249284
nextOneOf(['A', 'a']);
250285
nextOneOf(['D', 'd']);
286+
methodBoundary();
251287
return 'HEAD';
252288
case 'D':
253289
nextOneOf(['D', 'd']);
@@ -256,6 +292,7 @@ export const createParser = (): ConsoleParser => {
256292
nextOneOf(['E', 'e']);
257293
nextOneOf(['T', 't']);
258294
nextOneOf(['E', 'e']);
295+
methodBoundary();
259296
return 'DELETE';
260297
case 'P':
261298
nextOneOf(['P', 'p']);
@@ -266,15 +303,18 @@ export const createParser = (): ConsoleParser => {
266303
nextOneOf(['T', 't']);
267304
nextOneOf(['C', 'c']);
268305
nextOneOf(['H', 'h']);
306+
methodBoundary();
269307
return 'PATCH';
270308
case 'U':
271309
nextOneOf(['U', 'u']);
272310
nextOneOf(['T', 't']);
311+
methodBoundary();
273312
return 'PUT';
274313
case 'O':
275314
nextOneOf(['O', 'o']);
276315
nextOneOf(['S', 's']);
277316
nextOneOf(['T', 't']);
317+
methodBoundary();
278318
return 'POST';
279319
default:
280320
error("Unexpected '" + ch + "'");
@@ -417,10 +457,16 @@ export const createParser = (): ConsoleParser => {
417457
request();
418458
white();
419459
} catch (e: unknown) {
420-
addError(getErrorMessage(e));
460+
const syntaxError = e as { at?: number; endAt?: number };
461+
addError(getErrorMessage(e), syntaxError.at, syntaxError.endAt);
421462
// snap
422463
const remainingText = text.substr(at);
423-
const nextMethodIndex = remainingText.search(/^\s*(POST|HEAD|GET|PUT|DELETE|PATCH)\b/im);
464+
// Match the verb without a trailing `\b` so that lines starting with
465+
// a valid method prefix but continuing with identifier characters
466+
// (`GETT`, `POSTS`, `PUThjjkjoj`) are picked up as recovery anchors;
467+
// `method()` then re-throws at the boundary and an error annotation
468+
// is recorded for each such line.
469+
const nextMethodIndex = remainingText.search(/^\s*(POST|HEAD|GET|PUT|DELETE|PATCH)/im);
424470
const nextCommentLine = remainingText.search(/^\s*(#|\/\*|\/\/).*$/m);
425471
if (nextMethodIndex === -1 && nextCommentLine === -1) {
426472
// If there are no comments or other requests after the error, there is no point in parsing more so we stop here
@@ -444,7 +490,9 @@ export const createParser = (): ConsoleParser => {
444490
multiRequest();
445491
white();
446492
if (ch) {
447-
addError('Syntax error');
493+
if (!hasErrorCoveringOffset(at)) {
494+
addError('Syntax error');
495+
}
448496
}
449497

450498
const result = { errors, requests };

src/platform/packages/shared/kbn-monaco/src/languages/console/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
export interface ErrorAnnotation {
1111
offset: number;
12+
endOffset?: number;
1213
text: string;
1314
}
1415

src/platform/plugins/shared/dashboard/server/api/dashboard_state_schemas.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ const sectionGridSchema = schema.object({
121121
y: schema.number({ meta: { description: 'The y coordinate of the section in grid units.' } }),
122122
});
123123

124-
export function getSectionSchema(isDashboardAppRequest: boolean) {
124+
export function getSectionSchema(isDashboardAppRequest: boolean, isReadRequest: boolean = false) {
125125
return schema.object(
126126
{
127127
title: schema.string({
@@ -138,7 +138,7 @@ export function getSectionSchema(isDashboardAppRequest: boolean) {
138138
panels: schema.arrayOf(getPanelSchema(isDashboardAppRequest), {
139139
meta: { description: 'The panels that belong to the section.' },
140140
defaultValue: [],
141-
maxSize: MAX_PANELS,
141+
maxSize: isDashboardAppRequest && isReadRequest ? Number.MAX_SAFE_INTEGER : MAX_PANELS,
142142
}),
143143
id: schema.maybe(
144144
schema.string({
@@ -230,16 +230,19 @@ export const accessControlSchema = schema.maybe(
230230
)
231231
);
232232

233-
export function getDashboardStateSchema(isDashboardAppRequest: boolean) {
233+
export function getDashboardStateSchema(
234+
isDashboardAppRequest: boolean,
235+
isReadRequest: boolean = false
236+
) {
234237
return schema.object(
235238
{
236-
pinned_panels: getPinnedPanelsSchema(),
239+
pinned_panels: getPinnedPanelsSchema(isDashboardAppRequest && isReadRequest),
237240
description: schema.maybe(
238241
schema.string({ meta: { description: 'A short description of the dashboard.' } })
239242
),
240243
filters: schema.maybe(
241244
schema.arrayOf(asCodeFilterSchema, {
242-
maxSize: 500,
245+
maxSize: isDashboardAppRequest && isReadRequest ? Number.MAX_SAFE_INTEGER : 500,
243246
meta: {
244247
description: 'Filters applied across all panels, including pinned panels.',
245248
},
@@ -249,11 +252,11 @@ export function getDashboardStateSchema(isDashboardAppRequest: boolean) {
249252
panels: schema.arrayOf(
250253
schema.oneOf([
251254
getPanelSchema(isDashboardAppRequest),
252-
getSectionSchema(isDashboardAppRequest),
255+
getSectionSchema(isDashboardAppRequest, isReadRequest),
253256
]),
254257
{
255258
defaultValue: [],
256-
maxSize: MAX_PANELS,
259+
maxSize: isDashboardAppRequest && isReadRequest ? Number.MAX_SAFE_INTEGER : MAX_PANELS,
257260
meta: {
258261
description:
259262
'Panels and sections in the dashboard. Each entry is either a panel (with a `type` and `config`) or a collapsible section (with a `title`, `collapsed` state, and nested `panels`).',
@@ -272,7 +275,7 @@ export function getDashboardStateSchema(isDashboardAppRequest: boolean) {
272275
refresh_interval: schema.maybe(refreshIntervalSchema),
273276
tags: schema.maybe(
274277
schema.arrayOf(schema.string(), {
275-
maxSize: 100,
278+
maxSize: isDashboardAppRequest && isReadRequest ? Number.MAX_SAFE_INTEGER : 100,
276279
meta: { description: 'Tag IDs to associate with this dashboard.' },
277280
})
278281
),

src/platform/plugins/shared/dashboard/server/api/read/register_read_route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function registerReadRoute(
3737
// Route is registered during setup and before all plugins have registered embeddable schemas.
3838
// Instead, use once to only call getDashboardStateSchema the first time a route handler is executed.
3939
const getCachedDashboardStateSchema = once(() => {
40-
return getDashboardStateSchema(isDashboardAppRequest);
40+
return getDashboardStateSchema(isDashboardAppRequest, true);
4141
});
4242

4343
readRoute.addVersion(

src/platform/plugins/shared/dashboard/server/api/read/schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function getReadResponseBodySchema(isDashboardAppRequest: boolean) {
2020
'The unique ID of the dashboard, as returned by the create or search endpoints.',
2121
},
2222
}),
23-
data: getDashboardStateSchema(isDashboardAppRequest),
23+
data: getDashboardStateSchema(isDashboardAppRequest, true),
2424
meta: asCodeMetaSchema,
2525
warnings: schema.maybe(warningsSchema),
2626
});

x-pack/platform/packages/shared/response-ops/alerting-v2-constants/src/artifacts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
/** Artifact type identifier for runbooks */
99
export const RUNBOOK_ARTIFACT_TYPE = 'runbook';
1010

11+
/** Artifact type identifier for linked dashboards */
12+
export const DASHBOARD_ARTIFACT_TYPE = 'dashboard';
13+
1114
/** Default maximum character length for artifact values (applies when no type-specific override exists) */
1215
export const DEFAULT_ARTIFACT_VALUE_LIMIT = 1024;
1316

x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/__stories__/rule_form_flyout.stories.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ const mockServices = {
5959
EmbeddableComponent: () => null,
6060
stateHelperApi: () => ({}),
6161
} as any,
62+
uiActions: {
63+
getAction: async () => ({
64+
execute: ({ onResults }: { onResults: (results: unknown[]) => void }) => onResults([]),
65+
}),
66+
} as any,
6267
};
6368

6469
const mockFormServices: RuleFormServices = {
@@ -68,6 +73,7 @@ const mockFormServices: RuleFormServices = {
6873
application: mockServices.application,
6974
notifications: mockServices.notifications,
7075
lens: mockServices.lens,
76+
uiActions: mockServices.uiActions,
7177
};
7278

7379
// =============================================================================

0 commit comments

Comments
 (0)