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:
+
+ -
+
+ remark-parse
+
+
+ -
+
+ additional pre-processing for code blocks
+
+
+ -
+
+ remark-emoji
+
+
+ -
+
+ remark-breaks
+
+
+ -
+
+ link validation for security
+
+
+ -
+
+ injection of EuiCheckbox for markdown check boxes
+
+
+ -
+
+ tooltip plugin parser
+
+
+
+
+ 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:
+
+
+ -
+
+ remark-rehype
+
+
+ -
+
+ rehype-react
+
+
+ -
+
+ tooltip plugin renderer
+
+
+
+
+ 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:
+
+
+ -
+
+ tooltip plugin ui
+
+
+
+
+ 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