diff --git a/docs/development/core/server/kibana-plugin-core-server.cspconfig.__private_.md b/docs/development/core/server/kibana-plugin-core-server.cspconfig.__private_.md new file mode 100644 index 0000000000000..217066481d33c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.cspconfig.__private_.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CspConfig](./kibana-plugin-core-server.cspconfig.md) > ["\#private"](./kibana-plugin-core-server.cspconfig.__private_.md) + +## CspConfig."\#private" property + +Signature: + +```typescript +#private; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.cspconfig.md b/docs/development/core/server/kibana-plugin-core-server.cspconfig.md index 9f4f3211ea2b1..0337a1f4d3301 100644 --- a/docs/development/core/server/kibana-plugin-core-server.cspconfig.md +++ b/docs/development/core/server/kibana-plugin-core-server.cspconfig.md @@ -20,6 +20,7 @@ The constructor for this class is marked as internal. Third-party code should no | Property | Modifiers | Type | Description | | --- | --- | --- | --- | +| ["\#private"](./kibana-plugin-core-server.cspconfig.__private_.md) | | | | | [DEFAULT](./kibana-plugin-core-server.cspconfig.default.md) | static | CspConfig | | | [disableEmbedding](./kibana-plugin-core-server.cspconfig.disableembedding.md) | | boolean | | | [header](./kibana-plugin-core-server.cspconfig.header.md) | | string | | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md index b875b1fce4288..444132024596e 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md @@ -36,6 +36,7 @@ | [isSavedObjectEmbeddableInput(input)](./kibana-plugin-plugins-embeddable-public.issavedobjectembeddableinput.md) | | | [openAddPanelFlyout(options)](./kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md) | | | [plugin(initializerContext)](./kibana-plugin-plugins-embeddable-public.plugin.md) | | +| [useEmbeddableFactory({ input, factory, onInputUpdated, })](./kibana-plugin-plugins-embeddable-public.useembeddablefactory.md) | | ## Interfaces diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.useembeddablefactory.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.useembeddablefactory.md new file mode 100644 index 0000000000000..9af20cacc2cee --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.useembeddablefactory.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [useEmbeddableFactory](./kibana-plugin-plugins-embeddable-public.useembeddablefactory.md) + +## useEmbeddableFactory() function + +Signature: + +```typescript +export declare function useEmbeddableFactory({ input, factory, onInputUpdated, }: EmbeddableRendererWithFactory): readonly [ErrorEmbeddable | IEmbeddable | undefined, boolean, string | undefined]; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| { input, factory, onInputUpdated, } | EmbeddableRendererWithFactory<I> | | + +Returns: + +`readonly [ErrorEmbeddable | IEmbeddable | undefined, boolean, string | undefined]` + diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index c3c29adcea18f..bcaa86d73adc4 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -36,11 +36,57 @@ Set to `false` to disable Console. *Default: `true`* <>. | `csp.rules:` - | A https://w3c.github.io/webappsec-csp/[content-security-policy] template + | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] +A https://w3c.github.io/webappsec-csp/[Content Security Policy] template that disables certain unnecessary and potentially insecure capabilities in the browser. It is strongly recommended that you keep the default CSP rules that ship with {kib}. +| `csp.script_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src[Content Security Policy `script-src` directive]. + +| `csp.worker_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/worker-src[Content Security Policy `worker-src` directive]. + +| `csp.style_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src[Content Security Policy `style-src` directive]. + +| `csp.connect_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src[Content Security Policy `connect-src` directive]. + +| `csp.default_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src[Content Security Policy `default-src` directive]. + +| `csp.font_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/font-src[Content Security Policy `font-src` directive]. + +| `csp.frame_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-src[Content Security Policy `frame-src` directive]. + +| `csp.img_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/img-src[Content Security Policy `img-src` directive]. + +| `csp.frame_ancestors:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors[Content Security Policy `frame-ancestors` directive]. + +|=== + +[NOTE] +============ +The `frame-ancestors` directive can also be configured by using +<>. In that case, that takes precedence and any values in `csp.frame_ancestors` +are ignored. +============ + +[cols="2*<"] +|=== + +| `csp.report_uri:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri[Content Security Policy `report-uri` directive]. + +| `csp.report_to:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-to[Content Security Policy `report-to` directive]. + |[[csp-strict]] `csp.strict:` | Blocks {kib} access to any browser that does not enforce even rudimentary CSP rules. In practice, this disables @@ -538,8 +584,7 @@ a|`server.securityResponseHeaders:` is used in all responses to the client from the {kib} server, and specifies what value is used. Allowed values are any text value or `null`. To disable, set to `null`. *Default:* `null` -[[server-securityResponseHeaders-disableEmbedding]] -a|`server.securityResponseHeaders:` +|[[server-securityResponseHeaders-disableEmbedding]]`server.securityResponseHeaders:` `disableEmbedding:` | Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy[`Content-Security-Policy`] and https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options[`X-Frame-Options`] headers are configured to disable embedding diff --git a/docs/user/dashboard/lens-advanced.asciidoc b/docs/user/dashboard/lens-advanced.asciidoc index ec8d90aa4920e..33e0e362058f4 100644 --- a/docs/user/dashboard/lens-advanced.asciidoc +++ b/docs/user/dashboard/lens-advanced.asciidoc @@ -104,7 +104,7 @@ To quickly create many copies of a percentile metric that shows distribution of . From the *Chart Type* dropdown, select *Line*. + [role="screenshot"] -image::images/lens_advanced_2_1.png[Chart type menu with Line selected] +image::images/lens_advanced_2_1.png[Chart type menu with Line selected, width=50%] . From the *Available fields* list, drag and drop *products.price* to the visualization builder. @@ -239,12 +239,11 @@ For each category type that you want to break down, create a filter. Change the legend position to the top of the chart. . From the *Legend* dropdown, select the top position. - + [role="screenshot"] image::images/lens_advanced_4_1.png[Prices share by category] - Click *Save and return*. +. Click *Save and return*. [discrete] [[view-the-cumulative-number-of-products-sold-on-weekends]] @@ -299,7 +298,8 @@ image::images/lens_advanced_5_2.png[Line chart with cumulative sum of orders mad [[compare-time-ranges]] === Compare time ranges -*Lens* allows you to compare the currently selected time range with historical data using the *Time shift* option. +*Lens* allows you to compare the currently selected time range with historical data using the *Time shift* option. To calculate the percent +change, use *Formula*. Time shifts can be used on any metric. The special shift *previous* will show the time window preceding the currently selected one, spanning the same duration. For example, if *Last 7 days* is selected in the time filter, *previous* will show data from 14 days ago to 7 days ago. @@ -326,9 +326,32 @@ To compare current sales numbers with sales from a week ago, follow these steps: .. Click *Time shift* .. Click the *1 week* option. You can also define custom shifts by typing amount followed by time unit (like *1w* for a one week shift), then hit enter. - ++ [role="screenshot"] -image::images/lens_time_shift.png[Line chart with week-over-week sales comparison] +image::images/lens_time_shift.png[Line chart with week-over-week sales comparison, width=50%] + +. Click *Save and return*. + +[float] +[[compare-time-as-percent]] +==== Compare time ranges as a percent change + +To view the percent change in sales between the current time and the previous week, use a *Formula*: + +. Open *Lens*. + +. From the *Available fields* list, drag and drop *Records* to the visualization builder. + +. Click *Count of Records*, then click *Formula*. + +. Type `count() / count(shift='1w') - 1`. To learn more about the formula +syntax, click *Help*. + +. Click *Value format* and select *Percent* with 0 decimals. + +. In the *Display name* field, enter `Percent change`, then click *Close*. + +. Click *Save and return*. [discrete] [[view-customers-over-time-by-continents]] @@ -366,18 +389,14 @@ To split the customers count by continent: . From the *Available fields* list, drag and drop *geoip.continent_name* to the *Columns* field of the editor. + [role="screenshot"] -image::images/lens_advanced_6_1.png[Table with daily customers by continent configuration] +image::images/lens_advanced_6_1.png[Table with daily customers by continent configuration, width=50%] . Click *Save and return*. + [discrete] === Save the dashboard -By default the dashboard attempts to match the palette across panels, but in this case there's no need for that, so it can be disabled. - -[role="screenshot"] -image::images/lens_advanced_7_1.png[Disable palette sync in dashboard] - Now that you have a complete overview of your ecommerce sales data, save the dashboard. . In the toolbar, click *Save*. diff --git a/src/core/server/csp/config.test.ts b/src/core/server/csp/config.test.ts index 8036ebeeaad31..6db93addb7da8 100644 --- a/src/core/server/csp/config.test.ts +++ b/src/core/server/csp/config.test.ts @@ -9,11 +9,469 @@ import { config } from './config'; describe('config.validate()', () => { - test(`does not allow "disableEmbedding" to be set to true`, () => { + it(`does not allow "disableEmbedding" to be set to true`, () => { // This is intentionally not editable in the raw CSP config. // Users should set `server.securityResponseHeaders.disableEmbedding` to control this config property. expect(() => config.schema.validate({ disableEmbedding: true })).toThrowError( '[disableEmbedding]: expected value to equal [false]' ); }); + + describe(`"script_src"`, () => { + it(`throws if containing 'unsafe-inline' when 'strict' is true`, () => { + expect(() => + config.schema.validate({ + strict: true, + warnLegacyBrowsers: false, + script_src: [`'self'`, `unsafe-inline`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.strict\` is true"` + ); + + expect(() => + config.schema.validate({ + strict: true, + warnLegacyBrowsers: false, + script_src: [`'self'`, `'unsafe-inline'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.strict\` is true"` + ); + }); + + it(`throws if containing 'unsafe-inline' when 'warnLegacyBrowsers' is true`, () => { + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: true, + script_src: [`'self'`, `unsafe-inline`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.warnLegacyBrowsers\` is true"` + ); + + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: true, + script_src: [`'self'`, `'unsafe-inline'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.warnLegacyBrowsers\` is true"` + ); + }); + + it(`does not throw if containing 'unsafe-inline' when 'strict' and 'warnLegacyBrowsers' are false`, () => { + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: false, + script_src: [`'self'`, `unsafe-inline`], + }) + ).not.toThrow(); + + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: false, + script_src: [`'self'`, `'unsafe-inline'`], + }) + ).not.toThrow(); + }); + + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + script_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + script_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[script_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + script_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[script_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + script_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[script_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"worker_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + worker_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + worker_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[worker_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + worker_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[worker_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + worker_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[worker_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"style_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + style_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + style_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[style_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + style_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[style_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + style_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[style_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"connect_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + connect_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + connect_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[connect_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + connect_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[connect_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + connect_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[connect_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"default_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + default_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + default_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[default_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + default_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[default_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + default_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[default_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"font_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + font_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + font_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[font_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + font_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[font_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + font_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[font_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"frame_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + frame_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + frame_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + frame_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + frame_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"img_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + img_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + img_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[img_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + img_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[img_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + img_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[img_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"frame_ancestors"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + frame_ancestors: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + frame_ancestors: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_ancestors]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + frame_ancestors: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_ancestors]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + frame_ancestors: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_ancestors]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); }); diff --git a/src/core/server/csp/config.ts b/src/core/server/csp/config.ts index a61fa1b03a45c..3a7cb20985cea 100644 --- a/src/core/server/csp/config.ts +++ b/src/core/server/csp/config.ts @@ -7,28 +7,150 @@ */ import { TypeOf, schema } from '@kbn/config-schema'; +import { ServiceConfigDescriptor } from '../internal_types'; + +interface DirectiveValidationOptions { + allowNone: boolean; + allowNonce: boolean; +} + +const getDirectiveValidator = (options: DirectiveValidationOptions) => { + const validateValue = getDirectiveValueValidator(options); + return (values: string[]) => { + for (const value of values) { + const error = validateValue(value); + if (error) { + return error; + } + } + }; +}; + +const getDirectiveValueValidator = ({ allowNone, allowNonce }: DirectiveValidationOptions) => { + return (value: string) => { + if (!allowNonce && value.startsWith('nonce-')) { + return `using "nonce-*" is considered insecure and is not allowed`; + } + if (!allowNone && (value === `none` || value === `'none'`)) { + return `using "none" would conflict with Kibana's default csp configuration and is not allowed`; + } + }; +}; + +const configSchema = schema.object( + { + rules: schema.maybe(schema.arrayOf(schema.string())), + script_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + worker_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + style_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + connect_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + default_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + font_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + frame_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + img_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + frame_ancestors: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + report_uri: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: true, allowNonce: false }), + }), + report_to: schema.arrayOf(schema.string(), { + defaultValue: [], + }), + strict: schema.boolean({ defaultValue: true }), + warnLegacyBrowsers: schema.boolean({ defaultValue: true }), + disableEmbedding: schema.oneOf([schema.literal(false)], { defaultValue: false }), + }, + { + validate: (cspConfig) => { + if (cspConfig.rules && hasDirectiveSpecified(cspConfig)) { + return `"csp.rules" cannot be used when specifying per-directive additions such as "script_src", "worker_src" or "style_src"`; + } + const hasUnsafeInlineScriptSrc = + cspConfig.script_src.includes(`unsafe-inline`) || + cspConfig.script_src.includes(`'unsafe-inline'`); + + if (cspConfig.strict && hasUnsafeInlineScriptSrc) { + return 'cannot use `unsafe-inline` for `script_src` when `csp.strict` is true'; + } + if (cspConfig.warnLegacyBrowsers && hasUnsafeInlineScriptSrc) { + return 'cannot use `unsafe-inline` for `script_src` when `csp.warnLegacyBrowsers` is true'; + } + }, + } +); + +const hasDirectiveSpecified = (rawConfig: CspConfigType): boolean => { + return Boolean( + rawConfig.script_src.length || + rawConfig.worker_src.length || + rawConfig.style_src.length || + rawConfig.connect_src.length || + rawConfig.default_src.length || + rawConfig.font_src.length || + rawConfig.frame_src.length || + rawConfig.img_src.length || + rawConfig.frame_ancestors.length || + rawConfig.report_uri.length || + rawConfig.report_to.length + ); +}; /** * @internal */ -export type CspConfigType = TypeOf; +export type CspConfigType = TypeOf; -export const config = { +export const config: ServiceConfigDescriptor = { // TODO: Move this to server.csp using config deprecations // ? https://github.com/elastic/kibana/pull/52251 path: 'csp', - schema: schema.object({ - rules: schema.arrayOf(schema.string(), { - defaultValue: [ - `script-src 'unsafe-eval' 'self'`, - `worker-src blob: 'self'`, - `style-src 'unsafe-inline' 'self'`, - ], - }), - strict: schema.boolean({ defaultValue: true }), - warnLegacyBrowsers: schema.boolean({ defaultValue: true }), - disableEmbedding: schema.oneOf([schema.literal(false)], { defaultValue: false }), - }), + schema: configSchema, + deprecations: () => [ + (rawConfig, fromPath, addDeprecation) => { + const cspConfig = rawConfig[fromPath]; + if (cspConfig?.rules) { + addDeprecation({ + message: + '`csp.rules` is deprecated in favor of directive specific configuration. Please use `csp.connect_src`, ' + + '`csp.default_src`, `csp.font_src`, `csp.frame_ancestors`, `csp.frame_src`, `csp.img_src`, ' + + '`csp.report_uri`, `csp.report_to`, `csp.script_src`, `csp.style_src`, and `csp.worker_src` instead.', + correctiveActions: { + manualSteps: [ + `Remove "csp.rules" from the Kibana config file."`, + `Add directive specific configurations to the config file using "csp.connect_src", "csp.default_src", "csp.font_src", ` + + `"csp.frame_ancestors", "csp.frame_src", "csp.img_src", "csp.report_uri", "csp.report_to", "csp.script_src", ` + + `"csp.style_src", and "csp.worker_src".`, + ], + }, + }); + } + }, + ], }; - -export const FRAME_ANCESTORS_RULE = `frame-ancestors 'self'`; // only used by CspConfig when embedding is disabled diff --git a/src/core/server/csp/csp_config.test.ts b/src/core/server/csp/csp_config.test.ts index 1e023c6f08ea8..a1bac7d4ae73e 100644 --- a/src/core/server/csp/csp_config.test.ts +++ b/src/core/server/csp/csp_config.test.ts @@ -7,7 +7,7 @@ */ import { CspConfig } from './csp_config'; -import { FRAME_ANCESTORS_RULE } from './config'; +import { config as cspConfig, CspConfigType } from './config'; // CSP rules aren't strictly additive, so any change can potentially expand or // restrict the policy in a way we consider a breaking change. For that reason, @@ -23,6 +23,12 @@ import { FRAME_ANCESTORS_RULE } from './config'; // the nature of a change in defaults during a PR review. describe('CspConfig', () => { + let defaultConfig: CspConfigType; + + beforeEach(() => { + defaultConfig = cspConfig.schema.validate({}); + }); + test('DEFAULT', () => { expect(CspConfig.DEFAULT).toMatchInlineSnapshot(` CspConfig { @@ -40,50 +46,129 @@ describe('CspConfig', () => { }); test('defaults from config', () => { - expect(new CspConfig()).toEqual(CspConfig.DEFAULT); + expect(new CspConfig(defaultConfig)).toEqual(CspConfig.DEFAULT); }); describe('partial config', () => { test('allows "rules" to be set and changes header', () => { - const rules = ['foo', 'bar']; - const config = new CspConfig({ rules }); + const rules = [`foo 'self'`, `bar 'self'`]; + const config = new CspConfig({ ...defaultConfig, rules }); expect(config.rules).toEqual(rules); - expect(config.header).toMatchInlineSnapshot(`"foo; bar"`); + expect(config.header).toMatchInlineSnapshot(`"foo 'self'; bar 'self'"`); }); test('allows "strict" to be set', () => { - const config = new CspConfig({ strict: false }); + const config = new CspConfig({ ...defaultConfig, strict: false }); expect(config.strict).toEqual(false); expect(config.strict).not.toEqual(CspConfig.DEFAULT.strict); }); test('allows "warnLegacyBrowsers" to be set', () => { const warnLegacyBrowsers = false; - const config = new CspConfig({ warnLegacyBrowsers }); + const config = new CspConfig({ ...defaultConfig, warnLegacyBrowsers }); expect(config.warnLegacyBrowsers).toEqual(warnLegacyBrowsers); expect(config.warnLegacyBrowsers).not.toEqual(CspConfig.DEFAULT.warnLegacyBrowsers); }); + test('allows "worker_src" to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + worker_src: ['foo', 'bar'], + }); + expect(config.rules).toEqual([`worker-src foo bar`]); + expect(config.header).toEqual(`worker-src foo bar`); + }); + + test('allows "style_src" to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + style_src: ['foo', 'bar'], + }); + expect(config.rules).toEqual([`style-src foo bar`]); + expect(config.header).toEqual(`style-src foo bar`); + }); + + test('allows "script_src" to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + script_src: ['foo', 'bar'], + }); + expect(config.rules).toEqual([`script-src foo bar`]); + expect(config.header).toEqual(`script-src foo bar`); + }); + + test('allows all directives to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + script_src: ['script', 'foo'], + worker_src: ['worker', 'bar'], + style_src: ['style', 'dolly'], + }); + expect(config.rules).toEqual([ + `script-src script foo`, + `worker-src worker bar`, + `style-src style dolly`, + ]); + expect(config.header).toEqual( + `script-src script foo; worker-src worker bar; style-src style dolly` + ); + }); + + test('applies defaults when `rules` is undefined', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: undefined, + script_src: ['script'], + worker_src: ['worker'], + style_src: ['style'], + }); + expect(config.rules).toEqual([ + `script-src 'unsafe-eval' 'self' script`, + `worker-src blob: 'self' worker`, + `style-src 'unsafe-inline' 'self' style`, + ]); + expect(config.header).toEqual( + `script-src 'unsafe-eval' 'self' script; worker-src blob: 'self' worker; style-src 'unsafe-inline' 'self' style` + ); + }); + describe('allows "disableEmbedding" to be set', () => { const disableEmbedding = true; test('and changes rules/header if custom rules are not defined', () => { - const config = new CspConfig({ disableEmbedding }); + const config = new CspConfig({ ...defaultConfig, disableEmbedding }); expect(config.disableEmbedding).toEqual(disableEmbedding); expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); - expect(config.rules).toEqual(expect.arrayContaining([FRAME_ANCESTORS_RULE])); + expect(config.rules).toEqual(expect.arrayContaining([`frame-ancestors 'self'`])); expect(config.header).toMatchInlineSnapshot( `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'"` ); }); test('and does not change rules/header if custom rules are defined', () => { - const rules = ['foo', 'bar']; - const config = new CspConfig({ disableEmbedding, rules }); + const rules = [`foo 'self'`, `bar 'self'`]; + const config = new CspConfig({ ...defaultConfig, disableEmbedding, rules }); expect(config.disableEmbedding).toEqual(disableEmbedding); expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); expect(config.rules).toEqual(rules); - expect(config.header).toMatchInlineSnapshot(`"foo; bar"`); + expect(config.header).toMatchInlineSnapshot(`"foo 'self'; bar 'self'"`); + }); + + test('and overrides `frame-ancestors` if set', () => { + const config = new CspConfig({ + ...defaultConfig, + disableEmbedding: true, + frame_ancestors: ['foo.com'], + }); + expect(config.disableEmbedding).toEqual(disableEmbedding); + expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); + expect(config.header).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'"` + ); }); }); }); diff --git a/src/core/server/csp/csp_config.ts b/src/core/server/csp/csp_config.ts index 649c81576ef52..13778088d9df2 100644 --- a/src/core/server/csp/csp_config.ts +++ b/src/core/server/csp/csp_config.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { config, FRAME_ANCESTORS_RULE } from './config'; +import { config, CspConfigType } from './config'; +import { CspDirectives } from './csp_directives'; const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); @@ -50,8 +51,9 @@ export interface ICspConfig { * @public */ export class CspConfig implements ICspConfig { - static readonly DEFAULT = new CspConfig(); + static readonly DEFAULT = new CspConfig(DEFAULT_CONFIG); + readonly #directives: CspDirectives; public readonly rules: string[]; public readonly strict: boolean; public readonly warnLegacyBrowsers: boolean; @@ -62,16 +64,18 @@ export class CspConfig implements ICspConfig { * Returns the default CSP configuration when passed with no config * @internal */ - constructor(rawCspConfig: Partial> = {}) { - const source = { ...DEFAULT_CONFIG, ...rawCspConfig }; - - this.rules = [...source.rules]; - this.strict = source.strict; - this.warnLegacyBrowsers = source.warnLegacyBrowsers; - this.disableEmbedding = source.disableEmbedding; - if (!rawCspConfig.rules?.length && source.disableEmbedding) { - this.rules.push(FRAME_ANCESTORS_RULE); + constructor(rawCspConfig: CspConfigType) { + this.#directives = CspDirectives.fromConfig(rawCspConfig); + if (!rawCspConfig.rules?.length && rawCspConfig.disableEmbedding) { + this.#directives.clearDirectiveValues('frame-ancestors'); + this.#directives.addDirectiveValue('frame-ancestors', `'self'`); } - this.header = this.rules.join('; '); + + this.rules = this.#directives.getRules(); + this.header = this.#directives.getCspHeader(); + + this.strict = rawCspConfig.strict; + this.warnLegacyBrowsers = rawCspConfig.warnLegacyBrowsers; + this.disableEmbedding = rawCspConfig.disableEmbedding; } } diff --git a/src/core/server/csp/csp_directives.test.ts b/src/core/server/csp/csp_directives.test.ts new file mode 100644 index 0000000000000..1077b6ea9f3cd --- /dev/null +++ b/src/core/server/csp/csp_directives.test.ts @@ -0,0 +1,266 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CspDirectives } from './csp_directives'; +import { config as cspConfig } from './config'; + +describe('CspDirectives', () => { + describe('#addDirectiveValue', () => { + it('properly updates the rules', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo", + ] + `); + + directives.addDirectiveValue('style-src', 'bar'); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo bar", + ] + `); + }); + + it('properly updates the header', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot(`"style-src foo"`); + + directives.addDirectiveValue('style-src', 'bar'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot(`"style-src foo bar"`); + }); + + it('handles distinct directives', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + directives.addDirectiveValue('style-src', 'bar'); + directives.addDirectiveValue('worker-src', 'dolly'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"style-src foo bar; worker-src dolly"` + ); + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo bar", + "worker-src dolly", + ] + `); + }); + + it('removes duplicates', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + directives.addDirectiveValue('style-src', 'foo'); + directives.addDirectiveValue('style-src', 'bar'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot(`"style-src foo bar"`); + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo bar", + ] + `); + }); + + it('automatically adds single quotes for keywords', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'none'); + directives.addDirectiveValue('style-src', 'self'); + directives.addDirectiveValue('style-src', 'strict-dynamic'); + directives.addDirectiveValue('style-src', 'report-sample'); + directives.addDirectiveValue('style-src', 'unsafe-inline'); + directives.addDirectiveValue('style-src', 'unsafe-eval'); + directives.addDirectiveValue('style-src', 'unsafe-hashes'); + directives.addDirectiveValue('style-src', 'unsafe-allow-redirects'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"style-src 'none' 'self' 'strict-dynamic' 'report-sample' 'unsafe-inline' 'unsafe-eval' 'unsafe-hashes' 'unsafe-allow-redirects'"` + ); + }); + + it('does not add single quotes for keywords when already present', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', `'none'`); + directives.addDirectiveValue('style-src', `'self'`); + directives.addDirectiveValue('style-src', `'strict-dynamic'`); + directives.addDirectiveValue('style-src', `'report-sample'`); + directives.addDirectiveValue('style-src', `'unsafe-inline'`); + directives.addDirectiveValue('style-src', `'unsafe-eval'`); + directives.addDirectiveValue('style-src', `'unsafe-hashes'`); + directives.addDirectiveValue('style-src', `'unsafe-allow-redirects'`); + + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"style-src 'none' 'self' 'strict-dynamic' 'report-sample' 'unsafe-inline' 'unsafe-eval' 'unsafe-hashes' 'unsafe-allow-redirects'"` + ); + }); + }); + + describe('#fromConfig', () => { + it('returns the correct rules for the default config', () => { + const config = cspConfig.schema.validate({}); + const directives = CspDirectives.fromConfig(config); + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self'", + "worker-src blob: 'self'", + "style-src 'unsafe-inline' 'self'", + ] + `); + }); + + it('returns the correct header for the default config', () => { + const config = cspConfig.schema.validate({}); + const directives = CspDirectives.fromConfig(config); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'"` + ); + }); + + it('handles config with rules', () => { + const config = cspConfig.schema.validate({ + rules: [`script-src 'self' http://foo.com`, `worker-src 'self'`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "worker-src 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; worker-src 'self'"` + ); + }); + + it('adds single quotes for keyword for rules', () => { + const config = cspConfig.schema.validate({ + rules: [`script-src self http://foo.com`, `worker-src self`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "worker-src 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; worker-src 'self'"` + ); + }); + + it('handles multiple whitespaces when parsing rules', () => { + const config = cspConfig.schema.validate({ + rules: [` script-src 'self' http://foo.com `, ` worker-src 'self' `], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "worker-src 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; worker-src 'self'"` + ); + }); + + it('supports unregistered directives', () => { + const config = cspConfig.schema.validate({ + rules: [`script-src 'self' http://foo.com`, `img-src 'self'`, 'foo bar'], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "img-src 'self'", + "foo bar", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; img-src 'self'; foo bar"` + ); + }); + + it('adds default value for config with directives', () => { + const config = cspConfig.schema.validate({ + script_src: [`baz`], + worker_src: [`foo`], + style_src: [`bar`, `dolly`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self' baz", + "worker-src blob: 'self' foo", + "style-src 'unsafe-inline' 'self' bar dolly", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self' baz; worker-src blob: 'self' foo; style-src 'unsafe-inline' 'self' bar dolly"` + ); + }); + + it('adds additional values for some directives without defaults', () => { + const config = cspConfig.schema.validate({ + connect_src: [`connect-src`], + default_src: [`default-src`], + font_src: [`font-src`], + frame_src: [`frame-src`], + img_src: [`img-src`], + frame_ancestors: [`frame-ancestors`], + report_uri: [`report-uri`], + report_to: [`report-to`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self'", + "worker-src blob: 'self'", + "style-src 'unsafe-inline' 'self'", + "connect-src 'self' connect-src", + "default-src 'self' default-src", + "font-src 'self' font-src", + "frame-src 'self' frame-src", + "img-src 'self' img-src", + "frame-ancestors 'self' frame-ancestors", + "report-uri report-uri", + "report-to report-to", + ] + `); + }); + + it('adds single quotes for keywords in added directives', () => { + const config = cspConfig.schema.validate({ + script_src: [`unsafe-hashes`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self' 'unsafe-hashes'", + "worker-src blob: 'self'", + "style-src 'unsafe-inline' 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self' 'unsafe-hashes'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'"` + ); + }); + }); +}); diff --git a/src/core/server/csp/csp_directives.ts b/src/core/server/csp/csp_directives.ts new file mode 100644 index 0000000000000..9e3b60f7f1e4f --- /dev/null +++ b/src/core/server/csp/csp_directives.ts @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CspConfigType } from './config'; + +export type CspDirectiveName = + | 'script-src' + | 'worker-src' + | 'style-src' + | 'frame-ancestors' + | 'connect-src' + | 'default-src' + | 'font-src' + | 'frame-src' + | 'img-src' + | 'report-uri' + | 'report-to'; + +/** + * The default rules that are always applied + */ +export const defaultRules: Partial> = { + 'script-src': [`'unsafe-eval'`, `'self'`], + 'worker-src': [`blob:`, `'self'`], + 'style-src': [`'unsafe-inline'`, `'self'`], +}; + +/** + * Per-directive rules that will be added when the configuration contains at least one value + * Main purpose is to add `self` value to some directives when the configuration specifies other values + */ +export const additionalRules: Partial> = { + 'connect-src': [`'self'`], + 'default-src': [`'self'`], + 'font-src': [`'self'`], + 'img-src': [`'self'`], + 'frame-ancestors': [`'self'`], + 'frame-src': [`'self'`], +}; + +export class CspDirectives { + private readonly directives = new Map>(); + + addDirectiveValue(directiveName: CspDirectiveName, directiveValue: string) { + if (!this.directives.has(directiveName)) { + this.directives.set(directiveName, new Set()); + } + this.directives.get(directiveName)!.add(normalizeDirectiveValue(directiveValue)); + } + + clearDirectiveValues(directiveName: CspDirectiveName) { + this.directives.delete(directiveName); + } + + getCspHeader() { + return this.getRules().join('; '); + } + + getRules() { + return [...this.directives.entries()].map(([name, values]) => { + return [name, ...values].join(' '); + }); + } + + static fromConfig(config: CspConfigType): CspDirectives { + const cspDirectives = new CspDirectives(); + + // adding `csp.rules` or `default` rules + const initialRules = config.rules ? parseRules(config.rules) : { ...defaultRules }; + Object.entries(initialRules).forEach(([key, values]) => { + values?.forEach((value) => { + cspDirectives.addDirectiveValue(key as CspDirectiveName, value); + }); + }); + + // adding per-directive configuration + const additiveConfig = parseConfigDirectives(config); + [...additiveConfig.entries()].forEach(([directiveName, directiveValues]) => { + const additionalValues = additionalRules[directiveName] ?? []; + [...additionalValues, ...directiveValues].forEach((value) => { + cspDirectives.addDirectiveValue(directiveName, value); + }); + }); + + return cspDirectives; + } +} + +const parseRules = (rules: string[]): Partial> => { + const directives: Partial> = {}; + rules.forEach((rule) => { + const [name, ...values] = rule.replace(/\s+/g, ' ').trim().split(' '); + directives[name as CspDirectiveName] = values; + }); + return directives; +}; + +const parseConfigDirectives = (cspConfig: CspConfigType): Map => { + const map = new Map(); + + if (cspConfig.script_src?.length) { + map.set('script-src', cspConfig.script_src); + } + if (cspConfig.worker_src?.length) { + map.set('worker-src', cspConfig.worker_src); + } + if (cspConfig.style_src?.length) { + map.set('style-src', cspConfig.style_src); + } + if (cspConfig.connect_src?.length) { + map.set('connect-src', cspConfig.connect_src); + } + if (cspConfig.default_src?.length) { + map.set('default-src', cspConfig.default_src); + } + if (cspConfig.font_src?.length) { + map.set('font-src', cspConfig.font_src); + } + if (cspConfig.frame_src?.length) { + map.set('frame-src', cspConfig.frame_src); + } + if (cspConfig.img_src?.length) { + map.set('img-src', cspConfig.img_src); + } + if (cspConfig.frame_ancestors?.length) { + map.set('frame-ancestors', cspConfig.frame_ancestors); + } + if (cspConfig.report_uri?.length) { + map.set('report-uri', cspConfig.report_uri); + } + if (cspConfig.report_to?.length) { + map.set('report-to', cspConfig.report_to); + } + + return map; +}; + +const keywordTokens = [ + 'none', + 'self', + 'strict-dynamic', + 'report-sample', + 'unsafe-inline', + 'unsafe-eval', + 'unsafe-hashes', + 'unsafe-allow-redirects', +]; + +function normalizeDirectiveValue(value: string) { + if (keywordTokens.includes(value)) { + return `'${value}'`; + } + return value; +} diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index c802163866423..55af02a08561b 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -69,7 +69,11 @@ configService.atPath.mockImplementation((path) => { } as any); } if (path === 'csp') { - return new BehaviorSubject({} as any); + return new BehaviorSubject({ + strict: false, + disableEmbedding: false, + warnLegacyBrowsers: true, + }); } throw new Error(`Unexpected config path: ${path}`); }); diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 56095336d970b..06a4745632233 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -8,7 +8,7 @@ import uuid from 'uuid'; import { config, HttpConfig } from './http_config'; -import { CspConfig } from '../csp'; +import { config as cspConfig } from '../csp'; import { ExternalUrlConfig } from '../external_url'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost', '0.0.0.0']; @@ -459,7 +459,8 @@ describe('HttpConfig', () => { }, }, }); - const httpConfig = new HttpConfig(rawConfig, CspConfig.DEFAULT, ExternalUrlConfig.DEFAULT); + const rawCspConfig = cspConfig.schema.validate({}); + const httpConfig = new HttpConfig(rawConfig, rawCspConfig, ExternalUrlConfig.DEFAULT); expect(httpConfig.customResponseHeaders).toEqual({ string: 'string', diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index cbd300fdc9c09..c2023c5577d61 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -79,7 +79,11 @@ describe('core lifecycle handlers', () => { } as any); } if (path === 'csp') { - return new BehaviorSubject({} as any); + return new BehaviorSubject({ + strict: false, + disableEmbedding: false, + warnLegacyBrowsers: true, + }); } throw new Error(`Unexpected config path: ${path}`); }); diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index b3180b43d0026..4e1a88e967f8f 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -56,7 +56,11 @@ configService.atPath.mockImplementation((path) => { } as any); } if (path === 'csp') { - return new BehaviorSubject({} as any); + return new BehaviorSubject({ + strict: false, + disableEmbedding: false, + warnLegacyBrowsers: true, + }); } throw new Error(`Unexpected config path: ${path}`); }); diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index fcecf39f7e53a..3bc0b54635eb5 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -777,8 +777,12 @@ export interface CountResponse { // @public export class CspConfig implements ICspConfig { + // (undocumented) + #private; + // Warning: (ae-forgotten-export) The symbol "CspConfigType" needs to be exported by the entry point index.d.ts + // // @internal - constructor(rawCspConfig?: Partial>); + constructor(rawCspConfig: CspConfigType); // (undocumented) static readonly DEFAULT: CspConfig; // (undocumented) diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index b520ab3070b15..a224793bace3f 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -31,6 +31,17 @@ kibana_vars=( csp.rules csp.strict csp.warnLegacyBrowsers + csp.script_src + csp.worker_src + csp.style_src + csp.connect_src + csp.default_src + csp.font_src + csp.frame_src + csp.img_src + csp.frame_ancestors + csp.report_uri + csp.report_to data.autocomplete.valueSuggestions.terminateAfter data.autocomplete.valueSuggestions.timeout elasticsearch.customHeaders diff --git a/src/plugins/data/common/es_query/filters/phrases_filter.ts b/src/plugins/data/common/es_query/filters/phrases_filter.ts index 8a79472154493..2694461fc1930 100644 --- a/src/plugins/data/common/es_query/filters/phrases_filter.ts +++ b/src/plugins/data/common/es_query/filters/phrases_filter.ts @@ -41,11 +41,6 @@ export const buildPhrasesFilter = ( const type = FILTERS.PHRASES; const key = field.name; - const format = (f: IFieldType, value: any) => - f && f.format && f.format.convert ? f.format.convert(value) : value; - - const value = params.map((v: any) => format(field, v)).join(', '); - let should; if (field.scripted) { should = params.map((v: any) => ({ @@ -60,7 +55,7 @@ export const buildPhrasesFilter = ( } return { - meta: { index, type, key, value, params }, + meta: { index, type, key, params }, query: { bool: { should, diff --git a/src/plugins/data/common/es_query/filters/range_filter.ts b/src/plugins/data/common/es_query/filters/range_filter.ts index 7bc7a8cff7487..9f1d9a5d08926 100644 --- a/src/plugins/data/common/es_query/filters/range_filter.ts +++ b/src/plugins/data/common/es_query/filters/range_filter.ts @@ -84,10 +84,7 @@ export const getRangeFilterField = (filter: RangeFilter) => { }; const formatValue = (field: IFieldType, params: any[]) => - map(params, (val: any, key: string) => get(operators, key) + format(field, val)).join(' '); - -const format = (field: IFieldType, value: any) => - field && field.format && field.format.convert ? field.format.convert(value) : value; + map(params, (val: any, key: string) => get(operators, key) + val).join(' '); // Creates a filter where the value for the given field is in the given range // params should be an object containing `lt`, `lte`, `gt`, and/or `gte` diff --git a/src/plugins/data/common/search/expressions/phrase_filter.test.ts b/src/plugins/data/common/search/expressions/phrase_filter.test.ts index 39bd907513a0d..a61cc0bfd68ab 100644 --- a/src/plugins/data/common/search/expressions/phrase_filter.test.ts +++ b/src/plugins/data/common/search/expressions/phrase_filter.test.ts @@ -32,7 +32,6 @@ describe('interpreter/functions#phraseFilter', () => { "something", ], "type": "phrases", - "value": "test, something", }, "query": Object { "bool": Object { diff --git a/src/plugins/data/public/query/filter_manager/lib/get_display_value.test.ts b/src/plugins/data/public/query/filter_manager/lib/get_display_value.test.ts new file mode 100644 index 0000000000000..48e1007534769 --- /dev/null +++ b/src/plugins/data/public/query/filter_manager/lib/get_display_value.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { stubIndexPattern, phraseFilter } from 'src/plugins/data/common/stubs'; +import { getDisplayValueFromFilter } from './get_display_value'; + +describe('getDisplayValueFromFilter', () => { + it('returns the value if string', () => { + phraseFilter.meta.value = 'abc'; + const displayValue = getDisplayValueFromFilter(phraseFilter, [stubIndexPattern]); + expect(displayValue).toBe('abc'); + }); + + it('returns the value if undefined', () => { + phraseFilter.meta.value = undefined; + const displayValue = getDisplayValueFromFilter(phraseFilter, [stubIndexPattern]); + expect(displayValue).toBe(''); + }); + + it('calls the value function if proivided', () => { + // The type of value currently doesn't match how it's used. Refactor needed. + phraseFilter.meta.value = jest.fn((x) => { + return 'abc'; + }) as any; + const displayValue = getDisplayValueFromFilter(phraseFilter, [stubIndexPattern]); + expect(displayValue).toBe('abc'); + expect(phraseFilter.meta.value).toHaveBeenCalledWith(undefined); + }); + + it('calls the value function if proivided, with formatter', () => { + stubIndexPattern.getFormatterForField = jest.fn().mockReturnValue('banana'); + phraseFilter.meta.value = jest.fn((x) => { + return x + 'abc'; + }) as any; + const displayValue = getDisplayValueFromFilter(phraseFilter, [stubIndexPattern]); + expect(stubIndexPattern.getFormatterForField).toHaveBeenCalledTimes(1); + expect(phraseFilter.meta.value).toHaveBeenCalledWith('banana'); + expect(displayValue).toBe('bananaabc'); + }); +}); diff --git a/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts b/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts index 45c6167f600bc..1ccfaacb24e4b 100644 --- a/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts +++ b/src/plugins/data/public/query/filter_manager/lib/get_display_value.ts @@ -28,11 +28,14 @@ function getValueFormatter(indexPattern?: IIndexPattern, key?: string) { } export function getDisplayValueFromFilter(filter: Filter, indexPatterns: IIndexPattern[]): string { - if (typeof filter.meta.value === 'function') { + const { key, value } = filter.meta; + if (typeof value === 'function') { const indexPattern = getIndexPatternFromFilter(filter, indexPatterns); - const valueFormatter: any = getValueFormatter(indexPattern, filter.meta.key); - return (filter.meta.value as any)(valueFormatter); + const valueFormatter = getValueFormatter(indexPattern, key); + // TODO: distinguish between FilterMeta which is serializable to mapped FilterMeta + // Where value can be a function. + return (value as any)(valueFormatter); } else { - return filter.meta.value || ''; + return value || ''; } } diff --git a/src/plugins/data/public/query/filter_manager/lib/mappers/map_phrases.ts b/src/plugins/data/public/query/filter_manager/lib/mappers/map_phrases.ts index bfd528264b00f..5601dd66e5206 100644 --- a/src/plugins/data/public/query/filter_manager/lib/mappers/map_phrases.ts +++ b/src/plugins/data/public/query/filter_manager/lib/mappers/map_phrases.ts @@ -6,14 +6,29 @@ * Side Public License, v 1. */ -import { Filter, isPhrasesFilter } from '../../../../../common'; +import { Filter, FilterValueFormatter, isPhrasesFilter } from '../../../../../common'; + +const getFormattedValueFn = (params: any) => { + return (formatter?: FilterValueFormatter) => { + return params + .map((v: any) => { + return formatter ? formatter.convert(v) : v; + }) + .join(', '); + }; +}; export const mapPhrases = (filter: Filter) => { if (!isPhrasesFilter(filter)) { throw filter; } - const { type, key, value, params } = filter.meta; + const { type, key, params } = filter.meta; - return { type, key, value, params }; + return { + type, + key, + value: getFormattedValueFn(params), + params, + }; }; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 25d080dbfd546..80171e1ad2fab 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -69,6 +69,7 @@ export { EmbeddablePackageState, EmbeddableRenderer, EmbeddableRendererProps, + useEmbeddableFactory, } from './lib'; export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service'; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx index 457852c48ed77..b919672ad01e3 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx @@ -9,14 +9,39 @@ import React from 'react'; import { waitFor } from '@testing-library/dom'; import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; import { HelloWorldEmbeddable, HelloWorldEmbeddableFactoryDefinition, HELLO_WORLD_EMBEDDABLE, } from '../../tests/fixtures'; -import { EmbeddableRenderer } from './embeddable_renderer'; +import { EmbeddableRenderer, useEmbeddableFactory } from './embeddable_renderer'; import { embeddablePluginMock } from '../../mocks'; +describe('useEmbeddableFactory', () => { + it('should update upstream value changes', async () => { + const { setup, doStart } = embeddablePluginMock.createInstance(); + const getFactory = setup.registerEmbeddableFactory( + HELLO_WORLD_EMBEDDABLE, + new HelloWorldEmbeddableFactoryDefinition() + ); + doStart(); + + const { result, waitForNextUpdate } = renderHook(() => + useEmbeddableFactory({ factory: getFactory(), input: { id: 'hello' } }) + ); + + const [, loading] = result.current; + + expect(loading).toBe(true); + + await waitForNextUpdate(); + + const [embeddable] = result.current; + expect(embeddable).toBeDefined(); + }); +}); + describe('', () => { test('Render embeddable', () => { const embeddable = new HelloWorldEmbeddable({ id: 'hello' }); diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx index 153564187d4b5..433b21e92cce5 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx @@ -28,12 +28,6 @@ interface EmbeddableRendererPropsWithEmbeddable { embeddable: IEmbeddable; } -function isWithEmbeddable( - props: EmbeddableRendererProps -): props is EmbeddableRendererPropsWithEmbeddable { - return 'embeddable' in props; -} - interface EmbeddableRendererWithFactory { input: I; onInputUpdated?: (newInput: I) => void; @@ -46,6 +40,72 @@ function isWithFactory( return 'factory' in props; } +export function useEmbeddableFactory({ + input, + factory, + onInputUpdated, +}: EmbeddableRendererWithFactory) { + const [embeddable, setEmbeddable] = useState | ErrorEmbeddable | undefined>( + undefined + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const latestInput = React.useRef(input); + useEffect(() => { + latestInput.current = input; + }, [input]); + + useEffect(() => { + let canceled = false; + + // keeping track of embeddables created by this component to be able to destroy them + let createdEmbeddableRef: IEmbeddable | ErrorEmbeddable | undefined; + setEmbeddable(undefined); + setLoading(true); + factory + .create(latestInput.current!) + .then((createdEmbeddable) => { + if (canceled) { + if (createdEmbeddable) { + createdEmbeddable.destroy(); + } + } else { + createdEmbeddableRef = createdEmbeddable; + setEmbeddable(createdEmbeddable); + } + }) + .catch((err) => { + if (canceled) return; + setError(err?.message); + }) + .finally(() => { + if (canceled) return; + setLoading(false); + }); + + return () => { + canceled = true; + if (createdEmbeddableRef) { + createdEmbeddableRef.destroy(); + } + }; + }, [factory]); + + useEffect(() => { + if (!embeddable) return; + if (isErrorEmbeddable(embeddable)) return; + if (!onInputUpdated) return; + const sub = embeddable.getInput$().subscribe((newInput) => { + onInputUpdated(newInput); + }); + return () => { + sub.unsubscribe(); + }; + }, [embeddable, onInputUpdated]); + + return [embeddable, loading, error] as const; +} + /** * Helper react component to render an embeddable * Can be used if you have an embeddable object or an embeddable factory @@ -82,72 +142,22 @@ function isWithFactory( export const EmbeddableRenderer = ( props: EmbeddableRendererProps ) => { - const { input, onInputUpdated } = props; - const [embeddable, setEmbeddable] = useState | ErrorEmbeddable | undefined>( - isWithEmbeddable(props) ? props.embeddable : undefined - ); - const [loading, setLoading] = useState(!isWithEmbeddable(props)); - const [error, setError] = useState(); - const latestInput = React.useRef(props.input); - useEffect(() => { - latestInput.current = input; - }, [input]); - - const factoryFromProps = isWithFactory(props) ? props.factory : undefined; - const embeddableFromProps = isWithEmbeddable(props) ? props.embeddable : undefined; - useEffect(() => { - let canceled = false; - if (embeddableFromProps) { - setEmbeddable(embeddableFromProps); - return; - } - - // keeping track of embeddables created by this component to be able to destroy them - let createdEmbeddableRef: IEmbeddable | ErrorEmbeddable | undefined; - if (factoryFromProps) { - setEmbeddable(undefined); - setLoading(true); - factoryFromProps - .create(latestInput.current!) - .then((createdEmbeddable) => { - if (canceled) { - if (createdEmbeddable) { - createdEmbeddable.destroy(); - } - } else { - createdEmbeddableRef = createdEmbeddable; - setEmbeddable(createdEmbeddable); - } - }) - .catch((err) => { - if (canceled) return; - setError(err?.message); - }) - .finally(() => { - if (canceled) return; - setLoading(false); - }); - } - - return () => { - canceled = true; - if (createdEmbeddableRef) { - createdEmbeddableRef.destroy(); - } - }; - }, [factoryFromProps, embeddableFromProps]); - - useEffect(() => { - if (!embeddable) return; - if (isErrorEmbeddable(embeddable)) return; - if (!onInputUpdated) return; - const sub = embeddable.getInput$().subscribe((newInput) => { - onInputUpdated(newInput); - }); - return () => { - sub.unsubscribe(); - }; - }, [embeddable, onInputUpdated]); + if (isWithFactory(props)) { + return ; + } + return ; +}; +// +const EmbeddableByFactory = ({ + factory, + input, + onInputUpdated, +}: EmbeddableRendererWithFactory) => { + const [embeddable, loading, error] = useEmbeddableFactory({ + factory, + input, + onInputUpdated, + }); return ; }; diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index 71dfd73e534e7..eede745f31794 100644 --- a/src/plugins/embeddable/public/lib/embeddables/index.ts +++ b/src/plugins/embeddable/public/lib/embeddables/index.ts @@ -16,4 +16,8 @@ export { ErrorEmbeddable, isErrorEmbeddable } from './error_embeddable'; export { withEmbeddableSubscription } from './with_subscription'; export { EmbeddableRoot } from './embeddable_root'; export * from '../../../common/lib/saved_object_embeddable'; -export { EmbeddableRenderer, EmbeddableRendererProps } from './embeddable_renderer'; +export { + EmbeddableRenderer, + EmbeddableRendererProps, + useEmbeddableFactory, +} from './embeddable_renderer'; diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 22001608f63ab..1b3e0388e9bb0 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -542,3 +542,40 @@ test('Check when hide header option is true', async () => { const title = findTestSubject(component, `embeddablePanelHeading-HelloAryaStark`); expect(title.length).toBe(0); }); + +test('Should work in minimal way rendering only the inspector action', async () => { + const inspector = inspectorPluginMock.createStartContract(); + inspector.isAvailable = jest.fn(() => true); + + const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, { + getEmbeddableFactory, + } as any); + + const embeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Arya', + lastName: 'Stark', + }); + + const component = mount( + + Promise.resolve([])} + inspector={inspector} + hideHeader={false} + /> + + ); + + findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); + expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1); + await nextTick(); + component.update(); + expect(findTestSubject(component, `embeddablePanelAction-openInspector`).length).toBe(1); + const action = findTestSubject(component, `embeddablePanelAction-ACTION_CUSTOMIZE_PANEL`); + expect(action.length).toBe(0); +}); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 8cf2de8c80743..b66950c170d69 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -54,16 +54,20 @@ const removeById = (disabledActions: string[]) => ({ id }: { id: string }) => interface Props { embeddable: IEmbeddable; getActions: UiActionsService['getTriggerCompatibleActions']; - getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; - getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories']; - overlays: CoreStart['overlays']; - notifications: CoreStart['notifications']; - application: CoreStart['application']; - inspector: InspectorStartContract; - SavedObjectFinder: React.ComponentType; + getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory']; + getAllEmbeddableFactories?: EmbeddableStart['getEmbeddableFactories']; + overlays?: CoreStart['overlays']; + notifications?: CoreStart['notifications']; + application?: CoreStart['application']; + inspector?: InspectorStartContract; + SavedObjectFinder?: React.ComponentType; stateTransfer?: EmbeddableStateTransfer; hideHeader?: boolean; + actionPredicate?: (actionId: string) => boolean; reportUiCounter?: UsageCollectionStart['reportUiCounter']; + showShadow?: boolean; + showBadges?: boolean; + showNotifications?: boolean; } interface State { @@ -80,7 +84,11 @@ interface State { errorEmbeddable?: ErrorEmbeddable; } -interface PanelUniversalActions { +interface InspectorPanelAction { + inspectPanel: InspectPanelAction; +} + +interface BasePanelActions { customizePanelTitle: CustomizePanelTitleAction; addPanel: AddPanelAction; inspectPanel: InspectPanelAction; @@ -88,6 +96,15 @@ interface PanelUniversalActions { editPanel: EditPanelAction; } +const emptyObject = {}; +type EmptyObject = typeof emptyObject; + +type PanelUniversalActions = + | BasePanelActions + | InspectorPanelAction + | (BasePanelActions & InspectorPanelAction) + | EmptyObject; + export class EmbeddablePanel extends React.Component { private embeddableRoot: React.RefObject; private parentSubscription?: Subscription; @@ -117,10 +134,15 @@ export class EmbeddablePanel extends React.Component { } private async refreshBadges() { + if (!this.mounted) { + return; + } + if (this.props.showBadges === false) { + return; + } let badges = await this.props.getActions(PANEL_BADGE_TRIGGER, { embeddable: this.props.embeddable, }); - if (!this.mounted) return; const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { @@ -135,10 +157,15 @@ export class EmbeddablePanel extends React.Component { } private async refreshNotifications() { + if (!this.mounted) { + return; + } + if (this.props.showNotifications === false) { + return; + } let notifications = await this.props.getActions(PANEL_NOTIFICATION_TRIGGER, { embeddable: this.props.embeddable, }); - if (!this.mounted) return; const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { @@ -229,13 +256,18 @@ export class EmbeddablePanel extends React.Component { paddingSize="none" role="figure" aria-labelledby={headerId} + hasShadow={this.props.showShadow} > {!this.props.hideHeader && ( { }; private getUniversalActions = (): PanelUniversalActions => { + let actions = {}; + if (this.props.inspector) { + actions = { + inspectPanel: new InspectPanelAction(this.props.inspector), + }; + } + if ( + !this.props.getEmbeddableFactory || + !this.props.getAllEmbeddableFactories || + !this.props.overlays || + !this.props.notifications || + !this.props.SavedObjectFinder || + !this.props.application + ) { + return actions; + } + const createGetUserData = (overlays: OverlayStart) => async function getUserData(context: { embeddable: IEmbeddable }) { return new Promise<{ title: string | undefined; hideTitle?: boolean }>((resolve) => { @@ -308,6 +357,7 @@ export class EmbeddablePanel extends React.Component { // Universal actions are exposed on the context menu for every embeddable, they bypass the trigger // registry. return { + ...actions, customizePanelTitle: new CustomizePanelTitleAction(createGetUserData(this.props.overlays)), addPanel: new AddPanelAction( this.props.getEmbeddableFactory, @@ -317,7 +367,6 @@ export class EmbeddablePanel extends React.Component { this.props.SavedObjectFinder, this.props.reportUiCounter ), - inspectPanel: new InspectPanelAction(this.props.inspector), removePanel: new RemovePanelAction(), editPanel: new EditPanelAction( this.props.getEmbeddableFactory, @@ -338,9 +387,13 @@ export class EmbeddablePanel extends React.Component { regularActions = regularActions.filter(removeDisabledActions); } - const sortedActions = [...regularActions, ...Object.values(this.state.universalActions)].sort( - sortByOrderField - ); + let sortedActions = regularActions + .concat(Object.values(this.state.universalActions || {}) as Array>) + .sort(sortByOrderField); + + if (this.props.actionPredicate) { + sortedActions = sortedActions.filter(({ id }) => this.props.actionPredicate!(id)); + } return await buildContextMenuForActions({ actions: sortedActions.map((action) => ({ diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 403aa3e3f4c9f..742a2d1909941 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -36,7 +36,7 @@ export interface PanelHeaderProps { embeddable: IEmbeddable; headerId?: string; showPlaceholderTitle?: boolean; - customizeTitle: CustomizePanelTitleAction; + customizeTitle?: CustomizePanelTitleAction; } function renderBadges(badges: Array>, embeddable: IEmbeddable) { @@ -177,7 +177,7 @@ export function PanelHeader({ > {title || placeholderTitle} - ) : ( + ) : customizeTitle ? ( {title || placeholderTitle} - ); + ) : null; } return description ? ( ({ input, factory, onInputUpdated, }: EmbeddableRendererWithFactory): readonly [ErrorEmbeddable | IEmbeddable | undefined, boolean, string | undefined]; + // Warning: (ae-missing-release-tag) "VALUE_CLICK_TRIGGER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap index b949fa7995d30..153438b34eb47 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap @@ -1,819 +1,707 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`home change home route should render a link to change the default route in advanced settings if advanced settings is enabled 1`] = ` -
- + />, + "rightSideItems": Array [], } - /> -
+ - - - - - - - - -
-
+ + + + + + + +