Skip to content

Commit

Permalink
Add support for reading request ID from X-Opaque-Id header
Browse files Browse the repository at this point in the history
  • Loading branch information
joshdover committed Jul 14, 2020
1 parent 6c20c08 commit cffd076
Show file tree
Hide file tree
Showing 27 changed files with 332 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ registerOnPostAuth: (handler: OnPostAuthHandler) => void;

## Remarks

The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPreRouting, which are called in sequence (from the first registered to the last). See [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md)<!-- -->.
The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). See [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md)<!-- -->.

Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ registerOnPreAuth: (handler: OnPreAuthHandler) => void;

## Remarks

Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). See [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md)<!-- -->.
Can register any number of registerOnPreAuth, which are called in sequence (from the first registered to the last). See [OnPreAuthHandler](./kibana-plugin-core-server.onpreauthhandler.md)<!-- -->.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) &gt; [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.

Original file line number Diff line number Diff line change
Expand Up @@ -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> | |
| [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. |
| [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. |
| [params](./kibana-plugin-core-server.kibanarequest.params.md) | | <code>Params</code> | |
| [query](./kibana-plugin-core-server.kibanarequest.query.md) | | <code>Query</code> | |
Expand Down
2 changes: 1 addition & 1 deletion docs/development/core/server/kibana-plugin-core-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. |
| [OnPreResponseExtensions](./kibana-plugin-core-server.onpreresponseextensions.md) | Additional data to extend a response. |
| [OnPreResponseInfo](./kibana-plugin-core-server.onpreresponseinfo.md) | Response status code. |
| [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. |
| [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreResponse interceptor for incoming request. |
| [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. |
| [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) | Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. |
| [OpsOsMetrics](./kibana-plugin-core-server.opsosmetrics.md) | OS related metrics |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## OnPreResponseToolkit interface

A tool set defining an outcome of OnPreRouting interceptor for incoming request.
A tool set defining an outcome of OnPreResponse interceptor for incoming request.

<b>Signature:</b>

Expand Down
6 changes: 6 additions & 0 deletions docs/setup/settings.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,12 @@ identifies this {kib} instance. *Default: `"your-hostname"`*
| {kib} is served by a back end server. This
setting specifies the port to use. *Default: `5601`*

| `server.requestOpaqueId.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.requestOpaqueId.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.requestOpaqueId.allowFromAnyIp` must also be set to `false.`

| `server.rewriteBasePath:`
| Specifies whether {kib} should
rewrite requests that are prefixed with `server.basePath` or require that they
Expand Down
16 changes: 15 additions & 1 deletion src/core/server/elasticsearch/legacy/cluster_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
clusterClient.asScoped(
httpServerMock.createRawRequest({ headers: { zero: '0', one: '1', two: '2', three: '3' } })
Expand Down Expand Up @@ -482,7 +496,7 @@ describe('#asScoped', () => {
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{},
expect.objectContaining({ 'x-opaque-id': expect.any(String) }),
auditor
);
});
Expand Down
8 changes: 6 additions & 2 deletions src/core/server/elasticsearch/legacy/cluster_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,10 @@ export class LegacyClusterClient implements ILegacyClusterClient {
return new LegacyScopedClusterClient(
this.callAsInternalUser,
this.callAsCurrentUser,
filterHeaders(this.getHeaders(request), this.config.requestHeadersWhitelist),
filterHeaders(this.getHeaders(request), [
'x-opaque-id',
...this.config.requestHeadersWhitelist,
]),
this.getScopedAuditor(request)
);
}
Expand Down Expand Up @@ -255,7 +258,8 @@ export class LegacyClusterClient implements ILegacyClusterClient {
}
const authHeaders = this.getAuthHeaders(request);
const headers = ensureRawRequest(request).headers;
const requestIdHeaders = request instanceof KibanaRequest ? { 'x-opaque-id': request.id } : {};

return { ...headers, ...authHeaders };
return { ...headers, ...authHeaders, ...requestIdHeaders };
}
}
4 changes: 4 additions & 0 deletions src/core/server/http/__snapshots__/http_config.test.ts.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/core/server/http/cookie_session_storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ configService.atPath.mockReturnValue(
whitelist: [],
},
customResponseHeaders: {},
requestOpaqueId: {
allowFromAnyIp: true,
ipAllowlist: [],
},
} as any)
);

Expand Down
56 changes: 56 additions & 0 deletions src/core/server/http/http_config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,62 @@ test('throws if invalid hostname', () => {
expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot();
});

describe('requestOpaqueId', () => {
test('accepts valid ip addresses', () => {
const {
requestOpaqueId: { ipAllowlist },
} = config.schema.validate({
requestOpaqueId: {
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({
requestOpaqueId: {
allowFromAnyIp: false,
ipAllowlist: ['1200:0000:AB00:1234:O000:2552:7777:1313', '[2001:db8:0:1]:80'],
},
});
}).toThrowErrorMatchingInlineSnapshot(
`"[requestOpaqueId.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({
requestOpaqueId: {
allowFromAnyIp: true,
ipAllowlist: ['0.0.0.0', '123.123.123.123', '1200:0000:AB00:1234:0000:2552:7777:1313'],
},
});
}).toThrowErrorMatchingInlineSnapshot(
`"[requestOpaqueId]: allowFromAnyIp must be set to 'false' if any values are specified in ipAllowlist"`
);

expect(() => {
config.schema.validate({
requestOpaqueId: {
ipAllowlist: ['0.0.0.0', '123.123.123.123', '1200:0000:AB00:1234:0000:2552:7777:1313'],
},
});
}).toThrowErrorMatchingInlineSnapshot(
`"[requestOpaqueId]: allowFromAnyIp must be set to 'false' if any values are specified in ipAllowlist"`
);
});
});

test('can specify max payload as string', () => {
const obj = {
maxPayload: '2mb',
Expand Down
15 changes: 15 additions & 0 deletions src/core/server/http/http_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,19 @@ export const config = {
{ defaultValue: [] }
),
}),
requestOpaqueId: schema.object(
{
allowFromAnyIp: schema.boolean({ defaultValue: true }),
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) => {
Expand Down Expand Up @@ -144,6 +157,7 @@ export class HttpConfig {
public compression: { enabled: boolean; referrerWhitelist?: string[] };
public csp: ICspConfig;
public xsrf: { disableProtection: boolean; whitelist: string[] };
public requestOpaqueId: { allowFromAnyIp: boolean; ipAllowlist: string[] };

/**
* @internal
Expand Down Expand Up @@ -172,6 +186,7 @@ export class HttpConfig {
this.compression = rawHttpConfig.compression;
this.csp = new CspConfig(rawCspConfig);
this.xsrf = rawHttpConfig.xsrf;
this.requestOpaqueId = rawHttpConfig.requestOpaqueId;
}
}

Expand Down
13 changes: 9 additions & 4 deletions src/core/server/http/http_server.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import {
RouteMethod,
KibanaResponseFactory,
RouteValidationSpec,
KibanaRouteState,
KibanaRouteOptions,
KibanaRequestState,
} from './router';
import { OnPreResponseToolkit } from './lifecycle/on_pre_response';
import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
Expand All @@ -45,7 +46,8 @@ interface RequestFixtureOptions<P = any, Q = any, B = any> {
method?: RouteMethod;
socket?: Socket;
routeTags?: string[];
kibanaRouteState?: KibanaRouteState;
kibanaRouteOptions?: KibanaRouteOptions;
kibanaRequestState?: KibanaRequestState;
routeAuthRequired?: false;
validation?: {
params?: RouteValidationSpec<P>;
Expand All @@ -65,13 +67,15 @@ function createKibanaRequestMock<P = any, Q = any, B = any>({
routeTags,
routeAuthRequired,
validation = {},
kibanaRouteState = { xsrfRequired: true },
kibanaRouteOptions = { xsrfRequired: true },
kibanaRequestState = { requestId: '123' },
auth = { isAuthenticated: true },
}: RequestFixtureOptions<P, Q, B> = {}) {
const queryString = stringify(query, { sort: false });

return KibanaRequest.from<P, Q, B>(
createRawRequestMock({
app: kibanaRequestState,
auth,
headers,
params,
Expand All @@ -86,7 +90,7 @@ function createKibanaRequestMock<P = any, Q = any, B = any>({
search: queryString ? `?${queryString}` : queryString,
},
route: {
settings: { tags: routeTags, auth: routeAuthRequired, app: kibanaRouteState },
settings: { tags: routeTags, auth: routeAuthRequired, app: kibanaRouteOptions },
},
raw: {
req: { socket },
Expand Down Expand Up @@ -128,6 +132,7 @@ function createRawRequestMock(customization: DeepPartial<Request> = {}) {
raw: {
req: {
url: '/',
socket: {},
},
},
},
Expand Down
6 changes: 5 additions & 1 deletion src/core/server/http/http_server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ beforeEach(() => {
port: 10002,
ssl: { enabled: false },
compression: { enabled: true },
} as HttpConfig;
requestOpaqueId: {
allowFromAnyIp: true,
ipAllowlist: [],
},
} as any;

configWithSSL = {
...config,
Expand Down
24 changes: 20 additions & 4 deletions src/core/server/http/http_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,19 @@ import url from 'url';

import { Logger, LoggerFactory } from '../logging';
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 { adoptToHapiOnPreAuth, OnPreAuthHandler } from './lifecycle/on_pre_auth';
import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth';
import { adoptToHapiOnRequest, OnPreRoutingHandler } from './lifecycle/on_pre_routing';
import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response';
import { IRouter, RouteConfigOptions, KibanaRouteState, isSafeMethod } from './router';
import {
IRouter,
RouteConfigOptions,
KibanaRouteOptions,
KibanaRequestState,
isSafeMethod,
} from './router';
import {
SessionStorageCookieOptions,
createCookieSessionStorageFactory,
Expand Down Expand Up @@ -115,6 +121,7 @@ export class HttpServer {
const basePathService = new BasePath(config.basePath);
this.setupBasePathRewrite(config, basePathService);
this.setupConditionalCompression(config);
this.setupRequestStateAssignment(config);

return {
registerRouter: this.registerRouter.bind(this),
Expand Down Expand Up @@ -164,7 +171,7 @@ export class HttpServer {
const { authRequired, tags, body = {} } = route.options;
const { accepts: allow, maxBytes, output, parse } = body;

const kibanaRouteState: KibanaRouteState = {
const kibanaRouteOptions: KibanaRouteOptions = {
xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method),
};

Expand All @@ -174,7 +181,7 @@ export class HttpServer {
path: route.path,
options: {
auth: this.getAuthOption(authRequired),
app: kibanaRouteState,
app: kibanaRouteOptions,
tags: tags ? Array.from(tags) : undefined,
// TODO: This 'validate' section can be removed once the legacy platform is completely removed.
// We are telling Hapi that NP routes can accept any payload, so that it can bypass the default
Expand Down Expand Up @@ -270,6 +277,15 @@ export class HttpServer {
}
}

private setupRequestStateAssignment(config: HttpConfig) {
this.server!.ext('onRequest', (request, responseToolkit) => {
request.app = {
requestId: getRequestId(request, config.requestOpaqueId),
} as KibanaRequestState;
return responseToolkit.continue;
});
}

private registerOnPreAuth(fn: OnPreAuthHandler) {
if (this.server === undefined) {
throw new Error('Server is not created yet');
Expand Down
Loading

0 comments on commit cffd076

Please sign in to comment.