diff --git a/src-docs/src/views/markdown_editor/mardown_format_example.js b/src-docs/src/views/markdown_editor/mardown_format_example.js index dce19cfe922..45f27331f01 100644 --- a/src-docs/src/views/markdown_editor/mardown_format_example.js +++ b/src-docs/src/views/markdown_editor/mardown_format_example.js @@ -6,6 +6,7 @@ import { EuiMarkdownFormat, EuiText, EuiCode, + EuiCodeBlock, } from '../../../../src/components'; import { Link } from 'react-router-dom'; @@ -16,6 +17,9 @@ const markdownFormatSource = require('!!raw-loader!./markdown_format'); import MarkdownFormatStyles from './markdown_format_styles'; const markdownFormatStylesSource = require('!!raw-loader!./markdown_format_styles'); +import MarkdownFormatLinks, { markdownContent } from './markdown_format_links'; +const markdownFormatLinksSource = require('!!raw-loader!./markdown_format_links'); + import MarkdownFormatSink from './markdown_format_sink'; const markdownFormatSinkSource = require('!!raw-loader!./markdown_format_sink'); @@ -87,6 +91,31 @@ export const MarkdownFormatExample = { }, demo: , }, + { + source: [ + { + type: GuideSectionTypes.JS, + code: markdownFormatLinksSource, + }, + ], + title: 'Link validation for security', + text: ( + <> +

+ Markdown content often comes from untrusted sources like user + generated content. To help with potential security issues,{' '} + EuiMarkdownRenderer only renders links if they + begin with https:, http:,{' '} + mailto:, or /. +

+ {markdownContent} + + ), + props: { + EuiMarkdownFormat, + }, + demo: , + }, { source: [ { diff --git a/src-docs/src/views/markdown_editor/markdown_format_links.js b/src-docs/src/views/markdown_editor/markdown_format_links.js new file mode 100644 index 00000000000..2b28d6bd188 --- /dev/null +++ b/src-docs/src/views/markdown_editor/markdown_format_links.js @@ -0,0 +1,25 @@ +import React from 'react'; + +import { EuiMarkdownFormat } from '../../../../src'; + +// eslint-disable-next-line no-restricted-globals +const locationPathname = location.pathname; + +export const markdownContent = `**Links starting with http:, https:, mailto:, and / are valid:** + +* https://elastic.com +* http://elastic.com +* https link to [elastic.co](https://elastic.co) +* http link to [elastic.co](http://elastic.co) +* relative link to [eui doc's homepage](${locationPathname}) +* someone@elastic.co +* [email me!](mailto:someone@elastic.co) + +**Other link protocols are kept as their markdown source:** +* ftp://elastic.co +* An [ftp link](ftp://elastic.co) +`; + +export default () => { + return {markdownContent}; +}; diff --git a/src-docs/src/views/markdown_editor/markdown_link_validation.js b/src-docs/src/views/markdown_editor/markdown_link_validation.js new file mode 100644 index 00000000000..ee6185544d8 --- /dev/null +++ b/src-docs/src/views/markdown_editor/markdown_link_validation.js @@ -0,0 +1,34 @@ +import React from 'react'; + +import { + getDefaultEuiMarkdownParsingPlugins, + euiMarkdownLinkValidator, + EuiMarkdownFormat, +} from '../../../../src/components'; + +// find the validation plugin and configure it to only allow https: and mailto: links +const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); +parsingPlugins.find(([plugin, config]) => { + const isValidationPlugin = plugin === euiMarkdownLinkValidator; + if (isValidationPlugin) { + config.allowProtocols = ['https:', 'mailto:']; + } + return isValidationPlugin; +}); + +const markdown = `**Standalone links** +https://example.com +http://example.com +someone@example.com + +**As markdown syntax** +[example.com, https](https://example.com) +[example.com, http](http://example.com) +[email someone@example.com](mailto:someone@example.com) +`; + +export default () => ( + + {markdown} + +); diff --git a/src-docs/src/views/markdown_editor/markdown_plugin_example.js b/src-docs/src/views/markdown_editor/markdown_plugin_example.js index dd8f26f7675..a70ae2539dd 100644 --- a/src-docs/src/views/markdown_editor/markdown_plugin_example.js +++ b/src-docs/src/views/markdown_editor/markdown_plugin_example.js @@ -19,6 +19,9 @@ import { Link } from 'react-router-dom'; import MarkdownEditorWithPlugins from './markdown_editor_with_plugins'; const markdownEditorWithPluginsSource = require('!!raw-loader!./markdown_editor_with_plugins'); +const linkValidationSource = require('!!raw-loader!./markdown_link_validation'); +import LinkValidation from './markdown_link_validation'; + const pluginSnippet = ` +

+ EUI provides additional plugins by default, but these can be omitted + or otherwise customized by providing the{' '} + parsingPluginList,{' '} + processingPluginList, and{' '} + uiPlugins props to the editor and formatter + components. +

+

The parsing plugins, responsible for parsing markdown are:

+
    +
  1. + + remark-parse + +
  2. +
  3. + + additional pre-processing for code blocks + +
  4. +
  5. + + remark-emoji + +
  6. +
  7. + + remark-breaks + +
  8. +
  9. + + link validation for security + +
  10. +
  11. + + injection of EuiCheckbox for markdown check boxes + +
  12. +
  13. + + tooltip plugin parser + +
  14. +
+

+ The above set provides an abstract syntax tree used by the editor to + provide feedback, and the renderer passes that output to the set of + processing plugins to allow it to be rendered: +

+
    +
  1. + + remark-rehype + +
  2. +
  3. + + rehype-react + +
  4. +
  5. + + tooltip plugin renderer + +
  6. +
+

+ The last set of plugin configuration - uiPlugins{' '} + - allows toolbar buttons to be defined and how they alter or inject + markdown and returns with only one plugin: +

+
    +
  1. + + tooltip plugin ui + +
  2. +
+

+ These plugin definitions can be obtained by calling{' '} + getDefaultEuiMarkdownParsingPlugins,{' '} + getDefaultEuiMarkdownProcessingPlugins, and{' '} + getDefaultEuiMarkdownUiPlugins respectively. Each + of these three functions take an optional configuration object with + an exclude key, an array of EUI-defaulted plugins + to disable. Currently the only option this configuration can take is{' '} + 'tooltip'. +

+ + ), + }, + { + source: [ + { + type: GuideSectionTypes.JS, + code: linkValidationSource, + }, + ], + title: 'Link validation & security', + text: ( + +

+ To enhance user and application security, the default behavior + removes links to URLs that aren't relative (beginning with{' '} + /) and don't use the{' '} + https:, http:, or{' '} + mailto: protocols. This validation can be further + configured or removed altogether. +

+

+ In this example only https: and{' '} + mailto: links are allowed. +

+
+ ), + snippet: [ + `// change what link protocols are allowed +const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); +parsingPlugins.find(([plugin, config]) => { + const isValidationPlugin = plugin === euiMarkdownLinkValidator; + if (isValidationPlugin) { + config.allowProtocols = ['https:', 'mailto:']; + } + return isValidationPlugin; +});`, + `// filter out the link validation plugin +const parsingPlugins = getDefaultEuiMarkdownParsingPlugins().filter(([plugin]) => { + return plugin !== euiMarkdownLinkValidator; +});`, + `// disable relative urls +const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); +parsingPlugins.find(([plugin, config]) => { + const isValidationPlugin = plugin === euiMarkdownLinkValidator; + if (isValidationPlugin) { + config.allowRelative = false; + } + return isValidationPlugin; +});`, + ], + demo: , + }, { wrapText: false, text: ( diff --git a/src/components/markdown_editor/__snapshots__/markdown_editor.test.tsx.snap b/src/components/markdown_editor/__snapshots__/markdown_editor.test.tsx.snap index a3a9686f634..50ca6a7a9a5 100644 --- a/src/components/markdown_editor/__snapshots__/markdown_editor.test.tsx.snap +++ b/src/components/markdown_editor/__snapshots__/markdown_editor.test.tsx.snap @@ -29,7 +29,14 @@ exports[`EuiMarkdownEditor custom plugins are excluded and popover is rendered 1 ], Array [ [Function], - Object {}, + Object { + "allowProtocols": Array [ + "https:", + "http:", + "mailto:", + ], + "allowRelative": true, + }, ], Array [ [Function], diff --git a/src/components/markdown_editor/index.ts b/src/components/markdown_editor/index.ts index d3a62e4a6c0..d47f9ab3a72 100644 --- a/src/components/markdown_editor/index.ts +++ b/src/components/markdown_editor/index.ts @@ -26,3 +26,5 @@ export type { RemarkRehypeHandler, RemarkTokenizer, } from './markdown_types'; +export { euiMarkdownLinkValidator } from './plugins/markdown_link_validator'; +export type { EuiMarkdownLinkValidatorOptions } from './plugins/markdown_link_validator'; diff --git a/src/components/markdown_editor/plugins/markdown_default_plugins/parsing_plugins.ts b/src/components/markdown_editor/plugins/markdown_default_plugins/parsing_plugins.ts index 1afcb3c970b..d88c3625bf1 100644 --- a/src/components/markdown_editor/plugins/markdown_default_plugins/parsing_plugins.ts +++ b/src/components/markdown_editor/plugins/markdown_default_plugins/parsing_plugins.ts @@ -28,7 +28,10 @@ import breaks from 'remark-breaks'; import highlight from '../remark/remark_prismjs'; import * as MarkdownTooltip from '../markdown_tooltip'; import * as MarkdownCheckbox from '../markdown_checkbox'; -import { markdownLinkValidator } from '../markdown_link_validator'; +import { + euiMarkdownLinkValidator, + EuiMarkdownLinkValidatorOptions, +} from '../markdown_link_validator'; export type DefaultEuiMarkdownParsingPlugins = PluggableList; @@ -41,7 +44,13 @@ export const getDefaultEuiMarkdownParsingPlugins = ({ [highlight, {}], [emoji, { emoticon: false }], [breaks, {}], - [markdownLinkValidator, {}], + [ + euiMarkdownLinkValidator, + { + allowRelative: true, + allowProtocols: ['https:', 'http:', 'mailto:'], + } as EuiMarkdownLinkValidatorOptions, + ], [MarkdownCheckbox.parser, {}], ]; diff --git a/src/components/markdown_editor/plugins/markdown_link_validator.test.tsx b/src/components/markdown_editor/plugins/markdown_link_validator.test.tsx index a2318a468cf..9b0a156f39d 100644 --- a/src/components/markdown_editor/plugins/markdown_link_validator.test.tsx +++ b/src/components/markdown_editor/plugins/markdown_link_validator.test.tsx @@ -6,38 +6,56 @@ * Side Public License, v 1. */ -import { validateUrl, mutateLinkToText } from './markdown_link_validator'; +import { + validateUrl, + mutateLinkToText, + EuiMarkdownLinkValidatorOptions, +} from './markdown_link_validator'; import { validateHref } from '../../../services/security/href_validator'; +const defaultValidationOptions: EuiMarkdownLinkValidatorOptions = { + allowRelative: true, + allowProtocols: ['http:', 'https:', 'mailto:'], +}; + describe('validateURL', () => { it('approves of https:', () => { - expect(validateUrl('https:')).toBeTruthy(); + expect( + validateUrl('https://domain', defaultValidationOptions) + ).toBeTruthy(); }); it('approves of http:', () => { - expect(validateUrl('http:')).toBeTruthy(); + expect(validateUrl('http://domain', defaultValidationOptions)).toBeTruthy(); + }); + it('approves of mailto:', () => { + expect( + validateUrl('mailto:someone@elastic.co', defaultValidationOptions) + ).toBeTruthy(); }); it('approves of absolute relative links', () => { - expect(validateUrl('/')).toBeTruthy(); + expect(validateUrl('/', defaultValidationOptions)).toBeTruthy(); }); it('approves of relative protocols', () => { - expect(validateUrl('//')).toBeTruthy(); + expect(validateUrl('//', defaultValidationOptions)).toBeTruthy(); }); it('rejects a url starting with http with not an s following', () => { - expect(validateUrl('httpm:')).toBeFalsy(); + expect(validateUrl('httpm:', defaultValidationOptions)).toBeFalsy(); }); it('rejects a directory relative link', () => { - expect(validateUrl('./')).toBeFalsy(); - expect(validateUrl('../')).toBeFalsy(); + expect(validateUrl('./', defaultValidationOptions)).toBeFalsy(); + expect(validateUrl('../', defaultValidationOptions)).toBeFalsy(); }); it('rejects a word', () => { - expect(validateUrl('word')).toBeFalsy(); + expect(validateUrl('word', defaultValidationOptions)).toBeFalsy(); }); it('rejects gopher', () => { - expect(validateUrl('gopher:')).toBeFalsy(); + expect( + validateUrl('gopher://domain', defaultValidationOptions) + ).toBeFalsy(); }); it('rejects javascript', () => { // eslint-disable-next-line no-script-url - expect(validateUrl('javascript:')).toBeFalsy(); + expect(validateUrl('javascript:', defaultValidationOptions)).toBeFalsy(); // eslint-disable-next-line no-script-url expect(validateHref('javascript:alert()')).toBeFalsy(); }); @@ -72,4 +90,34 @@ describe('mutateLinkToText', () => { } `); }); + it('keeps only the link text when both text & url are the same value', () => { + expect( + mutateLinkToText({ + type: 'link', + url: 'ftp://www.example.com', + title: null, + children: [{ value: 'ftp://www.example.com' }], + }) + ).toMatchInlineSnapshot(` + Object { + "type": "text", + "value": "ftp://www.example.com", + } + `); + }); + it('renders with the markdown link syntax when link and url are not the same value', () => { + expect( + mutateLinkToText({ + type: 'link', + url: 'mailto:someone@elastic.co', + title: null, + children: [{ value: 'someone@elastic.co' }], + }) + ).toMatchInlineSnapshot(` + Object { + "type": "text", + "value": "[someone@elastic.co](mailto:someone@elastic.co)", + } + `); + }); }); diff --git a/src/components/markdown_editor/plugins/markdown_link_validator.tsx b/src/components/markdown_editor/plugins/markdown_link_validator.tsx index 4a6c7a9a079..4f23051fd13 100644 --- a/src/components/markdown_editor/plugins/markdown_link_validator.tsx +++ b/src/components/markdown_editor/plugins/markdown_link_validator.tsx @@ -16,12 +16,19 @@ interface LinkOrTextNode { children?: Array<{ value: string }>; } -export function markdownLinkValidator() { +export interface EuiMarkdownLinkValidatorOptions { + allowRelative: boolean; + allowProtocols: string[]; +} + +export function euiMarkdownLinkValidator( + options: EuiMarkdownLinkValidatorOptions +) { return (ast: any) => { visit(ast, 'link', (_node: unknown) => { const node = _node as LinkOrTextNode; - if (!validateUrl(node.url!)) { + if (!validateUrl(node.url!, options)) { mutateLinkToText(node); } }); @@ -29,15 +36,40 @@ export function markdownLinkValidator() { } export function mutateLinkToText(node: LinkOrTextNode) { + // this is an upsupported url, convert to a text node node.type = 'text'; - node.value = `[${node.children![0]?.value || ''}](${node.url})`; + + // and, if the link text matches the url there's only one value to show + // otherwise render as the markdown syntax so both text & url remain, unlinked + const linkText = node.children?.[0]?.value || ''; + const linkUrl = node.url ?? ''; + if (linkText === linkUrl) { + node.value = linkText; + } else { + node.value = `[${linkText}](${node.url})`; + } + delete node.children; delete node.title; delete node.url; return node; } -export function validateUrl(url: string) { - // A link is valid if it starts with http:, https:, or / - return /^(https?:|\/)/.test(url); +export function validateUrl( + url: string, + { allowRelative, allowProtocols }: EuiMarkdownLinkValidatorOptions +) { + // relative captures both relative paths `/` and protocols `//` + const isRelative = url.startsWith('/'); + if (isRelative) { + return allowRelative; + } + + try { + const parsedUrl = new URL(url); + return allowProtocols.indexOf(parsedUrl.protocol) !== -1; + } catch (e) { + // failed to parse input as url + return false; + } } diff --git a/src/services/url.test.ts b/src/services/url.test.ts index 95bc9d174e0..12bb1147129 100644 --- a/src/services/url.test.ts +++ b/src/services/url.test.ts @@ -18,12 +18,14 @@ describe('url', () => { expect(isDomainSecure('https://docs.elastic.co')).toEqual(true); expect(isDomainSecure('https://stats.elastic.co')).toEqual(true); expect(isDomainSecure('https://lots.of.kids.elastic.co')).toEqual(true); + expect(isDomainSecure('http://docs.elastic.co')).toEqual(true); expect( isDomainSecure('https://elastic.co/cool/url/with?lots=of¶ms') ).toEqual(true); }); it('returns false for unsecure domains', () => { + expect(isDomainSecure('asdfasdf')).toEqual(false); expect(isDomainSecure('https://wwwelastic.co')).toEqual(false); expect(isDomainSecure('https://www.zelastic.co')).toEqual(false); expect(isDomainSecure('https://*elastic.co')).toEqual(false); @@ -31,11 +33,13 @@ describe('url', () => { expect(isDomainSecure('https://elastic.co.now')).toEqual(false); expect(isDomainSecure('elastic.co')).toEqual(false); expect(isDomainSecure('smb://www.elastic.co')).toEqual(false); + expect(isDomainSecure('smb://www.elastic.co:443')).toEqual(false); expect( isDomainSecure( 'https://wwwelastic.co/cool/url/with?lots=of¶ms/https://elastic.co' ) ).toEqual(false); + expect(isDomainSecure('https://example.com/.elastic.co')).toEqual(false); }); }); }); diff --git a/src/services/url.ts b/src/services/url.ts index e9bb196abac..a61d5d2860c 100644 --- a/src/services/url.ts +++ b/src/services/url.ts @@ -6,19 +6,19 @@ * Side Public License, v 1. */ -const isElasticDomain = /(https?:\/\/(.+?\.)?elastic\.co((\/|\?)[A-Za-z0-9\-\._~:\/\?#\[\]@!$&'\(\)\*\+,;\=]*)?)/g; +const isElasticHost = /^([a-zA-Z0-9]+\.)*elastic\.co$/; -// In order for the domain to be secure the regex -// has to match _and_ the lengths of the match must -// be _exact_ since URL's can have other URL's as -// path or query params! +// In order for the domain to be secure it needs to be in a parsable format, +// with the protocol of http: or https: and the host matching elastic.co or +// of one its subdomains export const isDomainSecure = (url: string = '') => { - const matches = url.match(isElasticDomain); - - if (!matches) { + try { + const parsed = new URL(url); + const protocolMatches = + parsed.protocol === 'http:' || parsed.protocol === 'https:'; + const domainMatches = !!parsed.host.match(isElasticHost); + return protocolMatches && domainMatches; + } catch (e) { return false; } - const [match] = matches; - - return match.length === url.length; }; diff --git a/upcoming_changelogs/5790.md b/upcoming_changelogs/5790.md new file mode 100644 index 00000000000..9acf7310e1d --- /dev/null +++ b/upcoming_changelogs/5790.md @@ -0,0 +1,2 @@ +- Updated `EuiMarkdownFormat` to allow `mailto:` links by default +- Updated `EuiMarkdownEditor`'s `euiMarkdownLinkValidator` parsing plugin to allow customization of link validation