(settings, 'server.xsrf.whitelist').length > 0
+ ) {
+ log(
+ 'It is not recommended to disable xsrf protections for API endpoints via [server.xsrf.whitelist]. ' +
+ 'It will be removed in 8.0 release. Instead, supply the "kbn-xsrf" header.'
+ );
+ }
+ return settings;
+};
+
const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, log) => {
if (has(settings, 'server.basePath') && !has(settings, 'server.rewriteBasePath')) {
log(
@@ -177,4 +190,5 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({
rewriteBasePathDeprecation,
cspRulesDeprecation,
mapManifestServiceUrlDeprecation,
+ xsrfDeprecation,
];
diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts
index 0a9541393284e3..741c723ca93652 100644
--- a/src/core/server/http/http_server.mocks.ts
+++ b/src/core/server/http/http_server.mocks.ts
@@ -29,6 +29,7 @@ import {
RouteMethod,
KibanaResponseFactory,
RouteValidationSpec,
+ KibanaRouteState,
} from './router';
import { OnPreResponseToolkit } from './lifecycle/on_pre_response';
import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
@@ -43,6 +44,7 @@ interface RequestFixtureOptions {
method?: RouteMethod;
socket?: Socket;
routeTags?: string[];
+ kibanaRouteState?: KibanaRouteState;
routeAuthRequired?: false;
validation?: {
params?: RouteValidationSpec
;
@@ -62,6 +64,7 @@ function createKibanaRequestMock
({
routeTags,
routeAuthRequired,
validation = {},
+ kibanaRouteState = { xsrfRequired: true },
}: RequestFixtureOptions
= {}) {
const queryString = stringify(query, { sort: false });
@@ -80,7 +83,7 @@ function createKibanaRequestMock
({
search: queryString ? `?${queryString}` : queryString,
},
route: {
- settings: { tags: routeTags, auth: routeAuthRequired },
+ settings: { tags: routeTags, auth: routeAuthRequired, app: kibanaRouteState },
},
raw: {
req: { socket },
@@ -109,6 +112,7 @@ function createRawRequestMock(customization: DeepPartial = {}) {
return merge(
{},
{
+ app: { xsrfRequired: true } as any,
headers: {},
path: '/',
route: { settings: {} },
diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts
index 05ace06fa04e38..a30d64aed22195 100644
--- a/src/core/server/http/http_server.test.ts
+++ b/src/core/server/http/http_server.test.ts
@@ -811,6 +811,7 @@ test('exposes route details of incoming request to a route handler', async () =>
path: '/',
options: {
authRequired: true,
+ xsrfRequired: false,
tags: [],
},
});
@@ -923,6 +924,7 @@ test('exposes route details of incoming request to a route handler (POST + paylo
path: '/',
options: {
authRequired: true,
+ xsrfRequired: true,
tags: [],
body: {
parse: true, // hapi populates the default
diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts
index 025ab2bf56ac2d..cffdffab0d0cf7 100644
--- a/src/core/server/http/http_server.ts
+++ b/src/core/server/http/http_server.ts
@@ -27,7 +27,7 @@ import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_p
import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth';
import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response';
-import { IRouter } from './router';
+import { IRouter, KibanaRouteState, isSafeMethod } from './router';
import {
SessionStorageCookieOptions,
createCookieSessionStorageFactory,
@@ -147,9 +147,14 @@ export class HttpServer {
for (const route of router.getRoutes()) {
this.log.debug(`registering route handler for [${route.path}]`);
// Hapi does not allow payload validation to be specified for 'head' or 'get' requests
- const validate = ['head', 'get'].includes(route.method) ? undefined : { payload: true };
+ const validate = isSafeMethod(route.method) ? undefined : { payload: true };
const { authRequired = true, tags, body = {} } = route.options;
const { accepts: allow, maxBytes, output, parse } = body;
+
+ const kibanaRouteState: KibanaRouteState = {
+ xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method),
+ };
+
this.server.route({
handler: route.handler,
method: route.method,
@@ -157,6 +162,7 @@ export class HttpServer {
options: {
// Enforcing the comparison with true because plugins could overwrite the auth strategy by doing `options: { authRequired: authStrategy as any }`
auth: authRequired === true ? undefined : false,
+ app: kibanaRouteState,
tags: tags ? Array.from(tags) : undefined,
// TODO: This 'validate' section can be removed once the legacy platform is completely removed.
// We are telling Hapi that NP routes can accept any payload, so that it can bypass the default
diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts
index d31afe1670e419..8f4c02680f8a30 100644
--- a/src/core/server/http/index.ts
+++ b/src/core/server/http/index.ts
@@ -58,6 +58,8 @@ export {
RouteValidationError,
RouteValidatorFullConfig,
RouteValidationResultFactory,
+ DestructiveRouteMethod,
+ SafeRouteMethod,
} from './router';
export { BasePathProxyServer } from './base_path_proxy_server';
export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth';
diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
index f4c5f16870c7ed..b5364c616f17cf 100644
--- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
+++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
@@ -36,6 +36,7 @@ const versionHeader = 'kbn-version';
const xsrfHeader = 'kbn-xsrf';
const nameHeader = 'kbn-name';
const whitelistedTestPath = '/xsrf/test/route/whitelisted';
+const xsrfDisabledTestPath = '/xsrf/test/route/disabled';
const kibanaName = 'my-kibana-name';
const setupDeps = {
context: contextServiceMock.createSetupContract(),
@@ -188,6 +189,12 @@ describe('core lifecycle handlers', () => {
return res.ok({ body: 'ok' });
}
);
+ ((router as any)[method.toLowerCase()] as RouteRegistrar)(
+ { path: xsrfDisabledTestPath, validate: false, options: { xsrfRequired: false } },
+ (context, req, res) => {
+ return res.ok({ body: 'ok' });
+ }
+ );
});
await server.start();
@@ -235,6 +242,10 @@ describe('core lifecycle handlers', () => {
it('accepts whitelisted requests without either an xsrf or version header', async () => {
await getSupertest(method.toLowerCase(), whitelistedTestPath).expect(200, 'ok');
});
+
+ it('accepts requests on a route with disabled xsrf protection', async () => {
+ await getSupertest(method.toLowerCase(), xsrfDisabledTestPath).expect(200, 'ok');
+ });
});
});
});
diff --git a/src/core/server/http/lifecycle_handlers.test.ts b/src/core/server/http/lifecycle_handlers.test.ts
index 48a6973b741ba0..a80e432e0d4cb7 100644
--- a/src/core/server/http/lifecycle_handlers.test.ts
+++ b/src/core/server/http/lifecycle_handlers.test.ts
@@ -24,7 +24,7 @@ import {
} from './lifecycle_handlers';
import { httpServerMock } from './http_server.mocks';
import { HttpConfig } from './http_config';
-import { KibanaRequest, RouteMethod } from './router';
+import { KibanaRequest, RouteMethod, KibanaRouteState } from './router';
const createConfig = (partial: Partial): HttpConfig => partial as HttpConfig;
@@ -32,12 +32,14 @@ const forgeRequest = ({
headers = {},
path = '/',
method = 'get',
+ kibanaRouteState,
}: Partial<{
headers: Record;
path: string;
method: RouteMethod;
+ kibanaRouteState: KibanaRouteState;
}>): KibanaRequest => {
- return httpServerMock.createKibanaRequest({ headers, path, method });
+ return httpServerMock.createKibanaRequest({ headers, path, method, kibanaRouteState });
};
describe('xsrf post-auth handler', () => {
@@ -142,6 +144,29 @@ describe('xsrf post-auth handler', () => {
expect(toolkit.next).toHaveBeenCalledTimes(1);
expect(result).toEqual('next');
});
+
+ it('accepts requests if xsrf protection on a route is disabled', () => {
+ const config = createConfig({
+ xsrf: { whitelist: [], disableProtection: false },
+ });
+ const handler = createXsrfPostAuthHandler(config);
+ const request = forgeRequest({
+ method: 'post',
+ headers: {},
+ path: '/some-path',
+ kibanaRouteState: {
+ xsrfRequired: false,
+ },
+ });
+
+ toolkit.next.mockReturnValue('next' as any);
+
+ const result = handler(request, responseFactory, toolkit);
+
+ expect(responseFactory.badRequest).not.toHaveBeenCalled();
+ expect(toolkit.next).toHaveBeenCalledTimes(1);
+ expect(result).toEqual('next');
+ });
});
});
diff --git a/src/core/server/http/lifecycle_handlers.ts b/src/core/server/http/lifecycle_handlers.ts
index ee877ee031a2bb..7ef7e863260391 100644
--- a/src/core/server/http/lifecycle_handlers.ts
+++ b/src/core/server/http/lifecycle_handlers.ts
@@ -20,6 +20,7 @@
import { OnPostAuthHandler } from './lifecycle/on_post_auth';
import { OnPreResponseHandler } from './lifecycle/on_pre_response';
import { HttpConfig } from './http_config';
+import { isSafeMethod } from './router';
import { Env } from '../config';
import { LifecycleRegistrar } from './http_server';
@@ -31,15 +32,18 @@ export const createXsrfPostAuthHandler = (config: HttpConfig): OnPostAuthHandler
const { whitelist, disableProtection } = config.xsrf;
return (request, response, toolkit) => {
- if (disableProtection || whitelist.includes(request.route.path)) {
+ if (
+ disableProtection ||
+ whitelist.includes(request.route.path) ||
+ request.route.options.xsrfRequired === false
+ ) {
return toolkit.next();
}
- const isSafeMethod = request.route.method === 'get' || request.route.method === 'head';
const hasVersionHeader = VERSION_HEADER in request.headers;
const hasXsrfHeader = XSRF_HEADER in request.headers;
- if (!isSafeMethod && !hasVersionHeader && !hasXsrfHeader) {
+ if (!isSafeMethod(request.route.method) && !hasVersionHeader && !hasXsrfHeader) {
return response.badRequest({ body: `Request must contain a ${XSRF_HEADER} header.` });
}
diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts
index 32663d1513f36b..d254f391ca5e41 100644
--- a/src/core/server/http/router/index.ts
+++ b/src/core/server/http/router/index.ts
@@ -24,16 +24,20 @@ export {
KibanaRequestEvents,
KibanaRequestRoute,
KibanaRequestRouteOptions,
+ KibanaRouteState,
isRealRequest,
LegacyRequest,
ensureRawRequest,
} from './request';
export {
+ DestructiveRouteMethod,
+ isSafeMethod,
RouteMethod,
RouteConfig,
RouteConfigOptions,
RouteContentType,
RouteConfigOptionsBody,
+ SafeRouteMethod,
validBodyOutput,
} from './route';
export { HapiResponseAdapter } from './response_adapter';
diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts
index 703571ba53c0a3..bb2db6367f701f 100644
--- a/src/core/server/http/router/request.ts
+++ b/src/core/server/http/router/request.ts
@@ -18,18 +18,24 @@
*/
import { Url } from 'url';
-import { Request } from 'hapi';
+import { Request, ApplicationState } from 'hapi';
import { Observable, fromEvent, merge } from 'rxjs';
import { shareReplay, first, takeUntil } from 'rxjs/operators';
import { deepFreeze, RecursiveReadonly } from '../../../utils';
import { Headers } from './headers';
-import { RouteMethod, RouteConfigOptions, validBodyOutput } from './route';
+import { RouteMethod, RouteConfigOptions, validBodyOutput, isSafeMethod } from './route';
import { KibanaSocket, IKibanaSocket } from './socket';
import { RouteValidator, RouteValidatorFullConfig } from './validator';
const requestSymbol = Symbol('request');
+/**
+ * @internal
+ */
+export interface KibanaRouteState extends ApplicationState {
+ xsrfRequired: boolean;
+}
/**
* Route options: If 'GET' or 'OPTIONS' method, body options won't be returned.
* @public
@@ -184,8 +190,10 @@ export class KibanaRequest<
const options = ({
authRequired: request.route.settings.auth !== false,
+ // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8
+ xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true,
tags: request.route.settings.tags || [],
- body: ['get', 'options'].includes(method)
+ body: isSafeMethod(method)
? undefined
: {
parse,
diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts
index 4439a80b1eac71..d1458ef4ad0632 100644
--- a/src/core/server/http/router/route.ts
+++ b/src/core/server/http/router/route.ts
@@ -19,11 +19,27 @@
import { RouteValidatorFullConfig } from './validator';
+export function isSafeMethod(method: RouteMethod): method is SafeRouteMethod {
+ return method === 'get' || method === 'options';
+}
+
+/**
+ * Set of HTTP methods changing the state of the server.
+ * @public
+ */
+export type DestructiveRouteMethod = 'post' | 'put' | 'delete' | 'patch';
+
+/**
+ * Set of HTTP methods not changing the state of the server.
+ * @public
+ */
+export type SafeRouteMethod = 'get' | 'options';
+
/**
* The set of common HTTP methods supported by Kibana routing.
* @public
*/
-export type RouteMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options';
+export type RouteMethod = SafeRouteMethod | DestructiveRouteMethod;
/**
* The set of valid body.output
@@ -108,6 +124,15 @@ export interface RouteConfigOptions {
*/
authRequired?: boolean;
+ /**
+ * Defines xsrf protection requirements for a route:
+ * - true. Requires an incoming POST/PUT/DELETE request to contain `kbn-xsrf` header.
+ * - false. Disables xsrf protection.
+ *
+ * Set to true by default
+ */
+ xsrfRequired?: Method extends 'get' ? never : boolean;
+
/**
* Additional metadata tag strings to attach to the route.
*/
diff --git a/src/core/server/index.ts b/src/core/server/index.ts
index de6cdb2d7acd78..0c112e3cfb5b2a 100644
--- a/src/core/server/index.ts
+++ b/src/core/server/index.ts
@@ -159,6 +159,8 @@ export {
SessionStorageCookieOptions,
SessionCookieValidationResult,
SessionStorageFactory,
+ DestructiveRouteMethod,
+ SafeRouteMethod,
} from './http';
export { RenderingServiceSetup, IRenderOptions } from './rendering';
export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging';
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 445ed16ec7829b..8c5e84446a0d3c 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -685,6 +685,9 @@ export interface DeprecationSettings {
message: string;
}
+// @public
+export type DestructiveRouteMethod = 'post' | 'put' | 'delete' | 'patch';
+
// @public
export interface DiscoveredPlugin {
readonly configPath: ConfigPath;
@@ -1459,6 +1462,7 @@ export interface RouteConfigOptions {
authRequired?: boolean;
body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody;
tags?: readonly string[];
+ xsrfRequired?: Method extends 'get' ? never : boolean;
}
// @public
@@ -1473,7 +1477,7 @@ export interface RouteConfigOptionsBody {
export type RouteContentType = 'application/json' | 'application/*+json' | 'application/octet-stream' | 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/*';
// @public
-export type RouteMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options';
+export type RouteMethod = SafeRouteMethod | DestructiveRouteMethod;
// @public
export type RouteRegistrar = (route: RouteConfig
, handler: RequestHandler
) => void;
@@ -1526,6 +1530,9 @@ export interface RouteValidatorOptions {
};
}
+// @public
+export type SafeRouteMethod = 'get' | 'options';
+
// @public (undocumented)
export interface SavedObject {
attributes: T;