Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
622bf2df9d
commit
bd71bdbea7
36 changed files with 578 additions and 41 deletions
|
@ -0,0 +1,18 @@
|
||||||
|
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||||
|
|
||||||
|
[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) > [id](./kibana-plugin-core-server.kibanarequest.id.md)
|
||||||
|
|
||||||
|
## KibanaRequest.id property
|
||||||
|
|
||||||
|
A identifier to identify this request.
|
||||||
|
|
||||||
|
<b>Signature:</b>
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
readonly id: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remarks
|
||||||
|
|
||||||
|
Depending on the user's configuration, this value may be sourced from the incoming request's `X-Opaque-Id` header which is not guaranteed to be unique per request.
|
||||||
|
|
|
@ -26,6 +26,7 @@ export declare class KibanaRequest<Params = unknown, Query = unknown, Body = unk
|
||||||
| [body](./kibana-plugin-core-server.kibanarequest.body.md) | | <code>Body</code> | |
|
| [body](./kibana-plugin-core-server.kibanarequest.body.md) | | <code>Body</code> | |
|
||||||
| [events](./kibana-plugin-core-server.kibanarequest.events.md) | | <code>KibanaRequestEvents</code> | Request events [KibanaRequestEvents](./kibana-plugin-core-server.kibanarequestevents.md) |
|
| [events](./kibana-plugin-core-server.kibanarequest.events.md) | | <code>KibanaRequestEvents</code> | Request events [KibanaRequestEvents](./kibana-plugin-core-server.kibanarequestevents.md) |
|
||||||
| [headers](./kibana-plugin-core-server.kibanarequest.headers.md) | | <code>Headers</code> | Readonly copy of incoming request headers. |
|
| [headers](./kibana-plugin-core-server.kibanarequest.headers.md) | | <code>Headers</code> | Readonly copy of incoming request headers. |
|
||||||
|
| [id](./kibana-plugin-core-server.kibanarequest.id.md) | | <code>string</code> | A identifier to identify this request. |
|
||||||
| [isSystemRequest](./kibana-plugin-core-server.kibanarequest.issystemrequest.md) | | <code>boolean</code> | Whether or not the request is a "system request" rather than an application-level request. Can be set on the client using the <code>HttpFetchOptions#asSystemRequest</code> option. |
|
| [isSystemRequest](./kibana-plugin-core-server.kibanarequest.issystemrequest.md) | | <code>boolean</code> | Whether or not the request is a "system request" rather than an application-level request. Can be set on the client using the <code>HttpFetchOptions#asSystemRequest</code> option. |
|
||||||
| [params](./kibana-plugin-core-server.kibanarequest.params.md) | | <code>Params</code> | |
|
| [params](./kibana-plugin-core-server.kibanarequest.params.md) | | <code>Params</code> | |
|
||||||
| [query](./kibana-plugin-core-server.kibanarequest.query.md) | | <code>Query</code> | |
|
| [query](./kibana-plugin-core-server.kibanarequest.query.md) | | <code>Query</code> | |
|
||||||
|
|
|
@ -476,6 +476,12 @@ identifies this {kib} instance. *Default: `"your-hostname"`*
|
||||||
| {kib} is served by a back end server. This
|
| {kib} is served by a back end server. This
|
||||||
setting specifies the port to use. *Default: `5601`*
|
setting specifies the port to use. *Default: `5601`*
|
||||||
|
|
||||||
|
| `server.requestId.allowFromAnyIp:`
|
||||||
|
| Sets whether or not the X-Opaque-Id header should be trusted from any IP address for identifying requests in logs and forwarded to Elasticsearch.
|
||||||
|
|
||||||
|
| `server.requestId.ipAllowlist:`
|
||||||
|
| A list of IPv4 and IPv6 address which the `X-Opaque-Id` header should be trusted from. Normally this would be set to the IP addresses of the load balancers or reverse-proxy that end users use to access Kibana. If any are set, `server.requestId.allowFromAnyIp` must also be set to `false.`
|
||||||
|
|
||||||
| `server.rewriteBasePath:`
|
| `server.rewriteBasePath:`
|
||||||
| Specifies whether {kib} should
|
| Specifies whether {kib} should
|
||||||
rewrite requests that are prefixed with `server.basePath` or require that they
|
rewrite requests that are prefixed with `server.basePath` or require that they
|
||||||
|
|
|
@ -34,6 +34,8 @@ import {
|
||||||
ConditionalTypeValue,
|
ConditionalTypeValue,
|
||||||
DurationOptions,
|
DurationOptions,
|
||||||
DurationType,
|
DurationType,
|
||||||
|
IpOptions,
|
||||||
|
IpType,
|
||||||
LiteralType,
|
LiteralType,
|
||||||
MapOfOptions,
|
MapOfOptions,
|
||||||
MapOfType,
|
MapOfType,
|
||||||
|
@ -107,6 +109,10 @@ function never(): Type<never> {
|
||||||
return new NeverType();
|
return new NeverType();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ip(options?: IpOptions): Type<string> {
|
||||||
|
return new IpType(options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an optional type
|
* Create an optional type
|
||||||
*/
|
*/
|
||||||
|
@ -207,6 +213,7 @@ export const schema = {
|
||||||
conditional,
|
conditional,
|
||||||
contextRef,
|
contextRef,
|
||||||
duration,
|
duration,
|
||||||
|
ip,
|
||||||
literal,
|
literal,
|
||||||
mapOf,
|
mapOf,
|
||||||
maybe,
|
maybe,
|
||||||
|
|
|
@ -36,3 +36,4 @@ export { StringOptions, StringType } from './string_type';
|
||||||
export { UnionType } from './union_type';
|
export { UnionType } from './union_type';
|
||||||
export { URIOptions, URIType } from './uri_type';
|
export { URIOptions, URIType } from './uri_type';
|
||||||
export { NeverType } from './never_type';
|
export { NeverType } from './never_type';
|
||||||
|
export { IpType, IpOptions } from './ip_type';
|
||||||
|
|
71
packages/kbn-config-schema/src/types/ip_type.test.ts
Normal file
71
packages/kbn-config-schema/src/types/ip_type.test.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
/*
|
||||||
|
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||||
|
* license agreements. See the NOTICE file distributed with
|
||||||
|
* this work for additional information regarding copyright
|
||||||
|
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||||
|
* the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
* not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { schema } from '..';
|
||||||
|
|
||||||
|
const { ip } = schema;
|
||||||
|
|
||||||
|
describe('ip validation', () => {
|
||||||
|
test('accepts ipv4', () => {
|
||||||
|
expect(ip().validate('1.1.1.1')).toEqual('1.1.1.1');
|
||||||
|
});
|
||||||
|
test('accepts ipv6', () => {
|
||||||
|
expect(ip().validate('1200:0000:AB00:1234:0000:2552:7777:1313')).toEqual(
|
||||||
|
'1200:0000:AB00:1234:0000:2552:7777:1313'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('rejects ipv6 when not specified', () => {
|
||||||
|
expect(() =>
|
||||||
|
ip({ versions: ['ipv4'] }).validate('1200:0000:AB00:1234:0000:2552:7777:1313')
|
||||||
|
).toThrowErrorMatchingInlineSnapshot(`"value must be a valid ipv4 address"`);
|
||||||
|
});
|
||||||
|
test('rejects ipv4 when not specified', () => {
|
||||||
|
expect(() => ip({ versions: ['ipv6'] }).validate('1.1.1.1')).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"value must be a valid ipv6 address"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('rejects invalid ip addresses', () => {
|
||||||
|
expect(() => ip().validate('1.1.1.1/24')).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"value must be a valid ipv4 or ipv6 address"`
|
||||||
|
);
|
||||||
|
expect(() => ip().validate('99999.1.1.1')).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"value must be a valid ipv4 or ipv6 address"`
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
ip().validate('ZZZZ:0000:AB00:1234:0000:2552:7777:1313')
|
||||||
|
).toThrowErrorMatchingInlineSnapshot(`"value must be a valid ipv4 or ipv6 address"`);
|
||||||
|
expect(() => ip().validate('blah 1234')).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"value must be a valid ipv4 or ipv6 address"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns error when not string', () => {
|
||||||
|
expect(() => ip().validate(123)).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"expected value of type [string] but got [number]"`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => ip().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"expected value of type [string] but got [Array]"`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => ip().validate(/abc/)).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"expected value of type [string] but got [RegExp]"`
|
||||||
|
);
|
||||||
|
});
|
46
packages/kbn-config-schema/src/types/ip_type.ts
Normal file
46
packages/kbn-config-schema/src/types/ip_type.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||||
|
* license agreements. See the NOTICE file distributed with
|
||||||
|
* this work for additional information regarding copyright
|
||||||
|
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||||
|
* the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
* not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import typeDetect from 'type-detect';
|
||||||
|
import { internals } from '../internals';
|
||||||
|
import { Type, TypeOptions } from './type';
|
||||||
|
|
||||||
|
export type IpVersion = 'ipv4' | 'ipv6';
|
||||||
|
export type IpOptions = TypeOptions<string> & {
|
||||||
|
/**
|
||||||
|
* IP versions to accept, defaults to ['ipv4', 'ipv6'].
|
||||||
|
*/
|
||||||
|
versions: IpVersion[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class IpType extends Type<string> {
|
||||||
|
constructor(options: IpOptions = { versions: ['ipv4', 'ipv6'] }) {
|
||||||
|
const schema = internals.string().ip({ version: options.versions, cidr: 'forbidden' });
|
||||||
|
super(schema, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handleError(type: string, { value, version }: Record<string, any>) {
|
||||||
|
switch (type) {
|
||||||
|
case 'string.base':
|
||||||
|
return `expected value of type [string] but got [${typeDetect(value)}]`;
|
||||||
|
case 'string.ipVersion':
|
||||||
|
return `value must be a valid ${version.join(' or ')} address`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -96,7 +96,7 @@ describe('ClusterClient', () => {
|
||||||
expect(scopedClusterClient.asCurrentUser).toBe(scopedClient.child.mock.results[0].value);
|
expect(scopedClusterClient.asCurrentUser).toBe(scopedClient.child.mock.results[0].value);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a distinct scoped cluster client on each call', () => {
|
it('returns a distinct scoped cluster client on each call', () => {
|
||||||
const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders);
|
const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders);
|
||||||
const request = httpServerMock.createKibanaRequest();
|
const request = httpServerMock.createKibanaRequest();
|
||||||
|
|
||||||
|
@ -127,7 +127,7 @@ describe('ClusterClient', () => {
|
||||||
|
|
||||||
expect(scopedClient.child).toHaveBeenCalledTimes(1);
|
expect(scopedClient.child).toHaveBeenCalledTimes(1);
|
||||||
expect(scopedClient.child).toHaveBeenCalledWith({
|
expect(scopedClient.child).toHaveBeenCalledWith({
|
||||||
headers: { foo: 'bar' },
|
headers: { foo: 'bar', 'x-opaque-id': expect.any(String) },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -147,7 +147,7 @@ describe('ClusterClient', () => {
|
||||||
|
|
||||||
expect(scopedClient.child).toHaveBeenCalledTimes(1);
|
expect(scopedClient.child).toHaveBeenCalledTimes(1);
|
||||||
expect(scopedClient.child).toHaveBeenCalledWith({
|
expect(scopedClient.child).toHaveBeenCalledWith({
|
||||||
headers: { authorization: 'auth' },
|
headers: { authorization: 'auth', 'x-opaque-id': expect.any(String) },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -171,7 +171,7 @@ describe('ClusterClient', () => {
|
||||||
|
|
||||||
expect(scopedClient.child).toHaveBeenCalledTimes(1);
|
expect(scopedClient.child).toHaveBeenCalledTimes(1);
|
||||||
expect(scopedClient.child).toHaveBeenCalledWith({
|
expect(scopedClient.child).toHaveBeenCalledWith({
|
||||||
headers: { authorization: 'auth' },
|
headers: { authorization: 'auth', 'x-opaque-id': expect.any(String) },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -195,6 +195,26 @@ describe('ClusterClient', () => {
|
||||||
headers: {
|
headers: {
|
||||||
foo: 'bar',
|
foo: 'bar',
|
||||||
hello: 'dolly',
|
hello: 'dolly',
|
||||||
|
'x-opaque-id': expect.any(String),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the x-opaque-id header based on the request id', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
getAuthHeaders.mockReturnValue({});
|
||||||
|
|
||||||
|
const clusterClient = new ClusterClient(config, logger, getAuthHeaders);
|
||||||
|
const request = httpServerMock.createKibanaRequest({
|
||||||
|
kibanaRequestState: { requestId: 'my-fake-id' },
|
||||||
|
});
|
||||||
|
|
||||||
|
clusterClient.asScoped(request);
|
||||||
|
|
||||||
|
expect(scopedClient.child).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scopedClient.child).toHaveBeenCalledWith({
|
||||||
|
headers: {
|
||||||
|
'x-opaque-id': 'my-fake-id',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -221,6 +241,7 @@ describe('ClusterClient', () => {
|
||||||
headers: {
|
headers: {
|
||||||
foo: 'auth',
|
foo: 'auth',
|
||||||
hello: 'dolly',
|
hello: 'dolly',
|
||||||
|
'x-opaque-id': expect.any(String),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -247,6 +268,31 @@ describe('ClusterClient', () => {
|
||||||
headers: {
|
headers: {
|
||||||
foo: 'request',
|
foo: 'request',
|
||||||
hello: 'dolly',
|
hello: 'dolly',
|
||||||
|
'x-opaque-id': expect.any(String),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respect the precedence of x-opaque-id header over config headers', () => {
|
||||||
|
const config = createConfig({
|
||||||
|
customHeaders: {
|
||||||
|
'x-opaque-id': 'from config',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getAuthHeaders.mockReturnValue({});
|
||||||
|
|
||||||
|
const clusterClient = new ClusterClient(config, logger, getAuthHeaders);
|
||||||
|
const request = httpServerMock.createKibanaRequest({
|
||||||
|
headers: { foo: 'request' },
|
||||||
|
kibanaRequestState: { requestId: 'from request' },
|
||||||
|
});
|
||||||
|
|
||||||
|
clusterClient.asScoped(request);
|
||||||
|
|
||||||
|
expect(scopedClient.child).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scopedClient.child).toHaveBeenCalledWith({
|
||||||
|
headers: {
|
||||||
|
'x-opaque-id': 'from request',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
|
|
||||||
import { Client } from '@elastic/elasticsearch';
|
import { Client } from '@elastic/elasticsearch';
|
||||||
import { Logger } from '../../logging';
|
import { Logger } from '../../logging';
|
||||||
import { GetAuthHeaders, isRealRequest, Headers } from '../../http';
|
import { GetAuthHeaders, Headers, isKibanaRequest, isRealRequest } from '../../http';
|
||||||
import { ensureRawRequest, filterHeaders } from '../../http/router';
|
import { ensureRawRequest, filterHeaders } from '../../http/router';
|
||||||
import { ScopeableRequest } from '../types';
|
import { ScopeableRequest } from '../types';
|
||||||
import { ElasticsearchClient } from './types';
|
import { ElasticsearchClient } from './types';
|
||||||
|
@ -95,12 +95,14 @@ export class ClusterClient implements ICustomClusterClient {
|
||||||
private getScopedHeaders(request: ScopeableRequest): Headers {
|
private getScopedHeaders(request: ScopeableRequest): Headers {
|
||||||
let scopedHeaders: Headers;
|
let scopedHeaders: Headers;
|
||||||
if (isRealRequest(request)) {
|
if (isRealRequest(request)) {
|
||||||
const authHeaders = this.getAuthHeaders(request);
|
|
||||||
const requestHeaders = ensureRawRequest(request).headers;
|
const requestHeaders = ensureRawRequest(request).headers;
|
||||||
scopedHeaders = filterHeaders(
|
const requestIdHeaders = isKibanaRequest(request) ? { 'x-opaque-id': request.id } : {};
|
||||||
{ ...requestHeaders, ...authHeaders },
|
const authHeaders = this.getAuthHeaders(request);
|
||||||
this.config.requestHeadersWhitelist
|
|
||||||
);
|
scopedHeaders = filterHeaders({ ...requestHeaders, ...requestIdHeaders, ...authHeaders }, [
|
||||||
|
'x-opaque-id',
|
||||||
|
...this.config.requestHeadersWhitelist,
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
scopedHeaders = filterHeaders(request?.headers ?? {}, this.config.requestHeadersWhitelist);
|
scopedHeaders = filterHeaders(request?.headers ?? {}, this.config.requestHeadersWhitelist);
|
||||||
}
|
}
|
||||||
|
|
|
@ -349,6 +349,20 @@ describe('#asScoped', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('passes x-opaque-id header with request id', () => {
|
||||||
|
clusterClient.asScoped(
|
||||||
|
httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'alpha' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
|
||||||
|
expect(MockScopedClusterClient).toHaveBeenCalledWith(
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
|
{ 'x-opaque-id': 'alpha' },
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('both scoped and internal API caller fail if cluster client is closed', async () => {
|
test('both scoped and internal API caller fail if cluster client is closed', async () => {
|
||||||
clusterClient.asScoped(
|
clusterClient.asScoped(
|
||||||
httpServerMock.createRawRequest({ headers: { zero: '0', one: '1', two: '2', three: '3' } })
|
httpServerMock.createRawRequest({ headers: { zero: '0', one: '1', two: '2', three: '3' } })
|
||||||
|
@ -482,7 +496,7 @@ describe('#asScoped', () => {
|
||||||
expect(MockScopedClusterClient).toHaveBeenCalledWith(
|
expect(MockScopedClusterClient).toHaveBeenCalledWith(
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
{},
|
expect.objectContaining({ 'x-opaque-id': expect.any(String) }),
|
||||||
auditor
|
auditor
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -20,7 +20,7 @@ import { Client } from 'elasticsearch';
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
|
|
||||||
import { LegacyElasticsearchErrorHelpers } from './errors';
|
import { LegacyElasticsearchErrorHelpers } from './errors';
|
||||||
import { GetAuthHeaders, isRealRequest, KibanaRequest } from '../../http';
|
import { GetAuthHeaders, KibanaRequest, isKibanaRequest, isRealRequest } from '../../http';
|
||||||
import { AuditorFactory } from '../../audit_trail';
|
import { AuditorFactory } from '../../audit_trail';
|
||||||
import { filterHeaders, ensureRawRequest } from '../../http/router';
|
import { filterHeaders, ensureRawRequest } from '../../http/router';
|
||||||
import { Logger } from '../../logging';
|
import { Logger } from '../../logging';
|
||||||
|
@ -207,7 +207,10 @@ export class LegacyClusterClient implements ILegacyClusterClient {
|
||||||
return new LegacyScopedClusterClient(
|
return new LegacyScopedClusterClient(
|
||||||
this.callAsInternalUser,
|
this.callAsInternalUser,
|
||||||
this.callAsCurrentUser,
|
this.callAsCurrentUser,
|
||||||
filterHeaders(this.getHeaders(request), this.config.requestHeadersWhitelist),
|
filterHeaders(this.getHeaders(request), [
|
||||||
|
'x-opaque-id',
|
||||||
|
...this.config.requestHeadersWhitelist,
|
||||||
|
]),
|
||||||
this.getScopedAuditor(request)
|
this.getScopedAuditor(request)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -215,8 +218,7 @@ export class LegacyClusterClient implements ILegacyClusterClient {
|
||||||
private getScopedAuditor(request?: ScopeableRequest) {
|
private getScopedAuditor(request?: ScopeableRequest) {
|
||||||
// TODO: support alternative credential owners from outside of Request context in #39430
|
// TODO: support alternative credential owners from outside of Request context in #39430
|
||||||
if (request && isRealRequest(request)) {
|
if (request && isRealRequest(request)) {
|
||||||
const kibanaRequest =
|
const kibanaRequest = isKibanaRequest(request) ? request : KibanaRequest.from(request);
|
||||||
request instanceof KibanaRequest ? request : KibanaRequest.from(request);
|
|
||||||
const auditorFactory = this.getAuditorFactory();
|
const auditorFactory = this.getAuditorFactory();
|
||||||
return auditorFactory.asScoped(kibanaRequest);
|
return auditorFactory.asScoped(kibanaRequest);
|
||||||
}
|
}
|
||||||
|
@ -256,8 +258,9 @@ export class LegacyClusterClient implements ILegacyClusterClient {
|
||||||
return request && request.headers ? request.headers : {};
|
return request && request.headers ? request.headers : {};
|
||||||
}
|
}
|
||||||
const authHeaders = this.getAuthHeaders(request);
|
const authHeaders = this.getAuthHeaders(request);
|
||||||
const headers = ensureRawRequest(request).headers;
|
const requestHeaders = ensureRawRequest(request).headers;
|
||||||
|
const requestIdHeaders = isKibanaRequest(request) ? { 'x-opaque-id': request.id } : {};
|
||||||
|
|
||||||
return { ...headers, ...authHeaders };
|
return { ...requestHeaders, ...requestIdHeaders, ...authHeaders };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,6 +39,10 @@ Object {
|
||||||
},
|
},
|
||||||
"name": "kibana-hostname",
|
"name": "kibana-hostname",
|
||||||
"port": 5601,
|
"port": 5601,
|
||||||
|
"requestId": Object {
|
||||||
|
"allowFromAnyIp": false,
|
||||||
|
"ipAllowlist": Array [],
|
||||||
|
},
|
||||||
"rewriteBasePath": false,
|
"rewriteBasePath": false,
|
||||||
"socketTimeout": 120000,
|
"socketTimeout": 120000,
|
||||||
"ssl": Object {
|
"ssl": Object {
|
||||||
|
|
|
@ -63,6 +63,10 @@ configService.atPath.mockReturnValue(
|
||||||
whitelist: [],
|
whitelist: [],
|
||||||
},
|
},
|
||||||
customResponseHeaders: {},
|
customResponseHeaders: {},
|
||||||
|
requestId: {
|
||||||
|
allowFromAnyIp: true,
|
||||||
|
ipAllowlist: [],
|
||||||
|
},
|
||||||
} as any)
|
} as any)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -54,6 +54,63 @@ test('throws if invalid hostname', () => {
|
||||||
expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot();
|
expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('requestId', () => {
|
||||||
|
test('accepts valid ip addresses', () => {
|
||||||
|
const {
|
||||||
|
requestId: { ipAllowlist },
|
||||||
|
} = config.schema.validate({
|
||||||
|
requestId: {
|
||||||
|
allowFromAnyIp: false,
|
||||||
|
ipAllowlist: ['0.0.0.0', '123.123.123.123', '1200:0000:AB00:1234:0000:2552:7777:1313'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(ipAllowlist).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
"0.0.0.0",
|
||||||
|
"123.123.123.123",
|
||||||
|
"1200:0000:AB00:1234:0000:2552:7777:1313",
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid ip addresses', () => {
|
||||||
|
expect(() => {
|
||||||
|
config.schema.validate({
|
||||||
|
requestId: {
|
||||||
|
allowFromAnyIp: false,
|
||||||
|
ipAllowlist: ['1200:0000:AB00:1234:O000:2552:7777:1313', '[2001:db8:0:1]:80'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"[requestId.ipAllowlist.0]: value must be a valid ipv4 or ipv6 address"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects if allowFromAnyIp is `true` and `ipAllowlist` is non-empty', () => {
|
||||||
|
expect(() => {
|
||||||
|
config.schema.validate({
|
||||||
|
requestId: {
|
||||||
|
allowFromAnyIp: true,
|
||||||
|
ipAllowlist: ['0.0.0.0', '123.123.123.123', '1200:0000:AB00:1234:0000:2552:7777:1313'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"[requestId]: allowFromAnyIp must be set to 'false' if any values are specified in ipAllowlist"`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
config.schema.validate({
|
||||||
|
requestId: {
|
||||||
|
allowFromAnyIp: true,
|
||||||
|
ipAllowlist: ['0.0.0.0', '123.123.123.123', '1200:0000:AB00:1234:0000:2552:7777:1313'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"[requestId]: allowFromAnyIp must be set to 'false' if any values are specified in ipAllowlist"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('can specify max payload as string', () => {
|
test('can specify max payload as string', () => {
|
||||||
const obj = {
|
const obj = {
|
||||||
maxPayload: '2mb',
|
maxPayload: '2mb',
|
||||||
|
|
|
@ -87,6 +87,19 @@ export const config = {
|
||||||
{ defaultValue: [] }
|
{ defaultValue: [] }
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
requestId: schema.object(
|
||||||
|
{
|
||||||
|
allowFromAnyIp: schema.boolean({ defaultValue: false }),
|
||||||
|
ipAllowlist: schema.arrayOf(schema.ip(), { defaultValue: [] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validate(value) {
|
||||||
|
if (value.allowFromAnyIp === true && value.ipAllowlist?.length > 0) {
|
||||||
|
return `allowFromAnyIp must be set to 'false' if any values are specified in ipAllowlist`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
validate: (rawConfig) => {
|
validate: (rawConfig) => {
|
||||||
|
@ -130,6 +143,7 @@ export class HttpConfig {
|
||||||
public compression: { enabled: boolean; referrerWhitelist?: string[] };
|
public compression: { enabled: boolean; referrerWhitelist?: string[] };
|
||||||
public csp: ICspConfig;
|
public csp: ICspConfig;
|
||||||
public xsrf: { disableProtection: boolean; whitelist: string[] };
|
public xsrf: { disableProtection: boolean; whitelist: string[] };
|
||||||
|
public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
|
@ -158,6 +172,7 @@ export class HttpConfig {
|
||||||
this.compression = rawHttpConfig.compression;
|
this.compression = rawHttpConfig.compression;
|
||||||
this.csp = new CspConfig(rawCspConfig);
|
this.csp = new CspConfig(rawCspConfig);
|
||||||
this.xsrf = rawHttpConfig.xsrf;
|
this.xsrf = rawHttpConfig.xsrf;
|
||||||
|
this.requestId = rawHttpConfig.requestId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,8 @@ import {
|
||||||
RouteMethod,
|
RouteMethod,
|
||||||
KibanaResponseFactory,
|
KibanaResponseFactory,
|
||||||
RouteValidationSpec,
|
RouteValidationSpec,
|
||||||
KibanaRouteState,
|
KibanaRouteOptions,
|
||||||
|
KibanaRequestState,
|
||||||
} from './router';
|
} from './router';
|
||||||
import { OnPreResponseToolkit } from './lifecycle/on_pre_response';
|
import { OnPreResponseToolkit } from './lifecycle/on_pre_response';
|
||||||
import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
|
import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
|
||||||
|
@ -45,7 +46,8 @@ interface RequestFixtureOptions<P = any, Q = any, B = any> {
|
||||||
method?: RouteMethod;
|
method?: RouteMethod;
|
||||||
socket?: Socket;
|
socket?: Socket;
|
||||||
routeTags?: string[];
|
routeTags?: string[];
|
||||||
kibanaRouteState?: KibanaRouteState;
|
kibanaRouteOptions?: KibanaRouteOptions;
|
||||||
|
kibanaRequestState?: KibanaRequestState;
|
||||||
routeAuthRequired?: false;
|
routeAuthRequired?: false;
|
||||||
validation?: {
|
validation?: {
|
||||||
params?: RouteValidationSpec<P>;
|
params?: RouteValidationSpec<P>;
|
||||||
|
@ -65,13 +67,15 @@ function createKibanaRequestMock<P = any, Q = any, B = any>({
|
||||||
routeTags,
|
routeTags,
|
||||||
routeAuthRequired,
|
routeAuthRequired,
|
||||||
validation = {},
|
validation = {},
|
||||||
kibanaRouteState = { xsrfRequired: true },
|
kibanaRouteOptions = { xsrfRequired: true },
|
||||||
|
kibanaRequestState = { requestId: '123' },
|
||||||
auth = { isAuthenticated: true },
|
auth = { isAuthenticated: true },
|
||||||
}: RequestFixtureOptions<P, Q, B> = {}) {
|
}: RequestFixtureOptions<P, Q, B> = {}) {
|
||||||
const queryString = stringify(query, { sort: false });
|
const queryString = stringify(query, { sort: false });
|
||||||
|
|
||||||
return KibanaRequest.from<P, Q, B>(
|
return KibanaRequest.from<P, Q, B>(
|
||||||
createRawRequestMock({
|
createRawRequestMock({
|
||||||
|
app: kibanaRequestState,
|
||||||
auth,
|
auth,
|
||||||
headers,
|
headers,
|
||||||
params,
|
params,
|
||||||
|
@ -86,7 +90,7 @@ function createKibanaRequestMock<P = any, Q = any, B = any>({
|
||||||
search: queryString ? `?${queryString}` : queryString,
|
search: queryString ? `?${queryString}` : queryString,
|
||||||
},
|
},
|
||||||
route: {
|
route: {
|
||||||
settings: { tags: routeTags, auth: routeAuthRequired, app: kibanaRouteState },
|
settings: { tags: routeTags, auth: routeAuthRequired, app: kibanaRouteOptions },
|
||||||
},
|
},
|
||||||
raw: {
|
raw: {
|
||||||
req: {
|
req: {
|
||||||
|
@ -133,6 +137,7 @@ function createRawRequestMock(customization: DeepPartial<Request> = {}) {
|
||||||
raw: {
|
raw: {
|
||||||
req: {
|
req: {
|
||||||
url: '/',
|
url: '/',
|
||||||
|
socket: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -68,7 +68,11 @@ beforeEach(() => {
|
||||||
port: 10002,
|
port: 10002,
|
||||||
ssl: { enabled: false },
|
ssl: { enabled: false },
|
||||||
compression: { enabled: true },
|
compression: { enabled: true },
|
||||||
} as HttpConfig;
|
requestId: {
|
||||||
|
allowFromAnyIp: true,
|
||||||
|
ipAllowlist: [],
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
configWithSSL = {
|
configWithSSL = {
|
||||||
...config,
|
...config,
|
||||||
|
|
|
@ -22,13 +22,19 @@ import url from 'url';
|
||||||
|
|
||||||
import { Logger, LoggerFactory } from '../logging';
|
import { Logger, LoggerFactory } from '../logging';
|
||||||
import { HttpConfig } from './http_config';
|
import { HttpConfig } from './http_config';
|
||||||
import { createServer, getListenerOptions, getServerOptions } from './http_tools';
|
import { createServer, getListenerOptions, getServerOptions, getRequestId } from './http_tools';
|
||||||
import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth';
|
import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth';
|
||||||
import { adoptToHapiOnPreAuth, OnPreAuthHandler } from './lifecycle/on_pre_auth';
|
import { adoptToHapiOnPreAuth, OnPreAuthHandler } from './lifecycle/on_pre_auth';
|
||||||
import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth';
|
import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth';
|
||||||
import { adoptToHapiOnRequest, OnPreRoutingHandler } from './lifecycle/on_pre_routing';
|
import { adoptToHapiOnRequest, OnPreRoutingHandler } from './lifecycle/on_pre_routing';
|
||||||
import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response';
|
import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response';
|
||||||
import { IRouter, RouteConfigOptions, KibanaRouteState, isSafeMethod } from './router';
|
import {
|
||||||
|
IRouter,
|
||||||
|
RouteConfigOptions,
|
||||||
|
KibanaRouteOptions,
|
||||||
|
KibanaRequestState,
|
||||||
|
isSafeMethod,
|
||||||
|
} from './router';
|
||||||
import {
|
import {
|
||||||
SessionStorageCookieOptions,
|
SessionStorageCookieOptions,
|
||||||
createCookieSessionStorageFactory,
|
createCookieSessionStorageFactory,
|
||||||
|
@ -115,6 +121,7 @@ export class HttpServer {
|
||||||
const basePathService = new BasePath(config.basePath);
|
const basePathService = new BasePath(config.basePath);
|
||||||
this.setupBasePathRewrite(config, basePathService);
|
this.setupBasePathRewrite(config, basePathService);
|
||||||
this.setupConditionalCompression(config);
|
this.setupConditionalCompression(config);
|
||||||
|
this.setupRequestStateAssignment(config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
registerRouter: this.registerRouter.bind(this),
|
registerRouter: this.registerRouter.bind(this),
|
||||||
|
@ -164,7 +171,7 @@ export class HttpServer {
|
||||||
const { authRequired, tags, body = {}, timeout } = route.options;
|
const { authRequired, tags, body = {}, timeout } = route.options;
|
||||||
const { accepts: allow, maxBytes, output, parse } = body;
|
const { accepts: allow, maxBytes, output, parse } = body;
|
||||||
|
|
||||||
const kibanaRouteState: KibanaRouteState = {
|
const kibanaRouteOptions: KibanaRouteOptions = {
|
||||||
xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method),
|
xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -180,7 +187,7 @@ export class HttpServer {
|
||||||
path: route.path,
|
path: route.path,
|
||||||
options: {
|
options: {
|
||||||
auth: this.getAuthOption(authRequired),
|
auth: this.getAuthOption(authRequired),
|
||||||
app: kibanaRouteState,
|
app: kibanaRouteOptions,
|
||||||
ext: {
|
ext: {
|
||||||
onPreAuth: {
|
onPreAuth: {
|
||||||
method: (request, h) => {
|
method: (request, h) => {
|
||||||
|
@ -303,6 +310,16 @@ export class HttpServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setupRequestStateAssignment(config: HttpConfig) {
|
||||||
|
this.server!.ext('onRequest', (request, responseToolkit) => {
|
||||||
|
request.app = {
|
||||||
|
...(request.app ?? {}),
|
||||||
|
requestId: getRequestId(request, config.requestId),
|
||||||
|
} as KibanaRequestState;
|
||||||
|
return responseToolkit.continue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private registerOnPreAuth(fn: OnPreAuthHandler) {
|
private registerOnPreAuth(fn: OnPreAuthHandler) {
|
||||||
if (this.server === undefined) {
|
if (this.server === undefined) {
|
||||||
throw new Error('Server is not created yet');
|
throw new Error('Server is not created yet');
|
||||||
|
|
|
@ -26,11 +26,20 @@ jest.mock('fs', () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('uuid', () => ({
|
||||||
|
v4: jest.fn().mockReturnValue('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'),
|
||||||
|
}));
|
||||||
|
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import { Request, ResponseToolkit } from 'hapi';
|
import { Request, ResponseToolkit } from 'hapi';
|
||||||
import Joi from 'joi';
|
import Joi from 'joi';
|
||||||
|
|
||||||
import { defaultValidationErrorHandler, HapiValidationError, getServerOptions } from './http_tools';
|
import {
|
||||||
|
defaultValidationErrorHandler,
|
||||||
|
HapiValidationError,
|
||||||
|
getServerOptions,
|
||||||
|
getRequestId,
|
||||||
|
} from './http_tools';
|
||||||
import { HttpServer } from './http_server';
|
import { HttpServer } from './http_server';
|
||||||
import { HttpConfig, config } from './http_config';
|
import { HttpConfig, config } from './http_config';
|
||||||
import { Router } from './router';
|
import { Router } from './router';
|
||||||
|
@ -94,7 +103,11 @@ describe('timeouts', () => {
|
||||||
maxPayload: new ByteSizeValue(1024),
|
maxPayload: new ByteSizeValue(1024),
|
||||||
ssl: {},
|
ssl: {},
|
||||||
compression: { enabled: true },
|
compression: { enabled: true },
|
||||||
} as HttpConfig);
|
requestId: {
|
||||||
|
allowFromAnyIp: true,
|
||||||
|
ipAllowlist: [],
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
registerRouter(router);
|
registerRouter(router);
|
||||||
|
|
||||||
await server.start();
|
await server.start();
|
||||||
|
@ -173,3 +186,75 @@ describe('getServerOptions', () => {
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getRequestId', () => {
|
||||||
|
describe('when allowFromAnyIp is true', () => {
|
||||||
|
it('generates a UUID if no x-opaque-id header is present', () => {
|
||||||
|
const request = {
|
||||||
|
headers: {},
|
||||||
|
raw: { req: { socket: { remoteAddress: '1.1.1.1' } } },
|
||||||
|
} as any;
|
||||||
|
expect(getRequestId(request, { allowFromAnyIp: true, ipAllowlist: [] })).toEqual(
|
||||||
|
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses x-opaque-id header value if present', () => {
|
||||||
|
const request = {
|
||||||
|
headers: {
|
||||||
|
'x-opaque-id': 'id from header',
|
||||||
|
raw: { req: { socket: { remoteAddress: '1.1.1.1' } } },
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
expect(getRequestId(request, { allowFromAnyIp: true, ipAllowlist: [] })).toEqual(
|
||||||
|
'id from header'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when allowFromAnyIp is false', () => {
|
||||||
|
describe('and ipAllowlist is empty', () => {
|
||||||
|
it('generates a UUID even if x-opaque-id header is present', () => {
|
||||||
|
const request = {
|
||||||
|
headers: { 'x-opaque-id': 'id from header' },
|
||||||
|
raw: { req: { socket: { remoteAddress: '1.1.1.1' } } },
|
||||||
|
} as any;
|
||||||
|
expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: [] })).toEqual(
|
||||||
|
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and ipAllowlist is not empty', () => {
|
||||||
|
it('uses x-opaque-id header if request comes from trusted IP address', () => {
|
||||||
|
const request = {
|
||||||
|
headers: { 'x-opaque-id': 'id from header' },
|
||||||
|
raw: { req: { socket: { remoteAddress: '1.1.1.1' } } },
|
||||||
|
} as any;
|
||||||
|
expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual(
|
||||||
|
'id from header'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a UUID if request comes from untrusted IP address', () => {
|
||||||
|
const request = {
|
||||||
|
headers: { 'x-opaque-id': 'id from header' },
|
||||||
|
raw: { req: { socket: { remoteAddress: '5.5.5.5' } } },
|
||||||
|
} as any;
|
||||||
|
expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual(
|
||||||
|
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates UUID if request comes from trusted IP address but no x-opaque-id header is present', () => {
|
||||||
|
const request = {
|
||||||
|
headers: {},
|
||||||
|
raw: { req: { socket: { remoteAddress: '1.1.1.1' } } },
|
||||||
|
} as any;
|
||||||
|
expect(getRequestId(request, { allowFromAnyIp: false, ipAllowlist: ['1.1.1.1'] })).toEqual(
|
||||||
|
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { Lifecycle, Request, ResponseToolkit, Server, ServerOptions, Util } from
|
||||||
import Hoek from 'hoek';
|
import Hoek from 'hoek';
|
||||||
import { ServerOptions as TLSOptions } from 'https';
|
import { ServerOptions as TLSOptions } from 'https';
|
||||||
import { ValidationError } from 'joi';
|
import { ValidationError } from 'joi';
|
||||||
|
import uuid from 'uuid';
|
||||||
import { HttpConfig } from './http_config';
|
import { HttpConfig } from './http_config';
|
||||||
import { validateObject } from './prototype_pollution';
|
import { validateObject } from './prototype_pollution';
|
||||||
|
|
||||||
|
@ -169,3 +170,12 @@ export function defaultValidationErrorHandler(
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRequestId(request: Request, options: HttpConfig['requestId']): string {
|
||||||
|
return options.allowFromAnyIp ||
|
||||||
|
// socket may be undefined in integration tests that connect via the http listener directly
|
||||||
|
(request.raw.req.socket?.remoteAddress &&
|
||||||
|
options.ipAllowlist.includes(request.raw.req.socket.remoteAddress))
|
||||||
|
? request.headers['x-opaque-id'] ?? uuid.v4()
|
||||||
|
: uuid.v4();
|
||||||
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ export { AuthStatus, GetAuthState, IsAuthenticated } from './auth_state_storage'
|
||||||
export {
|
export {
|
||||||
CustomHttpResponseOptions,
|
CustomHttpResponseOptions,
|
||||||
IKibanaSocket,
|
IKibanaSocket,
|
||||||
|
isKibanaRequest,
|
||||||
isRealRequest,
|
isRealRequest,
|
||||||
Headers,
|
Headers,
|
||||||
HttpResponseOptions,
|
HttpResponseOptions,
|
||||||
|
|
|
@ -406,7 +406,10 @@ describe('http service', () => {
|
||||||
// client contains authHeaders for BWC with legacy platform.
|
// client contains authHeaders for BWC with legacy platform.
|
||||||
const [client] = MockLegacyScopedClusterClient.mock.calls;
|
const [client] = MockLegacyScopedClusterClient.mock.calls;
|
||||||
const [, , clientHeaders] = client;
|
const [, , clientHeaders] = client;
|
||||||
expect(clientHeaders).toEqual(authHeaders);
|
expect(clientHeaders).toEqual({
|
||||||
|
...authHeaders,
|
||||||
|
'x-opaque-id': expect.any(String),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes request authorization header to Elasticsearch if registerAuth was not set', async () => {
|
it('passes request authorization header to Elasticsearch if registerAuth was not set', async () => {
|
||||||
|
@ -430,7 +433,10 @@ describe('http service', () => {
|
||||||
|
|
||||||
const [client] = MockLegacyScopedClusterClient.mock.calls;
|
const [client] = MockLegacyScopedClusterClient.mock.calls;
|
||||||
const [, , clientHeaders] = client;
|
const [, , clientHeaders] = client;
|
||||||
expect(clientHeaders).toEqual({ authorization: authorizationHeader });
|
expect(clientHeaders).toEqual({
|
||||||
|
authorization: authorizationHeader,
|
||||||
|
'x-opaque-id': expect.any(String),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forwards 401 errors returned from elasticsearch', async () => {
|
it('forwards 401 errors returned from elasticsearch', async () => {
|
||||||
|
|
|
@ -62,6 +62,10 @@ describe('core lifecycle handlers', () => {
|
||||||
'some-header': 'some-value',
|
'some-header': 'some-value',
|
||||||
},
|
},
|
||||||
xsrf: { disableProtection: false, whitelist: [whitelistedTestPath] },
|
xsrf: { disableProtection: false, whitelist: [whitelistedTestPath] },
|
||||||
|
requestId: {
|
||||||
|
allowFromAnyIp: true,
|
||||||
|
ipAllowlist: [],
|
||||||
|
},
|
||||||
} as any)
|
} as any)
|
||||||
);
|
);
|
||||||
server = createHttpServer({ configService });
|
server = createHttpServer({ configService });
|
||||||
|
|
|
@ -288,4 +288,24 @@ describe('KibanaRequest', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('request id', () => {
|
||||||
|
it('accepts x-opaque-id header case-insensitively', async () => {
|
||||||
|
const { server: innerServer, createRouter } = await server.setup(setupDeps);
|
||||||
|
const router = createRouter('/');
|
||||||
|
router.get({ path: '/', validate: false }, async (context, req, res) => {
|
||||||
|
return res.ok({ body: { requestId: req.id } });
|
||||||
|
});
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
const st = supertest(innerServer.listener);
|
||||||
|
|
||||||
|
const resp1 = await st.get('/').set({ 'x-opaque-id': 'alpha' }).expect(200);
|
||||||
|
expect(resp1.body).toEqual({ requestId: 'alpha' });
|
||||||
|
const resp2 = await st.get('/').set({ 'X-Opaque-Id': 'beta' }).expect(200);
|
||||||
|
expect(resp2.body).toEqual({ requestId: 'beta' });
|
||||||
|
const resp3 = await st.get('/').set({ 'X-OPAQUE-ID': 'gamma' }).expect(200);
|
||||||
|
expect(resp3.body).toEqual({ requestId: 'gamma' });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -24,7 +24,7 @@ import {
|
||||||
} from './lifecycle_handlers';
|
} from './lifecycle_handlers';
|
||||||
import { httpServerMock } from './http_server.mocks';
|
import { httpServerMock } from './http_server.mocks';
|
||||||
import { HttpConfig } from './http_config';
|
import { HttpConfig } from './http_config';
|
||||||
import { KibanaRequest, RouteMethod, KibanaRouteState } from './router';
|
import { KibanaRequest, RouteMethod, KibanaRouteOptions } from './router';
|
||||||
|
|
||||||
const createConfig = (partial: Partial<HttpConfig>): HttpConfig => partial as HttpConfig;
|
const createConfig = (partial: Partial<HttpConfig>): HttpConfig => partial as HttpConfig;
|
||||||
|
|
||||||
|
@ -32,14 +32,19 @@ const forgeRequest = ({
|
||||||
headers = {},
|
headers = {},
|
||||||
path = '/',
|
path = '/',
|
||||||
method = 'get',
|
method = 'get',
|
||||||
kibanaRouteState,
|
kibanaRouteOptions,
|
||||||
}: Partial<{
|
}: Partial<{
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
path: string;
|
path: string;
|
||||||
method: RouteMethod;
|
method: RouteMethod;
|
||||||
kibanaRouteState: KibanaRouteState;
|
kibanaRouteOptions: KibanaRouteOptions;
|
||||||
}>): KibanaRequest => {
|
}>): KibanaRequest => {
|
||||||
return httpServerMock.createKibanaRequest({ headers, path, method, kibanaRouteState });
|
return httpServerMock.createKibanaRequest({
|
||||||
|
headers,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
kibanaRouteOptions,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('xsrf post-auth handler', () => {
|
describe('xsrf post-auth handler', () => {
|
||||||
|
@ -154,7 +159,7 @@ describe('xsrf post-auth handler', () => {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
headers: {},
|
headers: {},
|
||||||
path: '/some-path',
|
path: '/some-path',
|
||||||
kibanaRouteState: {
|
kibanaRouteOptions: {
|
||||||
xsrfRequired: false,
|
xsrfRequired: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -24,7 +24,9 @@ export {
|
||||||
KibanaRequestEvents,
|
KibanaRequestEvents,
|
||||||
KibanaRequestRoute,
|
KibanaRequestRoute,
|
||||||
KibanaRequestRouteOptions,
|
KibanaRequestRouteOptions,
|
||||||
KibanaRouteState,
|
KibanaRouteOptions,
|
||||||
|
KibanaRequestState,
|
||||||
|
isKibanaRequest,
|
||||||
isRealRequest,
|
isRealRequest,
|
||||||
LegacyRequest,
|
LegacyRequest,
|
||||||
ensureRawRequest,
|
ensureRawRequest,
|
||||||
|
|
|
@ -16,12 +16,45 @@
|
||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
jest.mock('uuid', () => ({
|
||||||
|
v4: jest.fn().mockReturnValue('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'),
|
||||||
|
}));
|
||||||
|
|
||||||
import { RouteOptions } from 'hapi';
|
import { RouteOptions } from 'hapi';
|
||||||
import { KibanaRequest } from './request';
|
import { KibanaRequest } from './request';
|
||||||
import { httpServerMock } from '../http_server.mocks';
|
import { httpServerMock } from '../http_server.mocks';
|
||||||
import { schema } from '@kbn/config-schema';
|
import { schema } from '@kbn/config-schema';
|
||||||
|
|
||||||
describe('KibanaRequest', () => {
|
describe('KibanaRequest', () => {
|
||||||
|
describe('id property', () => {
|
||||||
|
it('uses the request.app.requestId property if present', () => {
|
||||||
|
const request = httpServerMock.createRawRequest({
|
||||||
|
app: { requestId: 'fakeId' },
|
||||||
|
});
|
||||||
|
const kibanaRequest = KibanaRequest.from(request);
|
||||||
|
expect(kibanaRequest.id).toEqual('fakeId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a new UUID if request.app property is not present', () => {
|
||||||
|
// Undefined app property
|
||||||
|
const request = httpServerMock.createRawRequest({
|
||||||
|
app: undefined,
|
||||||
|
});
|
||||||
|
const kibanaRequest = KibanaRequest.from(request);
|
||||||
|
expect(kibanaRequest.id).toEqual('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a new UUID if request.app.requestId property is not present', () => {
|
||||||
|
// Undefined app.requestId property
|
||||||
|
const request = httpServerMock.createRawRequest({
|
||||||
|
app: {},
|
||||||
|
});
|
||||||
|
const kibanaRequest = KibanaRequest.from(request);
|
||||||
|
expect(kibanaRequest.id).toEqual('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('get all headers', () => {
|
describe('get all headers', () => {
|
||||||
it('returns all headers', () => {
|
it('returns all headers', () => {
|
||||||
const request = httpServerMock.createRawRequest({
|
const request = httpServerMock.createRawRequest({
|
||||||
|
|
|
@ -18,7 +18,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Url } from 'url';
|
import { Url } from 'url';
|
||||||
import { Request, ApplicationState } from 'hapi';
|
import uuid from 'uuid';
|
||||||
|
import { Request, RouteOptionsApp, ApplicationState } from 'hapi';
|
||||||
import { Observable, fromEvent, merge } from 'rxjs';
|
import { Observable, fromEvent, merge } from 'rxjs';
|
||||||
import { shareReplay, first, takeUntil } from 'rxjs/operators';
|
import { shareReplay, first, takeUntil } from 'rxjs/operators';
|
||||||
import { RecursiveReadonly } from '@kbn/utility-types';
|
import { RecursiveReadonly } from '@kbn/utility-types';
|
||||||
|
@ -34,9 +35,17 @@ const requestSymbol = Symbol('request');
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export interface KibanaRouteState extends ApplicationState {
|
export interface KibanaRouteOptions extends RouteOptionsApp {
|
||||||
xsrfRequired: boolean;
|
xsrfRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface KibanaRequestState extends ApplicationState {
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route options: If 'GET' or 'OPTIONS' method, body options won't be returned.
|
* Route options: If 'GET' or 'OPTIONS' method, body options won't be returned.
|
||||||
* @public
|
* @public
|
||||||
|
@ -134,6 +143,15 @@ export class KibanaRequest<
|
||||||
|
|
||||||
return { query, params, body };
|
return { query, params, body };
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* A identifier to identify this request.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* Depending on the user's configuration, this value may be sourced from the
|
||||||
|
* incoming request's `X-Opaque-Id` header which is not guaranteed to be unique
|
||||||
|
* per request.
|
||||||
|
*/
|
||||||
|
public readonly id: string;
|
||||||
/** a WHATWG URL standard object. */
|
/** a WHATWG URL standard object. */
|
||||||
public readonly url: Url;
|
public readonly url: Url;
|
||||||
/** matched route details */
|
/** matched route details */
|
||||||
|
@ -171,6 +189,11 @@ export class KibanaRequest<
|
||||||
// until that time we have to expose all the headers
|
// until that time we have to expose all the headers
|
||||||
private readonly withoutSecretHeaders: boolean
|
private readonly withoutSecretHeaders: boolean
|
||||||
) {
|
) {
|
||||||
|
// The `requestId` property will not be populated for requests that are 'faked' by internal systems that leverage
|
||||||
|
// KibanaRequest in conjunction with scoped Elaticcsearch and SavedObjectsClient in order to pass credentials.
|
||||||
|
// In these cases, the id defaults to a newly generated UUID.
|
||||||
|
this.id = (request.app as KibanaRequestState | undefined)?.requestId ?? uuid.v4();
|
||||||
|
|
||||||
this.url = request.url;
|
this.url = request.url;
|
||||||
this.headers = deepFreeze({ ...request.headers });
|
this.headers = deepFreeze({ ...request.headers });
|
||||||
this.isSystemRequest =
|
this.isSystemRequest =
|
||||||
|
@ -220,7 +243,7 @@ export class KibanaRequest<
|
||||||
const options = ({
|
const options = ({
|
||||||
authRequired: this.getAuthRequired(request),
|
authRequired: this.getAuthRequired(request),
|
||||||
// some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8
|
// some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8
|
||||||
xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true,
|
xsrfRequired: (request.route.settings.app as KibanaRouteOptions)?.xsrfRequired ?? true,
|
||||||
tags: request.route.settings.tags || [],
|
tags: request.route.settings.tags || [],
|
||||||
timeout: {
|
timeout: {
|
||||||
payload: payloadTimeout,
|
payload: payloadTimeout,
|
||||||
|
@ -276,7 +299,11 @@ export class KibanaRequest<
|
||||||
export const ensureRawRequest = (request: KibanaRequest | LegacyRequest) =>
|
export const ensureRawRequest = (request: KibanaRequest | LegacyRequest) =>
|
||||||
isKibanaRequest(request) ? request[requestSymbol] : request;
|
isKibanaRequest(request) ? request[requestSymbol] : request;
|
||||||
|
|
||||||
function isKibanaRequest(request: unknown): request is KibanaRequest {
|
/**
|
||||||
|
* Checks if an incoming request is a {@link KibanaRequest}
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function isKibanaRequest(request: unknown): request is KibanaRequest {
|
||||||
return request instanceof KibanaRequest;
|
return request instanceof KibanaRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -46,6 +46,10 @@ configService.atPath.mockReturnValue(
|
||||||
whitelist: [],
|
whitelist: [],
|
||||||
},
|
},
|
||||||
customResponseHeaders: {},
|
customResponseHeaders: {},
|
||||||
|
requestId: {
|
||||||
|
allowFromAnyIp: true,
|
||||||
|
ipAllowlist: [],
|
||||||
|
},
|
||||||
} as any)
|
} as any)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1059,6 +1059,7 @@ export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown, Me
|
||||||
// @internal
|
// @internal
|
||||||
static from<P, Q, B>(req: Request, routeSchemas?: RouteValidator<P, Q, B> | RouteValidatorFullConfig<P, Q, B>, withoutSecretHeaders?: boolean): KibanaRequest<P, Q, B, any>;
|
static from<P, Q, B>(req: Request, routeSchemas?: RouteValidator<P, Q, B> | RouteValidatorFullConfig<P, Q, B>, withoutSecretHeaders?: boolean): KibanaRequest<P, Q, B, any>;
|
||||||
readonly headers: Headers;
|
readonly headers: Headers;
|
||||||
|
readonly id: string;
|
||||||
readonly isSystemRequest: boolean;
|
readonly isSystemRequest: boolean;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
readonly params: Params;
|
readonly params: Params;
|
||||||
|
|
|
@ -14,6 +14,7 @@ const buildRequest = (path = '/app/kibana') => {
|
||||||
const get = sinon.stub();
|
const get = sinon.stub();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
app: {},
|
||||||
path,
|
path,
|
||||||
route: { settings: {} },
|
route: { settings: {} },
|
||||||
headers: {},
|
headers: {},
|
||||||
|
|
|
@ -46,6 +46,7 @@ const alertsClientFactoryParams: jest.Mocked<AlertsClientFactoryOpts> = {
|
||||||
eventLog: eventLogMock.createStart(),
|
eventLog: eventLogMock.createStart(),
|
||||||
};
|
};
|
||||||
const fakeRequest = ({
|
const fakeRequest = ({
|
||||||
|
app: {},
|
||||||
headers: {},
|
headers: {},
|
||||||
getBasePath: () => '',
|
getBasePath: () => '',
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|
|
@ -23,7 +23,11 @@ describe('AuditTrailClient', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
event$ = new Subject();
|
event$ = new Subject();
|
||||||
client = new AuditTrailClient(httpServerMock.createKibanaRequest(), event$, deps);
|
client = new AuditTrailClient(
|
||||||
|
httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'request id alpha' } }),
|
||||||
|
event$,
|
||||||
|
deps
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -40,6 +44,15 @@ describe('AuditTrailClient', () => {
|
||||||
client.add({ message: 'message', type: 'type' });
|
client.add({ message: 'message', type: 'type' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('populates requestId', (done) => {
|
||||||
|
client.withAuditScope('scope_name');
|
||||||
|
event$.subscribe((event) => {
|
||||||
|
expect(event.requestId).toBe('request id alpha');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
client.add({ message: 'message', type: 'type' });
|
||||||
|
});
|
||||||
|
|
||||||
it('throws an exception if tries to re-write a scope', () => {
|
it('throws an exception if tries to re-write a scope', () => {
|
||||||
client.withAuditScope('scope_name');
|
client.withAuditScope('scope_name');
|
||||||
expect(() => client.withAuditScope('another_scope_name')).toThrowErrorMatchingInlineSnapshot(
|
expect(() => client.withAuditScope('another_scope_name')).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
|
|
@ -41,6 +41,7 @@ export class AuditTrailClient implements Auditor {
|
||||||
user: user?.username,
|
user: user?.username,
|
||||||
space: spaceId,
|
space: spaceId,
|
||||||
scope: this.scope,
|
scope: this.scope,
|
||||||
|
requestId: this.request.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,4 +13,5 @@ export interface AuditEvent {
|
||||||
scope?: string;
|
scope?: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
space?: string;
|
space?: string;
|
||||||
|
requestId?: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,6 +50,7 @@ const getRequest = async (headers: string | undefined, crypto: Crypto, logger: L
|
||||||
path: '/',
|
path: '/',
|
||||||
route: { settings: {} },
|
route: { settings: {} },
|
||||||
url: { href: '/' },
|
url: { href: '/' },
|
||||||
|
app: {},
|
||||||
raw: { req: { url: '/' } },
|
raw: { req: { url: '/' } },
|
||||||
} as Hapi.Request);
|
} as Hapi.Request);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue