Enables preventing access to internal APIs (#156935)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christiane (Tina) Heiligers 2023-05-10 04:25:15 -07:00 committed by GitHub
parent ed3941d45e
commit 7bbe92f085
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 438 additions and 18 deletions

1
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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

View file

@ -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',
},
});
});

View file

@ -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(
','
)}]`
);

View file

@ -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 {

View file

@ -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';

View file

@ -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;

View file

@ -67,6 +67,7 @@ Object {
"allowFromAnyIp": false,
"ipAllowlist": Array [],
},
"restrictInternalApis": false,
"rewriteBasePath": false,
"securityResponseHeaders": Object {
"crossOriginOpenerPolicy": "same-origin",

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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
};

View file

@ -50,6 +50,7 @@ const createConfigService = () => {
shutdownTimeout: moment.duration(30, 'seconds'),
keepaliveTimeout: 120_000,
socketTimeout: 120_000,
restrictInternalApis: false,
} as any);
}
if (path === 'externalUrl') {

View file

@ -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();

View file

@ -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 {

View file

@ -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",
},

View file

@ -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', () => {

View file

@ -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']
);
}

View file

@ -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;
}
}

View file

@ -46,6 +46,7 @@ describe('BasePathProxyServer', () => {
},
ssl: { enabled: false },
maxPayload: new ByteSizeValue(1024),
restrictInternalApis: false,
};
const serverOptions = getServerOptions(config);

View file

@ -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]'
);
});
});
});

View file

@ -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;
}
}

View file

@ -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,

View file

@ -38,6 +38,7 @@ const createConfig = (parts: Partial<IHttpConfig>): IHttpConfig => ({
enabled: false,
...parts.ssl,
},
restrictInternalApis: false,
});
describe('getServerOptions', () => {

View file

@ -18,6 +18,7 @@ export interface IHttpConfig {
cors: ICorsConfig;
ssl: ISslConfig;
shutdownTimeout: Duration;
restrictInternalApis: boolean;
}
export interface ICorsConfig {

View file

@ -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()');
});
});
});

View file

@ -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

View file

@ -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,

View file

@ -39,7 +39,6 @@ export function fetchStreaming({
if (!isCompressionDisabled) {
url = appendQueryParam(url, 'compress', 'true');
}
// Begin the request
xhr.open(method, url);
xhr.withCredentials = true;

View file

@ -11,6 +11,7 @@
"@kbn/config-schema",
"@kbn/std",
"@kbn/core-http-server",
"@kbn/core-http-common",
],
"exclude": [
"target/**/*",

View file

@ -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',
};
}

View file

@ -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' }))

View file

@ -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', () => {

View file

@ -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']
);
}

View file

@ -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: [
{

View file

@ -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: [],
},