mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Enables preventing access to internal APIs (#156935)
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
ed3941d45e
commit
7bbe92f085
35 changed files with 438 additions and 18 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -922,6 +922,7 @@ x-pack/test/observability_functional @elastic/actionable-observability
|
|||
/WORKSPACE.bazel @elastic/kibana-operations
|
||||
/.buildkite/ @elastic/kibana-operations
|
||||
/kbn_pm/ @elastic/kibana-operations
|
||||
/x-pack/dev-tools @elastic/kibana-operations
|
||||
|
||||
# Appex QA
|
||||
/src/dev/code_coverage @elastic/appex-qa
|
||||
|
|
|
@ -18,6 +18,8 @@ xpack.license_management.enabled: false
|
|||
#xpack.canvas.enabled: false #only disabable in dev-mode
|
||||
xpack.reporting.enabled: false
|
||||
|
||||
# Enforce restring access to internal APIs see https://github.com/elastic/kibana/issues/151940
|
||||
# server.restrictInternalApis: true
|
||||
# Telemetry enabled by default and not disableable via UI
|
||||
telemetry.optIn: true
|
||||
telemetry.allowChangingOptInStatus: false
|
||||
|
|
|
@ -160,6 +160,7 @@ describe('Fetch', () => {
|
|||
expect(fetchMock.lastOptions()!.headers).toMatchObject({
|
||||
'content-type': 'application/json',
|
||||
'kbn-version': 'VERSION',
|
||||
'x-elastic-internal-origin': 'Kibana',
|
||||
myheader: 'foo',
|
||||
});
|
||||
});
|
||||
|
@ -168,13 +169,30 @@ describe('Fetch', () => {
|
|||
fetchMock.get('*', {});
|
||||
await expect(
|
||||
fetchInstance.fetch('/my/path', {
|
||||
headers: { myHeader: 'foo', 'kbn-version': 'CUSTOM!' },
|
||||
headers: {
|
||||
myHeader: 'foo',
|
||||
'kbn-version': 'CUSTOM!',
|
||||
},
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid fetch headers, headers beginning with \\"kbn-\\" are not allowed: [kbn-version]"`
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow overwriting of x-elastic-internal-origin header', async () => {
|
||||
fetchMock.get('*', {});
|
||||
await expect(
|
||||
fetchInstance.fetch('/my/path', {
|
||||
headers: {
|
||||
myHeader: 'foo',
|
||||
'x-elastic-internal-origin': 'anything',
|
||||
},
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid fetch headers, headers beginning with \\"x-elastic-internal-\\" are not allowed: [x-elastic-internal-origin]"`
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set kbn-system-request header by default', async () => {
|
||||
fetchMock.get('*', {});
|
||||
await fetchInstance.fetch('/my/path', {
|
||||
|
@ -310,6 +328,7 @@ describe('Fetch', () => {
|
|||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'kbn-version': 'VERSION',
|
||||
'x-elastic-internal-origin': 'Kibana',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,7 +19,10 @@ import type {
|
|||
HttpResponse,
|
||||
HttpFetchOptionsWithPath,
|
||||
} from '@kbn/core-http-browser';
|
||||
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
|
||||
import {
|
||||
ELASTIC_HTTP_VERSION_HEADER,
|
||||
X_ELASTIC_INTERNAL_ORIGIN_REQUEST,
|
||||
} from '@kbn/core-http-common';
|
||||
import { HttpFetchError } from './http_fetch_error';
|
||||
import { HttpInterceptController } from './http_intercept_controller';
|
||||
import { interceptRequest, interceptResponse } from './intercept';
|
||||
|
@ -131,6 +134,7 @@ export class Fetch {
|
|||
...options.headers,
|
||||
'kbn-version': this.params.kibanaVersion,
|
||||
[ELASTIC_HTTP_VERSION_HEADER]: version,
|
||||
[X_ELASTIC_INTERNAL_ORIGIN_REQUEST]: 'Kibana',
|
||||
...(!isEmpty(context) ? new ExecutionContextContainer(context).toHeader() : {}),
|
||||
}),
|
||||
};
|
||||
|
@ -223,12 +227,23 @@ const validateFetchArguments = (
|
|||
);
|
||||
}
|
||||
|
||||
const invalidHeaders = Object.keys(fullOptions.headers ?? {}).filter((headerName) =>
|
||||
const invalidKbnHeaders = Object.keys(fullOptions.headers ?? {}).filter((headerName) =>
|
||||
headerName.startsWith('kbn-')
|
||||
);
|
||||
if (invalidHeaders.length) {
|
||||
const invalidInternalOriginProducHeader = Object.keys(fullOptions.headers ?? {}).filter(
|
||||
(headerName) => headerName.includes(X_ELASTIC_INTERNAL_ORIGIN_REQUEST)
|
||||
);
|
||||
|
||||
if (invalidKbnHeaders.length) {
|
||||
throw new Error(
|
||||
`Invalid fetch headers, headers beginning with "kbn-" are not allowed: [${invalidHeaders.join(
|
||||
`Invalid fetch headers, headers beginning with "kbn-" are not allowed: [${invalidKbnHeaders.join(
|
||||
','
|
||||
)}]`
|
||||
);
|
||||
}
|
||||
if (invalidInternalOriginProducHeader.length) {
|
||||
throw new Error(
|
||||
`Invalid fetch headers, headers beginning with "x-elastic-internal-" are not allowed: [${invalidInternalOriginProducHeader.join(
|
||||
','
|
||||
)}]`
|
||||
);
|
||||
|
|
|
@ -148,6 +148,7 @@ export interface IAnonymousPaths {
|
|||
/**
|
||||
* Headers to append to the request. Any headers that begin with `kbn-` are considered private to Core and will cause
|
||||
* {@link HttpHandler} to throw an error.
|
||||
* Includes the required Header that validates internal requests to internal APIs
|
||||
* @public
|
||||
*/
|
||||
export interface HttpHeadersInit {
|
||||
|
|
|
@ -9,4 +9,4 @@
|
|||
export type { IExternalUrlPolicy } from './src/external_url_policy';
|
||||
|
||||
export type { ApiVersion } from './src/versioning';
|
||||
export { ELASTIC_HTTP_VERSION_HEADER } from './src/constants';
|
||||
export { ELASTIC_HTTP_VERSION_HEADER, X_ELASTIC_INTERNAL_ORIGIN_REQUEST } from './src/constants';
|
||||
|
|
|
@ -8,3 +8,5 @@
|
|||
|
||||
/** @internal */
|
||||
export const ELASTIC_HTTP_VERSION_HEADER = 'elastic-api-version' as const;
|
||||
|
||||
export const X_ELASTIC_INTERNAL_ORIGIN_REQUEST = 'x-elastic-internal-origin' as const;
|
||||
|
|
|
@ -67,6 +67,7 @@ Object {
|
|||
"allowFromAnyIp": false,
|
||||
"ipAllowlist": Array [],
|
||||
},
|
||||
"restrictInternalApis": false,
|
||||
"rewriteBasePath": false,
|
||||
"securityResponseHeaders": Object {
|
||||
"crossOriginOpenerPolicy": "same-origin",
|
||||
|
|
|
@ -150,6 +150,7 @@ const configSchema = schema.object(
|
|||
},
|
||||
}
|
||||
),
|
||||
restrictInternalApis: schema.boolean({ defaultValue: false }), // allow access to internal routes by default to prevent breaking changes in current offerings
|
||||
},
|
||||
{
|
||||
validate: (rawConfig) => {
|
||||
|
@ -223,6 +224,7 @@ export class HttpConfig implements IHttpConfig {
|
|||
public xsrf: { disableProtection: boolean; allowlist: string[] };
|
||||
public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] };
|
||||
public shutdownTimeout: Duration;
|
||||
public restrictInternalApis: boolean;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
|
@ -263,6 +265,7 @@ export class HttpConfig implements IHttpConfig {
|
|||
this.xsrf = rawHttpConfig.xsrf;
|
||||
this.requestId = rawHttpConfig.requestId;
|
||||
this.shutdownTimeout = rawHttpConfig.shutdownTimeout;
|
||||
this.restrictInternalApis = rawHttpConfig.restrictInternalApis;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,10 +13,12 @@ import type {
|
|||
OnPreResponseToolkit,
|
||||
OnPostAuthToolkit,
|
||||
OnPreRoutingToolkit,
|
||||
OnPostAuthHandler,
|
||||
} from '@kbn/core-http-server';
|
||||
import { mockRouter } from '@kbn/core-http-router-server-mocks';
|
||||
import {
|
||||
createCustomHeadersPreResponseHandler,
|
||||
createRestrictInternalRoutesPostAuthHandler,
|
||||
createVersionCheckPostAuthHandler,
|
||||
createXsrfPostAuthHandler,
|
||||
} from './lifecycle_handlers';
|
||||
|
@ -242,6 +244,108 @@ describe('versionCheck post-auth handler', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('restrictInternal post-auth handler', () => {
|
||||
let toolkit: ToolkitMock;
|
||||
let responseFactory: ReturnType<typeof mockRouter.createResponseFactory>;
|
||||
|
||||
beforeEach(() => {
|
||||
toolkit = createToolkit();
|
||||
responseFactory = mockRouter.createResponseFactory();
|
||||
});
|
||||
const createForgeRequest = (
|
||||
access: 'internal' | 'public',
|
||||
headers: Record<string, string> | undefined = {}
|
||||
) => {
|
||||
return forgeRequest({
|
||||
method: 'get',
|
||||
headers,
|
||||
path: `/${access}/some-path`,
|
||||
kibanaRouteOptions: {
|
||||
xsrfRequired: false,
|
||||
access,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createForwardSuccess = (handler: OnPostAuthHandler, request: KibanaRequest) => {
|
||||
toolkit.next.mockReturnValue('next' as any);
|
||||
const result = handler(request, responseFactory, toolkit);
|
||||
|
||||
expect(toolkit.next).toHaveBeenCalledTimes(1);
|
||||
expect(responseFactory.badRequest).not.toHaveBeenCalled();
|
||||
expect(result).toBe('next');
|
||||
};
|
||||
|
||||
describe('when restriction is enabled', () => {
|
||||
const config = createConfig({
|
||||
name: 'my-server-name',
|
||||
restrictInternalApis: true,
|
||||
});
|
||||
it('returns a bad request if called without internal origin header for internal API', () => {
|
||||
const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig);
|
||||
const request = createForgeRequest('internal');
|
||||
|
||||
responseFactory.badRequest.mockReturnValue('badRequest' as any);
|
||||
|
||||
const result = handler(request, responseFactory, toolkit);
|
||||
|
||||
expect(toolkit.next).not.toHaveBeenCalled();
|
||||
expect(responseFactory.badRequest.mock.calls[0][0]?.body).toMatch(
|
||||
/uri \[.*\/internal\/some-path\] with method \[get\] exists but is not available with the current configuration/
|
||||
);
|
||||
expect(result).toBe('badRequest');
|
||||
});
|
||||
|
||||
it('forward the request to the next interceptor if called with internal origin header for internal API', () => {
|
||||
const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig);
|
||||
const request = createForgeRequest('internal', { 'x-elastic-internal-origin': 'Kibana' });
|
||||
createForwardSuccess(handler, request);
|
||||
});
|
||||
|
||||
it('forward the request to the next interceptor if called with internal origin header for public APIs', () => {
|
||||
const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig);
|
||||
const request = createForgeRequest('public', { 'x-elastic-internal-origin': 'Kibana' });
|
||||
createForwardSuccess(handler, request);
|
||||
});
|
||||
|
||||
it('forward the request to the next interceptor if called without internal origin header for public APIs', () => {
|
||||
const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig);
|
||||
const request = createForgeRequest('public');
|
||||
createForwardSuccess(handler, request);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when restriction is not enabled', () => {
|
||||
const config = createConfig({
|
||||
name: 'my-server-name',
|
||||
restrictInternalApis: false,
|
||||
});
|
||||
it('forward the request to the next interceptor if called without internal origin header for internal APIs', () => {
|
||||
const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig);
|
||||
const request = createForgeRequest('internal');
|
||||
createForwardSuccess(handler, request);
|
||||
});
|
||||
|
||||
it('forward the request to the next interceptor if called with internal origin header for internal API', () => {
|
||||
const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig);
|
||||
const request = createForgeRequest('internal', { 'x-elastic-internal-origin': 'Kibana' });
|
||||
createForwardSuccess(handler, request);
|
||||
});
|
||||
|
||||
it('forward the request to the next interceptor if called without internal origin header for public APIs', () => {
|
||||
const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig);
|
||||
const request = createForgeRequest('public');
|
||||
createForwardSuccess(handler, request);
|
||||
});
|
||||
|
||||
it('forward the request to the next interceptor if called with internal origin header for public APIs', () => {
|
||||
const handler = createRestrictInternalRoutesPostAuthHandler(config as HttpConfig);
|
||||
const request = createForgeRequest('public', { 'x-elastic-internal-origin': 'Kibana' });
|
||||
createForwardSuccess(handler, request);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('customHeaders pre-response handler', () => {
|
||||
let toolkit: ToolkitMock;
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import { Env } from '@kbn/config';
|
||||
import type { OnPostAuthHandler, OnPreResponseHandler } from '@kbn/core-http-server';
|
||||
import { isSafeMethod } from '@kbn/core-http-router-server-internal';
|
||||
import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST } from '@kbn/core-http-common/src/constants';
|
||||
import { HttpConfig } from './http_config';
|
||||
import { LifecycleRegistrar } from './http_server';
|
||||
|
||||
|
@ -39,6 +40,27 @@ export const createXsrfPostAuthHandler = (config: HttpConfig): OnPostAuthHandler
|
|||
};
|
||||
};
|
||||
|
||||
export const createRestrictInternalRoutesPostAuthHandler = (
|
||||
config: HttpConfig
|
||||
): OnPostAuthHandler => {
|
||||
const isRestrictionEnabled = config.restrictInternalApis;
|
||||
|
||||
return (request, response, toolkit) => {
|
||||
const isInternalRoute = request.route.options.access === 'internal';
|
||||
|
||||
// only check if the header is present, not it's content.
|
||||
const hasInternalKibanaRequestHeader = X_ELASTIC_INTERNAL_ORIGIN_REQUEST in request.headers;
|
||||
|
||||
if (isRestrictionEnabled && isInternalRoute && !hasInternalKibanaRequestHeader) {
|
||||
// throw 400
|
||||
return response.badRequest({
|
||||
body: `uri [${request.url}] with method [${request.route.method}] exists but is not available with the current configuration`,
|
||||
});
|
||||
}
|
||||
return toolkit.next();
|
||||
};
|
||||
};
|
||||
|
||||
export const createVersionCheckPostAuthHandler = (kibanaVersion: string): OnPostAuthHandler => {
|
||||
return (request, response, toolkit) => {
|
||||
const requestVersion = request.headers[VERSION_HEADER];
|
||||
|
@ -60,7 +82,6 @@ export const createVersionCheckPostAuthHandler = (kibanaVersion: string): OnPost
|
|||
};
|
||||
};
|
||||
|
||||
// TODO: implement header required for accessing internal routes. See https://github.com/elastic/kibana/issues/151940
|
||||
export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPreResponseHandler => {
|
||||
const {
|
||||
name: serverName,
|
||||
|
@ -76,7 +97,6 @@ export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPre
|
|||
'Content-Security-Policy': cspHeader,
|
||||
[KIBANA_NAME_HEADER]: serverName,
|
||||
};
|
||||
|
||||
return toolkit.next({ headers: additionalHeaders });
|
||||
};
|
||||
};
|
||||
|
@ -86,7 +106,12 @@ export const registerCoreHandlers = (
|
|||
config: HttpConfig,
|
||||
env: Env
|
||||
) => {
|
||||
// add headers based on config
|
||||
registrar.registerOnPreResponse(createCustomHeadersPreResponseHandler(config));
|
||||
// add extra request checks stuff
|
||||
registrar.registerOnPostAuth(createXsrfPostAuthHandler(config));
|
||||
// add check on version
|
||||
registrar.registerOnPostAuth(createVersionCheckPostAuthHandler(env.packageInfo.version));
|
||||
// add check on header if the route is internal
|
||||
registrar.registerOnPostAuth(createRestrictInternalRoutesPostAuthHandler(config)); // strictly speaking, we should have access to route.options.access from the request on postAuth
|
||||
};
|
||||
|
|
|
@ -50,6 +50,7 @@ const createConfigService = () => {
|
|||
shutdownTimeout: moment.duration(30, 'seconds'),
|
||||
keepaliveTimeout: 120_000,
|
||||
socketTimeout: 120_000,
|
||||
restrictInternalApis: false,
|
||||
} as any);
|
||||
}
|
||||
if (path === 'externalUrl') {
|
||||
|
|
|
@ -347,7 +347,11 @@ describe('throwIfAnyTypeNotVisibleByAPI', () => {
|
|||
|
||||
describe('logWarnOnExternalRequest', () => {
|
||||
let logger: MockedLogger;
|
||||
const firstPartyRequestHeaders = { 'kbn-version': 'a', referer: 'b' };
|
||||
const firstPartyRequestHeaders = {
|
||||
'kbn-version': 'a',
|
||||
referer: 'b',
|
||||
'x-elastic-internal-origin': 'foo',
|
||||
};
|
||||
const kibRequest = httpServerMock.createKibanaRequest({ headers: firstPartyRequestHeaders });
|
||||
const extRequest = httpServerMock.createKibanaRequest();
|
||||
|
||||
|
|
|
@ -157,7 +157,9 @@ export interface BulkGetItem {
|
|||
export function isKibanaRequest({ headers }: KibanaRequest) {
|
||||
// The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client.
|
||||
// We can't be 100% certain, but this is a reasonable attempt.
|
||||
return headers && headers['kbn-version'] && headers.referer;
|
||||
return (
|
||||
headers && headers['kbn-version'] && headers.referer && headers['x-elastic-internal-origin']
|
||||
);
|
||||
}
|
||||
|
||||
export interface LogWarnOnExternalRequest {
|
||||
|
|
|
@ -9,6 +9,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -20,6 +21,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -36,6 +38,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -47,6 +50,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -63,6 +67,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -74,6 +79,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -113,6 +119,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -129,6 +136,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -140,6 +148,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -156,6 +165,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -167,6 +177,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -183,6 +194,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -194,6 +206,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
@ -233,6 +246,7 @@ Array [
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"kbn-version": "kibanaVersion",
|
||||
"x-elastic-internal-origin": "Kibana",
|
||||
},
|
||||
"method": "POST",
|
||||
},
|
||||
|
|
|
@ -51,7 +51,11 @@ describe('CoreUsageStatsClient', () => {
|
|||
);
|
||||
return { usageStatsClient, debugLoggerMock, basePathMock, repositoryMock };
|
||||
};
|
||||
const firstPartyRequestHeaders = { 'kbn-version': 'a', referer: 'b' }; // as long as these two header fields are truthy, this will be treated like a first-party request
|
||||
const firstPartyRequestHeaders = {
|
||||
'kbn-version': 'a',
|
||||
referer: 'b',
|
||||
'x-elastic-internal-origin': 'c',
|
||||
}; // as long as these header fields are truthy, this will be treated like a first-party request
|
||||
const incrementOptions = { refresh: false };
|
||||
|
||||
describe('#getUsageStats', () => {
|
||||
|
|
|
@ -245,7 +245,9 @@ function getFieldsForCounter(prefix: string) {
|
|||
}
|
||||
|
||||
function getIsKibanaRequest({ headers }: KibanaRequest) {
|
||||
// The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client.
|
||||
// The presence of these request headers gives us a good indication that this is a first-party request from the Kibana client.
|
||||
// We can't be 100% certain, but this is a reasonable attempt.
|
||||
return headers && headers['kbn-version'] && headers.referer;
|
||||
return (
|
||||
headers && headers['kbn-version'] && headers.referer && headers['x-elastic-internal-origin']
|
||||
);
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ export const httpConfigSchema = schema.object(
|
|||
}),
|
||||
}),
|
||||
ssl: sslSchema,
|
||||
restrictInternalApis: schema.boolean({ defaultValue: false }),
|
||||
},
|
||||
{ unknowns: 'ignore' }
|
||||
);
|
||||
|
@ -54,6 +55,7 @@ export class HttpConfig implements IHttpConfig {
|
|||
socketTimeout: number;
|
||||
cors: ICorsConfig;
|
||||
ssl: ISslConfig;
|
||||
restrictInternalApis: boolean;
|
||||
|
||||
constructor(rawConfig: HttpConfigType) {
|
||||
this.basePath = rawConfig.basePath;
|
||||
|
@ -65,5 +67,6 @@ export class HttpConfig implements IHttpConfig {
|
|||
this.socketTimeout = rawConfig.socketTimeout;
|
||||
this.cors = rawConfig.cors;
|
||||
this.ssl = new SslConfig(rawConfig.ssl);
|
||||
this.restrictInternalApis = rawConfig.restrictInternalApis;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@ describe('BasePathProxyServer', () => {
|
|||
},
|
||||
ssl: { enabled: false },
|
||||
maxPayload: new ByteSizeValue(1024),
|
||||
restrictInternalApis: false,
|
||||
};
|
||||
|
||||
const serverOptions = getServerOptions(config);
|
||||
|
|
|
@ -20,6 +20,7 @@ describe('server config', () => {
|
|||
"valueInBytes": 1048576,
|
||||
},
|
||||
"port": 3000,
|
||||
"restrictInternalApis": false,
|
||||
"shutdownTimeout": "PT30S",
|
||||
"socketTimeout": 120000,
|
||||
"ssl": Object {
|
||||
|
@ -188,4 +189,34 @@ describe('server config', () => {
|
|||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restrictInternalApis', () => {
|
||||
test('is false by default', () => {
|
||||
const configSchema = config.schema;
|
||||
const obj = {};
|
||||
expect(new ServerConfig(configSchema.validate(obj)).restrictInternalApis).toBe(false);
|
||||
});
|
||||
|
||||
test('can specify retriction on access to internal APIs', () => {
|
||||
const configSchema = config.schema;
|
||||
expect(
|
||||
new ServerConfig(configSchema.validate({ restrictInternalApis: true })).restrictInternalApis
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
new ServerConfig(configSchema.validate({ restrictInternalApis: false }))
|
||||
.restrictInternalApis
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('throws if not boolean', () => {
|
||||
const configSchema = config.schema;
|
||||
expect(() => configSchema.validate({ restrictInternalApis: 100 })).toThrowError(
|
||||
'restrictInternalApis]: expected value of type [boolean] but got [number]'
|
||||
);
|
||||
expect(() => configSchema.validate({ restrictInternalApis: 'something' })).toThrowError(
|
||||
'restrictInternalApis]: expected value of type [boolean] but got [string]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -40,6 +40,7 @@ const configSchema = schema.object(
|
|||
defaultValue: 120000,
|
||||
}),
|
||||
ssl: sslSchema,
|
||||
restrictInternalApis: schema.boolean({ defaultValue: false }),
|
||||
},
|
||||
{
|
||||
validate: (rawConfig) => {
|
||||
|
@ -74,6 +75,7 @@ export class ServerConfig implements IHttpConfig {
|
|||
socketTimeout: number;
|
||||
ssl: ISslConfig;
|
||||
cors: ICorsConfig;
|
||||
restrictInternalApis: boolean;
|
||||
|
||||
constructor(rawConfig: ServerConfigType) {
|
||||
this.host = rawConfig.host;
|
||||
|
@ -88,5 +90,6 @@ export class ServerConfig implements IHttpConfig {
|
|||
allowCredentials: false,
|
||||
allowOrigin: ['*'],
|
||||
};
|
||||
this.restrictInternalApis = rawConfig.restrictInternalApis;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,6 +57,7 @@ export class Auth {
|
|||
'kbn-version': version,
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-elastic-internal-origin': 'Kibana',
|
||||
},
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
|
|
|
@ -38,6 +38,7 @@ const createConfig = (parts: Partial<IHttpConfig>): IHttpConfig => ({
|
|||
enabled: false,
|
||||
...parts.ssl,
|
||||
},
|
||||
restrictInternalApis: false,
|
||||
});
|
||||
|
||||
describe('getServerOptions', () => {
|
||||
|
|
|
@ -18,6 +18,7 @@ export interface IHttpConfig {
|
|||
cors: ICorsConfig;
|
||||
ssl: ISslConfig;
|
||||
shutdownTimeout: Duration;
|
||||
restrictInternalApis: boolean;
|
||||
}
|
||||
|
||||
export interface ICorsConfig {
|
||||
|
|
|
@ -25,11 +25,49 @@ const nameHeader = 'kbn-name';
|
|||
const allowlistedTestPath = '/xsrf/test/route/whitelisted';
|
||||
const xsrfDisabledTestPath = '/xsrf/test/route/disabled';
|
||||
const kibanaName = 'my-kibana-name';
|
||||
const internalProductHeader = 'x-elastic-internal-origin';
|
||||
const setupDeps = {
|
||||
context: contextServiceMock.createSetupContract(),
|
||||
executionContext: executionContextServiceMock.createInternalSetupContract(),
|
||||
};
|
||||
|
||||
interface HttpConfigTestOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
const setUpDefaultServerConfig = ({ enabled }: HttpConfigTestOptions = {}) =>
|
||||
({
|
||||
hosts: ['localhost'],
|
||||
maxPayload: new ByteSizeValue(1024),
|
||||
shutdownTimeout: moment.duration(30, 'seconds'),
|
||||
autoListen: true,
|
||||
ssl: {
|
||||
enabled: false,
|
||||
},
|
||||
cors: {
|
||||
enabled: false,
|
||||
},
|
||||
compression: { enabled: true, brotli: { enabled: false } },
|
||||
name: kibanaName,
|
||||
securityResponseHeaders: {
|
||||
// reflects default config
|
||||
strictTransportSecurity: null,
|
||||
xContentTypeOptions: 'nosniff',
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
permissionsPolicy: null,
|
||||
crossOriginOpenerPolicy: 'same-origin',
|
||||
},
|
||||
customResponseHeaders: {
|
||||
'some-header': 'some-value',
|
||||
'referrer-policy': 'strict-origin', // overrides a header that is defined by securityResponseHeaders
|
||||
},
|
||||
xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] },
|
||||
requestId: {
|
||||
allowFromAnyIp: true,
|
||||
ipAllowlist: [],
|
||||
},
|
||||
restrictInternalApis: enabled ?? false, // reflects default for public routes
|
||||
} as any);
|
||||
|
||||
describe('core lifecycle handlers', () => {
|
||||
let server: HttpService;
|
||||
let innerServer: HttpServerSetup['server'];
|
||||
|
@ -247,4 +285,109 @@ describe('core lifecycle handlers', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restrictInternalRoutes post-auth handler', () => {
|
||||
const testInternalRoute = '/restrict_internal_routes/test/route_internal';
|
||||
const testPublicRoute = '/restrict_internal_routes/test/route_public';
|
||||
beforeEach(async () => {
|
||||
router.get(
|
||||
{ path: testInternalRoute, validate: false, options: { access: 'internal' } },
|
||||
(context, req, res) => {
|
||||
return res.ok({ body: 'ok()' });
|
||||
}
|
||||
);
|
||||
router.get(
|
||||
{ path: testPublicRoute, validate: false, options: { access: 'public' } },
|
||||
(context, req, res) => {
|
||||
return res.ok({ body: 'ok()' });
|
||||
}
|
||||
);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
it('accepts requests with the internal product header to internal routes', async () => {
|
||||
await supertest(innerServer.listener)
|
||||
.get(testInternalRoute)
|
||||
.set(internalProductHeader, 'anything')
|
||||
.expect(200, 'ok()');
|
||||
});
|
||||
|
||||
it('accepts requests with the internal product header to public routes', async () => {
|
||||
await supertest(innerServer.listener)
|
||||
.get(testPublicRoute)
|
||||
.set(internalProductHeader, 'anything')
|
||||
.expect(200, 'ok()');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('core lifecyle handers with restrict internal routes enforced', () => {
|
||||
let server: HttpService;
|
||||
let innerServer: HttpServerSetup['server'];
|
||||
let router: IRouter;
|
||||
|
||||
beforeEach(async () => {
|
||||
const configService = configServiceMock.create();
|
||||
configService.atPath.mockImplementation((path) => {
|
||||
if (path === 'server') {
|
||||
return new BehaviorSubject(setUpDefaultServerConfig({ enabled: true }));
|
||||
}
|
||||
if (path === 'externalUrl') {
|
||||
return new BehaviorSubject({
|
||||
policy: [],
|
||||
} as any);
|
||||
}
|
||||
if (path === 'csp') {
|
||||
return new BehaviorSubject({
|
||||
strict: false,
|
||||
disableEmbedding: false,
|
||||
warnLegacyBrowsers: true,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected config path: ${path}`);
|
||||
});
|
||||
server = createHttpServer({ configService });
|
||||
|
||||
await server.preboot({ context: contextServiceMock.createPrebootContract() });
|
||||
const serverSetup = await server.setup(setupDeps);
|
||||
router = serverSetup.createRouter('/');
|
||||
innerServer = serverSetup.server;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
describe('restrictInternalRoutes postauth handler', () => {
|
||||
const testInternalRoute = '/restrict_internal_routes/test/route_internal';
|
||||
const testPublicRoute = '/restrict_internal_routes/test/route_public';
|
||||
beforeEach(async () => {
|
||||
router.get(
|
||||
{ path: testInternalRoute, validate: false, options: { access: 'internal' } },
|
||||
(context, req, res) => {
|
||||
return res.ok({ body: 'ok()' });
|
||||
}
|
||||
);
|
||||
router.get(
|
||||
{ path: testPublicRoute, validate: false, options: { access: 'public' } },
|
||||
(context, req, res) => {
|
||||
return res.ok({ body: 'ok()' });
|
||||
}
|
||||
);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
it('request requests without the internal product header to internal routes', async () => {
|
||||
const result = await supertest(innerServer.listener).get(testInternalRoute).expect(400);
|
||||
expect(result.body.error).toBe('Bad Request');
|
||||
});
|
||||
|
||||
it('accepts requests with the internal product header to internal routes', async () => {
|
||||
await supertest(innerServer.listener)
|
||||
.get(testInternalRoute)
|
||||
.set(internalProductHeader, 'anything')
|
||||
.expect(200, 'ok()');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -161,6 +161,7 @@ kibana_vars=(
|
|||
server.requestId.allowFromAnyIp
|
||||
server.requestId.ipAllowlist
|
||||
server.rewriteBasePath
|
||||
server.restrictInternalApis
|
||||
server.securityResponseHeaders.disableEmbedding
|
||||
server.securityResponseHeaders.permissionsPolicy
|
||||
server.securityResponseHeaders.referrerPolicy
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
|
||||
import { createStartServicesGetter } from '@kbn/kibana-utils-plugin/public';
|
||||
import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST } from '@kbn/core-http-common';
|
||||
import { fetchStreaming as fetchStreamingStatic, FetchStreamingParams } from './streaming';
|
||||
import { DISABLE_BFETCH_COMPRESSION, removeLeadingSlash } from '../common';
|
||||
import { createStreamingBatchedFunction, StreamingBatchedFunctionParams } from './batching';
|
||||
|
@ -83,6 +84,7 @@ export class BfetchPublicPlugin
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'kbn-version': version,
|
||||
[X_ELASTIC_INTERNAL_ORIGIN_REQUEST]: 'Kibana',
|
||||
...(params.headers || {}),
|
||||
},
|
||||
getIsCompressionDisabled,
|
||||
|
|
|
@ -39,7 +39,6 @@ export function fetchStreaming({
|
|||
if (!isCompressionDisabled) {
|
||||
url = appendQueryParam(url, 'compress', 'true');
|
||||
}
|
||||
|
||||
// Begin the request
|
||||
xhr.open(method, url);
|
||||
xhr.withCredentials = true;
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"@kbn/config-schema",
|
||||
"@kbn/std",
|
||||
"@kbn/core-http-server",
|
||||
"@kbn/core-http-common",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -9,6 +9,7 @@ import fetch from 'node-fetch';
|
|||
import { resolve } from 'path';
|
||||
import abab from 'abab';
|
||||
import pkg from '../../package.json';
|
||||
import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST } from '@kbn/core-http-common/src/constants';
|
||||
|
||||
function getRequestParams(argv) {
|
||||
// use `--host=https://somedomain.com:5601` or else http://localhost:5601 is defaulted
|
||||
|
@ -32,6 +33,7 @@ function getRequestHeaders(auth) {
|
|||
'kbn-version': pkg.version,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: auth,
|
||||
[X_ELASTIC_INTERNAL_ORIGIN_REQUEST]: 'Kibana',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import { ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT } from '../routes/tags';
|
|||
import { canRedirectRequest } from './can_redirect_request';
|
||||
|
||||
describe('can_redirect_request', () => {
|
||||
it('returns true if request does not have either a kbn-version or kbn-xsrf header', () => {
|
||||
it('returns true if request does not have either a kbn-version or kbn-xsrf header or x-elastic-internal-origin', () => {
|
||||
expect(canRedirectRequest(httpServerMock.createKibanaRequest())).toBe(true);
|
||||
});
|
||||
|
||||
|
@ -26,6 +26,14 @@ describe('can_redirect_request', () => {
|
|||
expect(canRedirectRequest(request)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if request has a x-elastic-internal-origin header', () => {
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
headers: { 'x-elastic-internal-origin': 'something' },
|
||||
});
|
||||
|
||||
expect(canRedirectRequest(request)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for api routes', () => {
|
||||
expect(
|
||||
canRedirectRequest(httpServerMock.createKibanaRequest({ path: '/api/security/some' }))
|
||||
|
|
|
@ -27,7 +27,11 @@ describe('UsageStatsClient', () => {
|
|||
return { usageStatsClient, debugLoggerMock, repositoryMock };
|
||||
};
|
||||
|
||||
const firstPartyRequestHeaders = { 'kbn-version': 'a', referer: 'b' }; // as long as these two header fields are truthy, this will be treated like a first-party request
|
||||
const firstPartyRequestHeaders = {
|
||||
'kbn-version': 'a',
|
||||
referer: 'b',
|
||||
'x-elastic-internal-origin': 'c',
|
||||
}; // as long as these header fields are truthy, this will be treated like a first-party request
|
||||
const incrementOptions = { refresh: false };
|
||||
|
||||
describe('#getUsageStats', () => {
|
||||
|
|
|
@ -119,7 +119,9 @@ export class UsageStatsClient {
|
|||
}
|
||||
|
||||
function getIsKibanaRequest(headers?: Headers) {
|
||||
// The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client.
|
||||
// The presence of these request headers gives us a good indication that this is a first-party request from the Kibana client.
|
||||
// We can't be 100% certain, but this is a reasonable attempt.
|
||||
return headers && headers['kbn-version'] && headers.referer;
|
||||
return (
|
||||
headers && headers['kbn-version'] && headers.referer && headers['x-elastic-internal-origin']
|
||||
);
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ interface SendOptions {
|
|||
options: object;
|
||||
strategy: string;
|
||||
space?: string;
|
||||
internalOrigin: string;
|
||||
}
|
||||
|
||||
export class BsearchSecureService extends FtrService {
|
||||
|
@ -43,6 +44,7 @@ export class BsearchSecureService extends FtrService {
|
|||
auth,
|
||||
referer,
|
||||
kibanaVersion,
|
||||
internalOrigin,
|
||||
options,
|
||||
strategy,
|
||||
space,
|
||||
|
@ -74,6 +76,13 @@ export class BsearchSecureService extends FtrService {
|
|||
.set('kbn-version', kibanaVersion)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(options);
|
||||
} else if (internalOrigin) {
|
||||
result = await supertestWithoutAuth
|
||||
.post(url)
|
||||
.auth(auth.username, auth.password)
|
||||
.set('x-elastic-internal-origin', internalOrigin)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(options);
|
||||
} else {
|
||||
result = await supertestWithoutAuth
|
||||
.post(url)
|
||||
|
@ -96,6 +105,7 @@ export class BsearchSecureService extends FtrService {
|
|||
.post(`${spaceUrl}/internal/bsearch`)
|
||||
.auth(auth.username, auth.password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.set('x-elastic-internal-origin', 'Kibana')
|
||||
.send({
|
||||
batch: [
|
||||
{
|
||||
|
|
|
@ -66,6 +66,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
},
|
||||
referer: 'test',
|
||||
kibanaVersion,
|
||||
internalOrigin: 'Kibana',
|
||||
options: {
|
||||
featureIds: [AlertConsumers.LOGS],
|
||||
},
|
||||
|
@ -87,6 +88,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
},
|
||||
referer: 'test',
|
||||
kibanaVersion,
|
||||
internalOrigin: 'Kibana',
|
||||
options: {
|
||||
featureIds: [AlertConsumers.LOGS],
|
||||
pagination: {
|
||||
|
@ -142,6 +144,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
},
|
||||
referer: 'test',
|
||||
kibanaVersion,
|
||||
internalOrigin: 'Kibana',
|
||||
options: {
|
||||
featureIds: [AlertConsumers.SIEM],
|
||||
},
|
||||
|
@ -163,6 +166,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
},
|
||||
referer: 'test',
|
||||
kibanaVersion,
|
||||
internalOrigin: 'Kibana',
|
||||
options: {
|
||||
featureIds: [AlertConsumers.SIEM, AlertConsumers.LOGS],
|
||||
},
|
||||
|
@ -185,6 +189,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
},
|
||||
referer: 'test',
|
||||
kibanaVersion,
|
||||
internalOrigin: 'Kibana',
|
||||
options: {
|
||||
featureIds: [AlertConsumers.SIEM],
|
||||
runtimeMappings: {
|
||||
|
@ -223,6 +228,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
},
|
||||
referer: 'test',
|
||||
kibanaVersion,
|
||||
internalOrigin: 'Kibana',
|
||||
options: {
|
||||
featureIds: [AlertConsumers.APM],
|
||||
},
|
||||
|
@ -247,6 +253,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
},
|
||||
referer: 'test',
|
||||
kibanaVersion,
|
||||
internalOrigin: 'Kibana',
|
||||
options: {
|
||||
featureIds: [],
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue