Skip to content

Commit

Permalink
Add ability to specify CORS accepted origins (elastic#84316)
Browse files Browse the repository at this point in the history
* 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]>
# Conflicts:
#	src/core/server/config/deprecation/core_deprecations.ts
#	x-pack/scripts/functional_tests.js
  • Loading branch information
mshustov committed Dec 10, 2020
1 parent 0968676 commit 91c63cf
Show file tree
Hide file tree
Showing 26 changed files with 452 additions and 16 deletions.
9 changes: 9 additions & 0 deletions docs/setup/settings.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,15 @@ deprecation warning at startup. This setting cannot end in a slash (`/`).
| [[server-compression]] `server.compression.enabled:`
| Set to `false` to disable HTTP compression for all responses. *Default: `true`*

| `server.cors.enabled:`
| experimental[] Set to `true` to allow cross-origin API calls. *Default:* `false`

| `server.cors.credentials:`
| experimental[] Set to `true` to allow browser code to access response body whenever request performed with user credentials. *Default:* `false`

| `server.cors.origin:`
| 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:* "*"

| `server.compression.referrerWhitelist:`
| Specifies an array of trusted hostnames, such as the {kib} host, or a reverse
proxy sitting in front of it. This determines whether HTTP compression may be used for responses, based on the request `Referer` header.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@
"@typescript-eslint/parser": "^4.8.1",
"@welldone-software/why-did-you-render": "^5.0.0",
"@yarnpkg/lockfile": "^1.1.0",
"abab": "^1.0.4",
"abab": "^2.0.4",
"angular-aria": "^1.8.0",
"angular-mocks": "^1.7.9",
"angular-recursion": "^1.0.5",
Expand Down
26 changes: 26 additions & 0 deletions src/core/server/config/deprecation/core_deprecations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,32 @@ describe('core deprecations', () => {
});
});

describe('server.cors', () => {
it('renames server.cors to server.cors.enabled', () => {
const { migrated } = applyCoreDeprecations({
server: { cors: true },
});
expect(migrated.server.cors).toEqual({ enabled: true });
});
it('logs a warning message about server.cors renaming', () => {
const { messages } = applyCoreDeprecations({
server: { cors: true },
});
expect(messages).toMatchInlineSnapshot(`
Array [
"\\"server.cors\\" is deprecated and has been replaced by \\"server.cors.enabled\\"",
]
`);
});
it('does not log deprecation message when server.cors.enabled set', () => {
const { migrated, messages } = applyCoreDeprecations({
server: { cors: { enabled: true } },
});
expect(migrated.server.cors).toEqual({ enabled: true });
expect(messages.length).toBe(0);
});
});

describe('rewriteBasePath', () => {
it('logs a warning is server.basePath is set and server.rewriteBasePath is not', () => {
const { messages } = applyCoreDeprecations({
Expand Down
12 changes: 12 additions & 0 deletions src/core/server/config/deprecation/core_deprecations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, log)
return settings;
};

const rewriteCorsSettings: ConfigDeprecation = (settings, fromPath, log) => {
const corsSettings = get(settings, 'server.cors');
if (typeof get(settings, 'server.cors') === 'boolean') {
log('"server.cors" is deprecated and has been replaced by "server.cors.enabled"');
settings.server.cors = {
enabled: corsSettings,
};
}
return settings;
};

const cspRulesDeprecation: ConfigDeprecation = (settings, fromPath, log) => {
const NONCE_STRING = `{nonce}`;
// Policies that should include the 'self' source
Expand Down Expand Up @@ -142,6 +153,7 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({
renameFromRoot('server.xsrf.whitelist', 'server.xsrf.allowlist'),
unusedFromRoot('elasticsearch.preserveHost'),
unusedFromRoot('elasticsearch.startupTimeout'),
rewriteCorsSettings,
configPathDeprecation,
dataPathDeprecation,
rewriteBasePathDeprecation,
Expand Down
6 changes: 5 additions & 1 deletion 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.

3 changes: 3 additions & 0 deletions src/core/server/http/cookie_session_storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ configService.atPath.mockImplementation((path) => {
allowFromAnyIp: true,
ipAllowlist: [],
},
cors: {
enabled: false,
},
} as any);
}
if (path === 'externalUrl') {
Expand Down
53 changes: 53 additions & 0 deletions src/core/server/http/http_config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,59 @@ describe('with compression', () => {
});
});

describe('cors', () => {
describe('origin', () => {
it('list cannot be empty', () => {
expect(() =>
config.schema.validate({
cors: {
origin: [],
},
})
).toThrowErrorMatchingInlineSnapshot(`
"[cors.origin]: types that failed validation:
- [cors.origin.0]: expected value to equal [*]
- [cors.origin.1]: array size is [0], but cannot be smaller than [1]"
`);
});

it('list of valid URLs', () => {
const origin = ['http://127.0.0.1:3000', 'https://elastic.co'];
expect(
config.schema.validate({
cors: { origin },
}).cors.origin
).toStrictEqual(origin);

expect(() =>
config.schema.validate({
cors: {
origin: ['*://elastic.co/*'],
},
})
).toThrow();
});

it('can be configured as "*" wildcard', () => {
expect(config.schema.validate({ cors: { origin: '*' } }).cors.origin).toBe('*');
});
});
describe('credentials', () => {
it('cannot use wildcard origin if "credentials: true"', () => {
expect(
() => config.schema.validate({ cors: { credentials: true, origin: '*' } }).cors.origin
).toThrowErrorMatchingInlineSnapshot(
`"[cors]: Cannot specify wildcard origin \\"*\\" with \\"credentials: true\\". Please provide a list of allowed origins."`
);
expect(
() => config.schema.validate({ cors: { credentials: true } }).cors.origin
).toThrowErrorMatchingInlineSnapshot(
`"[cors]: Cannot specify wildcard origin \\"*\\" with \\"credentials: true\\". Please provide a list of allowed origins."`
);
});
});
});

describe('HttpConfig', () => {
it('converts customResponseHeaders to strings or arrays of strings', () => {
const httpSchema = config.schema;
Expand Down
28 changes: 25 additions & 3 deletions src/core/server/http/http_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { SslConfig, sslSchema } from './ssl_config';

const validBasePathRegex = /^\/.*[^\/]$/;
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;

const hostURISchema = schema.uri({ scheme: ['http', 'https'] });
const match = (regex: RegExp, errorMsg: string) => (str: string) =>
regex.test(str) ? undefined : errorMsg;

Expand All @@ -45,7 +45,25 @@ export const config = {
validate: match(validBasePathRegex, "must start with a slash, don't end with one"),
})
),
cors: schema.boolean({ defaultValue: false }),
cors: schema.object(
{
enabled: schema.boolean({ defaultValue: false }),
credentials: schema.boolean({ defaultValue: false }),
origin: schema.oneOf(
[schema.literal('*'), schema.arrayOf(hostURISchema, { minSize: 1 })],
{
defaultValue: '*',
}
),
},
{
validate(value) {
if (value.credentials === true && value.origin === '*') {
return 'Cannot specify wildcard origin "*" with "credentials: true". Please provide a list of allowed origins.';
}
},
}
),
customResponseHeaders: schema.recordOf(schema.string(), schema.any(), {
defaultValue: {},
}),
Expand Down Expand Up @@ -148,7 +166,11 @@ export class HttpConfig {
public keepaliveTimeout: number;
public socketTimeout: number;
public port: number;
public cors: boolean | { origin: string[] };
public cors: {
enabled: boolean;
credentials: boolean;
origin: '*' | string[];
};
public customResponseHeaders: Record<string, string | string[]>;
public maxPayload: ByteSizeValue;
public basePath?: string;
Expand Down
3 changes: 3 additions & 0 deletions src/core/server/http/http_server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ beforeEach(() => {
allowFromAnyIp: true,
ipAllowlist: [],
},
cors: {
enabled: false,
},
} as any;

configWithSSL = {
Expand Down
23 changes: 23 additions & 0 deletions src/core/server/http/http_tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ describe('timeouts', () => {
host: '127.0.0.1',
maxPayload: new ByteSizeValue(1024),
ssl: {},
cors: {
enabled: false,
},
compression: { enabled: true },
requestId: {
allowFromAnyIp: true,
Expand Down Expand Up @@ -187,6 +190,26 @@ describe('getServerOptions', () => {
}
`);
});

it('properly configures CORS when cors enabled', () => {
const httpConfig = new HttpConfig(
config.schema.validate({
cors: {
enabled: true,
credentials: false,
origin: '*',
},
}),
{} as any,
{} as any
);

expect(getServerOptions(httpConfig).routes?.cors).toEqual({
credentials: false,
origin: '*',
headers: ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf'],
});
});
});

describe('getRequestId', () => {
Expand Down
25 changes: 20 additions & 5 deletions src/core/server/http/http_tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,34 @@
* specific language governing permissions and limitations
* under the License.
*/

import { Lifecycle, Request, ResponseToolkit, Server, ServerOptions, Util } from '@hapi/hapi';
import { Server } from '@hapi/hapi';
import type {
Lifecycle,
Request,
ResponseToolkit,
RouteOptionsCors,
ServerOptions,
Util,
} from '@hapi/hapi';
import Hoek from '@hapi/hoek';
import { ServerOptions as TLSOptions } from 'https';
import { ValidationError } from 'joi';
import type { ServerOptions as TLSOptions } from 'https';
import type { ValidationError } from 'joi';
import uuid from 'uuid';
import { HttpConfig } from './http_config';
import { validateObject } from './prototype_pollution';

const corsAllowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf'];
/**
* Converts Kibana `HttpConfig` into `ServerOptions` that are accepted by the Hapi server.
*/
export function getServerOptions(config: HttpConfig, { configureTLS = true } = {}) {
const cors: RouteOptionsCors | false = config.cors.enabled
? {
credentials: config.cors.credentials,
origin: config.cors.origin,
headers: corsAllowedHeaders,
}
: false;
// Note that all connection options configured here should be exactly the same
// as in the legacy platform server (see `src/legacy/server/http/index`). Any change
// SHOULD BE applied in both places. The only exception is TLS-specific options,
Expand All @@ -41,7 +56,7 @@ export function getServerOptions(config: HttpConfig, { configureTLS = true } = {
privacy: 'private',
otherwise: 'private, no-cache, no-store, must-revalidate',
},
cors: config.cors,
cors,
payload: {
maxBytes: config.maxPayload.getValueInBytes(),
},
Expand Down
3 changes: 3 additions & 0 deletions src/core/server/http/https_redirect_server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ beforeEach(() => {
enabled: true,
redirectHttpFromPort: chance.integer({ min: 20000, max: 30000 }),
},
cors: {
enabled: false,
},
} as HttpConfig;

server = new HttpsRedirectServer(loggingSystemMock.create().get());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ describe('core lifecycle handlers', () => {
ssl: {
enabled: false,
},
cors: {
enabled: false,
},
compression: { enabled: true },
name: kibanaName,
customResponseHeaders: {
Expand Down
3 changes: 3 additions & 0 deletions src/core/server/http/test_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ configService.atPath.mockImplementation((path) => {
ssl: {
enabled: false,
},
cors: {
enabled: false,
},
compression: { enabled: true },
xsrf: {
disableProtection: true,
Expand Down
3 changes: 3 additions & 0 deletions x-pack/scripts/functional_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ require('@kbn/test').runTestsCli([
require.resolve('../test/security_functional/login_selector.config.ts'),
require.resolve('../test/security_functional/oidc.config.ts'),
require.resolve('../test/security_functional/saml.config.ts'),
require.resolve('../test/functional_embedded/config.ts'),
require.resolve('../test/functional_cors/config.ts'),
require.resolve('../test/functional_enterprise_search/without_host_configured.config.ts'),
require.resolve('../test/api_integration/config_security_basic.ts'),
require.resolve('../test/api_integration/config_security_trial.ts'),
require.resolve('../test/api_integration/config.ts'),
Expand Down
Loading

0 comments on commit 91c63cf

Please sign in to comment.