Skip to content

Commit 44688d9

Browse files
mshustovlegregokobelb
authored
Add ability to specify CORS accepted origins (#84316)
* add settings * update abab package to version with types * add test case for CORS * add tests for cors config * fix jest tests * add deprecation message * tweak deprecation * make test runable on Cloud * add docs * fix type error * add test to throw on invalid URL * address comments * Update src/core/server/http/http_config.test.ts Co-authored-by: Larry Gregory <[email protected]> * Update docs/setup/settings.asciidoc Co-authored-by: Brandon Kobel <[email protected]> * allow kbn-xsrf headers to be set on CORS request Co-authored-by: Larry Gregory <[email protected]> Co-authored-by: Brandon Kobel <[email protected]>
1 parent 60b96d6 commit 44688d9

26 files changed

+450
-16
lines changed

docs/setup/settings.asciidoc

+9
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,15 @@ deprecation warning at startup. This setting cannot end in a slash (`/`).
450450
| [[server-compression]] `server.compression.enabled:`
451451
| Set to `false` to disable HTTP compression for all responses. *Default: `true`*
452452

453+
| `server.cors.enabled:`
454+
| experimental[] Set to `true` to allow cross-origin API calls. *Default:* `false`
455+
456+
| `server.cors.credentials:`
457+
| experimental[] Set to `true` to allow browser code to access response body whenever request performed with user credentials. *Default:* `false`
458+
459+
| `server.cors.origin:`
460+
| experimental[] List of origins permitted to access resources. You must specify explicit hostnames and not use `*` for `server.cors.origin` when `server.cors.credentials: true`. *Default:* "*"
461+
453462
| `server.compression.referrerWhitelist:`
454463
| Specifies an array of trusted hostnames, such as the {kib} host, or a reverse
455464
proxy sitting in front of it. This determines whether HTTP compression may be used for responses, based on the request `Referer` header.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@
573573
"@typescript-eslint/parser": "^4.8.1",
574574
"@welldone-software/why-did-you-render": "^5.0.0",
575575
"@yarnpkg/lockfile": "^1.1.0",
576-
"abab": "^1.0.4",
576+
"abab": "^2.0.4",
577577
"angular-aria": "^1.8.0",
578578
"angular-mocks": "^1.7.9",
579579
"angular-recursion": "^1.0.5",

src/core/server/config/deprecation/core_deprecations.test.ts

+26
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,32 @@ describe('core deprecations', () => {
9494
});
9595
});
9696

97+
describe('server.cors', () => {
98+
it('renames server.cors to server.cors.enabled', () => {
99+
const { migrated } = applyCoreDeprecations({
100+
server: { cors: true },
101+
});
102+
expect(migrated.server.cors).toEqual({ enabled: true });
103+
});
104+
it('logs a warning message about server.cors renaming', () => {
105+
const { messages } = applyCoreDeprecations({
106+
server: { cors: true },
107+
});
108+
expect(messages).toMatchInlineSnapshot(`
109+
Array [
110+
"\\"server.cors\\" is deprecated and has been replaced by \\"server.cors.enabled\\"",
111+
]
112+
`);
113+
});
114+
it('does not log deprecation message when server.cors.enabled set', () => {
115+
const { migrated, messages } = applyCoreDeprecations({
116+
server: { cors: { enabled: true } },
117+
});
118+
expect(migrated.server.cors).toEqual({ enabled: true });
119+
expect(messages.length).toBe(0);
120+
});
121+
});
122+
97123
describe('rewriteBasePath', () => {
98124
it('logs a warning is server.basePath is set and server.rewriteBasePath is not', () => {
99125
const { messages } = applyCoreDeprecations({

src/core/server/config/deprecation/core_deprecations.ts

+12
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, log)
5050
return settings;
5151
};
5252

53+
const rewriteCorsSettings: ConfigDeprecation = (settings, fromPath, log) => {
54+
const corsSettings = get(settings, 'server.cors');
55+
if (typeof get(settings, 'server.cors') === 'boolean') {
56+
log('"server.cors" is deprecated and has been replaced by "server.cors.enabled"');
57+
settings.server.cors = {
58+
enabled: corsSettings,
59+
};
60+
}
61+
return settings;
62+
};
63+
5364
const cspRulesDeprecation: ConfigDeprecation = (settings, fromPath, log) => {
5465
const NONCE_STRING = `{nonce}`;
5566
// Policies that should include the 'self' source
@@ -131,6 +142,7 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({ rename, unu
131142
rename('cpu.cgroup.path.override', 'ops.cGroupOverrides.cpuPath'),
132143
rename('cpuacct.cgroup.path.override', 'ops.cGroupOverrides.cpuAcctPath'),
133144
rename('server.xsrf.whitelist', 'server.xsrf.allowlist'),
145+
rewriteCorsSettings,
134146
configPathDeprecation,
135147
dataPathDeprecation,
136148
rewriteBasePathDeprecation,

src/core/server/http/__snapshots__/http_config.test.ts.snap

+5-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/server/http/cookie_session_storage.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ configService.atPath.mockImplementation((path) => {
6868
allowFromAnyIp: true,
6969
ipAllowlist: [],
7070
},
71+
cors: {
72+
enabled: false,
73+
},
7174
} as any);
7275
}
7376
if (path === 'externalUrl') {

src/core/server/http/http_config.test.ts

+53
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,59 @@ describe('with compression', () => {
330330
});
331331
});
332332

333+
describe('cors', () => {
334+
describe('origin', () => {
335+
it('list cannot be empty', () => {
336+
expect(() =>
337+
config.schema.validate({
338+
cors: {
339+
origin: [],
340+
},
341+
})
342+
).toThrowErrorMatchingInlineSnapshot(`
343+
"[cors.origin]: types that failed validation:
344+
- [cors.origin.0]: expected value to equal [*]
345+
- [cors.origin.1]: array size is [0], but cannot be smaller than [1]"
346+
`);
347+
});
348+
349+
it('list of valid URLs', () => {
350+
const origin = ['http://127.0.0.1:3000', 'https://elastic.co'];
351+
expect(
352+
config.schema.validate({
353+
cors: { origin },
354+
}).cors.origin
355+
).toStrictEqual(origin);
356+
357+
expect(() =>
358+
config.schema.validate({
359+
cors: {
360+
origin: ['*://elastic.co/*'],
361+
},
362+
})
363+
).toThrow();
364+
});
365+
366+
it('can be configured as "*" wildcard', () => {
367+
expect(config.schema.validate({ cors: { origin: '*' } }).cors.origin).toBe('*');
368+
});
369+
});
370+
describe('credentials', () => {
371+
it('cannot use wildcard origin if "credentials: true"', () => {
372+
expect(
373+
() => config.schema.validate({ cors: { credentials: true, origin: '*' } }).cors.origin
374+
).toThrowErrorMatchingInlineSnapshot(
375+
`"[cors]: Cannot specify wildcard origin \\"*\\" with \\"credentials: true\\". Please provide a list of allowed origins."`
376+
);
377+
expect(
378+
() => config.schema.validate({ cors: { credentials: true } }).cors.origin
379+
).toThrowErrorMatchingInlineSnapshot(
380+
`"[cors]: Cannot specify wildcard origin \\"*\\" with \\"credentials: true\\". Please provide a list of allowed origins."`
381+
);
382+
});
383+
});
384+
});
385+
333386
describe('HttpConfig', () => {
334387
it('converts customResponseHeaders to strings or arrays of strings', () => {
335388
const httpSchema = config.schema;

src/core/server/http/http_config.ts

+25-3
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { SslConfig, sslSchema } from './ssl_config';
2727

2828
const validBasePathRegex = /^\/.*[^\/]$/;
2929
const uuidRegexp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
30-
30+
const hostURISchema = schema.uri({ scheme: ['http', 'https'] });
3131
const match = (regex: RegExp, errorMsg: string) => (str: string) =>
3232
regex.test(str) ? undefined : errorMsg;
3333

@@ -45,7 +45,25 @@ export const config = {
4545
validate: match(validBasePathRegex, "must start with a slash, don't end with one"),
4646
})
4747
),
48-
cors: schema.boolean({ defaultValue: false }),
48+
cors: schema.object(
49+
{
50+
enabled: schema.boolean({ defaultValue: false }),
51+
credentials: schema.boolean({ defaultValue: false }),
52+
origin: schema.oneOf(
53+
[schema.literal('*'), schema.arrayOf(hostURISchema, { minSize: 1 })],
54+
{
55+
defaultValue: '*',
56+
}
57+
),
58+
},
59+
{
60+
validate(value) {
61+
if (value.credentials === true && value.origin === '*') {
62+
return 'Cannot specify wildcard origin "*" with "credentials: true". Please provide a list of allowed origins.';
63+
}
64+
},
65+
}
66+
),
4967
customResponseHeaders: schema.recordOf(schema.string(), schema.any(), {
5068
defaultValue: {},
5169
}),
@@ -148,7 +166,11 @@ export class HttpConfig {
148166
public keepaliveTimeout: number;
149167
public socketTimeout: number;
150168
public port: number;
151-
public cors: boolean | { origin: string[] };
169+
public cors: {
170+
enabled: boolean;
171+
credentials: boolean;
172+
origin: '*' | string[];
173+
};
152174
public customResponseHeaders: Record<string, string | string[]>;
153175
public maxPayload: ByteSizeValue;
154176
public basePath?: string;

src/core/server/http/http_server.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ beforeEach(() => {
7272
allowFromAnyIp: true,
7373
ipAllowlist: [],
7474
},
75+
cors: {
76+
enabled: false,
77+
},
7578
} as any;
7679

7780
configWithSSL = {

src/core/server/http/http_tools.test.ts

+23
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ describe('timeouts', () => {
102102
host: '127.0.0.1',
103103
maxPayload: new ByteSizeValue(1024),
104104
ssl: {},
105+
cors: {
106+
enabled: false,
107+
},
105108
compression: { enabled: true },
106109
requestId: {
107110
allowFromAnyIp: true,
@@ -187,6 +190,26 @@ describe('getServerOptions', () => {
187190
}
188191
`);
189192
});
193+
194+
it('properly configures CORS when cors enabled', () => {
195+
const httpConfig = new HttpConfig(
196+
config.schema.validate({
197+
cors: {
198+
enabled: true,
199+
credentials: false,
200+
origin: '*',
201+
},
202+
}),
203+
{} as any,
204+
{} as any
205+
);
206+
207+
expect(getServerOptions(httpConfig).routes?.cors).toEqual({
208+
credentials: false,
209+
origin: '*',
210+
headers: ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf'],
211+
});
212+
});
190213
});
191214

192215
describe('getRequestId', () => {

src/core/server/http/http_tools.ts

+20-5
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,34 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19-
20-
import { Lifecycle, Request, ResponseToolkit, Server, ServerOptions, Util } from '@hapi/hapi';
19+
import { Server } from '@hapi/hapi';
20+
import type {
21+
Lifecycle,
22+
Request,
23+
ResponseToolkit,
24+
RouteOptionsCors,
25+
ServerOptions,
26+
Util,
27+
} from '@hapi/hapi';
2128
import Hoek from '@hapi/hoek';
22-
import { ServerOptions as TLSOptions } from 'https';
23-
import { ValidationError } from 'joi';
29+
import type { ServerOptions as TLSOptions } from 'https';
30+
import type { ValidationError } from 'joi';
2431
import uuid from 'uuid';
2532
import { HttpConfig } from './http_config';
2633
import { validateObject } from './prototype_pollution';
2734

35+
const corsAllowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf'];
2836
/**
2937
* Converts Kibana `HttpConfig` into `ServerOptions` that are accepted by the Hapi server.
3038
*/
3139
export function getServerOptions(config: HttpConfig, { configureTLS = true } = {}) {
40+
const cors: RouteOptionsCors | false = config.cors.enabled
41+
? {
42+
credentials: config.cors.credentials,
43+
origin: config.cors.origin,
44+
headers: corsAllowedHeaders,
45+
}
46+
: false;
3247
// Note that all connection options configured here should be exactly the same
3348
// as in the legacy platform server (see `src/legacy/server/http/index`). Any change
3449
// SHOULD BE applied in both places. The only exception is TLS-specific options,
@@ -41,7 +56,7 @@ export function getServerOptions(config: HttpConfig, { configureTLS = true } = {
4156
privacy: 'private',
4257
otherwise: 'private, no-cache, no-store, must-revalidate',
4358
},
44-
cors: config.cors,
59+
cors,
4560
payload: {
4661
maxBytes: config.maxPayload.getValueInBytes(),
4762
},

src/core/server/http/https_redirect_server.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ beforeEach(() => {
4848
enabled: true,
4949
redirectHttpFromPort: chance.integer({ min: 20000, max: 30000 }),
5050
},
51+
cors: {
52+
enabled: false,
53+
},
5154
} as HttpConfig;
5255

5356
server = new HttpsRedirectServer(loggingSystemMock.create().get());

src/core/server/http/integration_tests/lifecycle_handlers.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ describe('core lifecycle handlers', () => {
5959
ssl: {
6060
enabled: false,
6161
},
62+
cors: {
63+
enabled: false,
64+
},
6265
compression: { enabled: true },
6366
name: kibanaName,
6467
customResponseHeaders: {

src/core/server/http/test_utils.ts

+3
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ configService.atPath.mockImplementation((path) => {
4141
ssl: {
4242
enabled: false,
4343
},
44+
cors: {
45+
enabled: false,
46+
},
4447
compression: { enabled: true },
4548
xsrf: {
4649
disableProtection: true,

x-pack/scripts/functional_tests.js

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const alwaysImportedTests = [
1515
require.resolve('../test/security_functional/oidc.config.ts'),
1616
require.resolve('../test/security_functional/saml.config.ts'),
1717
require.resolve('../test/functional_embedded/config.ts'),
18+
require.resolve('../test/functional_cors/config.ts'),
1819
require.resolve('../test/functional_enterprise_search/without_host_configured.config.ts'),
1920
];
2021
const onlyNotInCoverageTests = [

0 commit comments

Comments
 (0)