diff --git a/CHANGELOG.md b/CHANGELOG.md
index a92bb6859b5..0880a2e34c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@
- Fixed propagation of `esc` key presses closing parent popovers ([#4336](https://github.com/elastic/eui/pull/4336))
- Fixed overwritten `isDisabled` prop on `EuiListGroupItem` `extraAction` config ([#4359](https://github.com/elastic/eui/pull/4359))
- Fixed `inputRef` for `EuiCheckbox` ([#4298](https://github.com/elastic/eui/pull/4298))
+- Limited the links allowed in `EuiMarkdownEditor` to http, https, or starting with a forward slash ([#4362](https://github.com/elastic/eui/pull/4362))
+- Aligned components with an `href` prop to React's practice of disallowing `javascript:` protocols ([#4362](https://github.com/elastic/eui/pull/4362))
**Theme: Amsterdam**
diff --git a/package.json b/package.json
index 06b06fcd7a4..3a10fa446cb 100644
--- a/package.json
+++ b/package.json
@@ -78,6 +78,7 @@
"tabbable": "^3.0.0",
"text-diff": "^1.0.1",
"unified": "^9.2.0",
+ "url-parse": "^1.4.7",
"uuid": "^8.3.0",
"vfile": "^4.2.0"
},
@@ -108,6 +109,7 @@
"@types/react-router-dom": "^5.1.5",
"@types/resize-observer-browser": "^0.1.3",
"@types/tabbable": "^3.1.0",
+ "@types/url-parse": "^1.4.3",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^3.10.1",
"@typescript-eslint/parser": "^3.10.1",
diff --git a/src-docs/src/views/link/link_example.js b/src-docs/src/views/link/link_example.js
index 62ca51cf845..468a94648e0 100644
--- a/src-docs/src/views/link/link_example.js
+++ b/src-docs/src/views/link/link_example.js
@@ -10,6 +10,7 @@ import linkConfig from './playground';
import Link from './link';
import { LinkDisable } from './link_disable';
+import { LinkValidation } from './link_validation';
const linkSource = require('!!raw-loader!./link');
const linkHtml = renderToHtml(Link);
@@ -17,6 +18,9 @@ const linkHtml = renderToHtml(Link);
const linkDisableSource = require('!!raw-loader!./link_disable');
const linkDisableHtml = renderToHtml(LinkDisable);
+const linkValidationSource = require('!!raw-loader!./link_validation');
+const linkValidationHtml = renderToHtml(LinkValidation);
+
const linkSnippet = [
`
`,
@@ -77,6 +81,36 @@ export const LinkExample = {
props: { EuiLink },
demo: ,
},
+ {
+ title: 'Link validation',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: linkValidationSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: linkValidationHtml,
+ },
+ ],
+ text: (
+
+ To make links more secure for users, EuiLink and
+ other components that accept an href prop become
+ disabled if that href uses the{' '}
+ javascript: protocol. This helps protect consuming
+ applications from cross-site scripting (XSS) attacks and mirrors
+ React's{' '}
+
+ planned behavior
+ {' '}
+ to prevent rendering of javascript: links.
+
+ ),
+ demo: ,
+ },
],
playground: linkConfig,
};
diff --git a/src-docs/src/views/link/link_validation.js b/src-docs/src/views/link/link_validation.js
new file mode 100644
index 00000000000..356a902762c
--- /dev/null
+++ b/src-docs/src/views/link/link_validation.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import { EuiLink } from '../../../../src/components';
+
+const urls = [
+ 'https://elastic.co',
+ '//elastic.co',
+ 'relative/url/somewhere',
+ 'http://username:password@example.com/',
+ // eslint-disable-next-line no-script-url
+ 'javascript:alert()',
+];
+
+export const LinkValidation = () => {
+ return (
+ <>
+ {urls.map((url) => (
+
+
+ {url}
+
+
+ ))}
+ >
+ );
+};
diff --git a/src/components/badge/badge.tsx b/src/components/badge/badge.tsx
index b8b02947439..957c8dd3d3d 100644
--- a/src/components/badge/badge.tsx
+++ b/src/components/badge/badge.tsx
@@ -36,6 +36,7 @@ import {
import { EuiInnerText } from '../inner_text';
import { EuiIcon, IconColor, IconType } from '../icon';
import { chromaValid, parseColor } from '../color_picker/utils';
+import { validateHref } from '../../services/security/href_validator';
type IconSide = 'left' | 'right';
@@ -136,7 +137,7 @@ export const EuiBadge: FunctionComponent = ({
iconType,
iconSide = 'left',
className,
- isDisabled,
+ isDisabled: _isDisabled,
onClick,
iconOnClick,
onClickAriaLabel,
@@ -150,6 +151,9 @@ export const EuiBadge: FunctionComponent = ({
}) => {
checkValidColor(color);
+ const isHrefValid = !href || validateHref(href);
+ const isDisabled = _isDisabled || !isHrefValid;
+
let optionalCustomStyles: object | undefined = style;
let textColor = null;
// TODO - replace with variable once https://github.com/elastic/eui/issues/2731 is closed
@@ -215,6 +219,7 @@ export const EuiBadge: FunctionComponent = ({
'euiBadge__icon',
closeButtonProps && closeButtonProps.className
);
+
const Element = href && !isDisabled ? 'a' : 'button';
const relObj: {
href?: string;
diff --git a/src/components/basic_table/__snapshots__/in_memory_table.test.tsx.snap b/src/components/basic_table/__snapshots__/in_memory_table.test.tsx.snap
index 08f28f3d581..415efa62d7f 100644
--- a/src/components/basic_table/__snapshots__/in_memory_table.test.tsx.snap
+++ b/src/components/basic_table/__snapshots__/in_memory_table.test.tsx.snap
@@ -383,6 +383,7 @@ exports[`EuiInMemoryTable behavior pagination 1`] = `
diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx
index 7a07d67cf99..a587acf8e88 100644
--- a/src/components/button/button.tsx
+++ b/src/components/button/button.tsx
@@ -42,6 +42,7 @@ import {
EuiButtonContentType,
EuiButtonContent,
} from './button_content';
+import { validateHref } from '../../services/security/href_validator';
export type ButtonColor =
| 'primary'
@@ -250,8 +251,8 @@ export type Props = ExclusiveUnion<
>;
export const EuiButton: FunctionComponent = ({
- isDisabled,
- disabled,
+ isDisabled: _isDisabled,
+ disabled: _disabled,
href,
target,
rel,
@@ -259,6 +260,10 @@ export const EuiButton: FunctionComponent = ({
buttonRef,
...rest
}) => {
+ const isHrefValid = !href || validateHref(href);
+ const disabled = _disabled || !isHrefValid;
+ const isDisabled = _isDisabled || !isHrefValid;
+
const buttonIsDisabled = rest.isLoading || isDisabled || disabled;
const element = href && !isDisabled ? 'a' : 'button';
diff --git a/src/components/button/button_empty/button_empty.tsx b/src/components/button/button_empty/button_empty.tsx
index e42b92c5d58..c89dae37dcf 100644
--- a/src/components/button/button_empty/button_empty.tsx
+++ b/src/components/button/button_empty/button_empty.tsx
@@ -33,6 +33,7 @@ import {
EuiButtonContentProps,
EuiButtonContentType,
} from '../button_content';
+import { validateHref } from '../../../services/security/href_validator';
export type EuiButtonEmptyColor =
| 'primary'
@@ -126,8 +127,8 @@ export const EuiButtonEmpty: FunctionComponent = ({
color = 'primary',
size,
flush,
- isDisabled,
- disabled,
+ isDisabled: _isDisabled,
+ disabled: _disabled,
isLoading,
href,
target,
@@ -139,6 +140,10 @@ export const EuiButtonEmpty: FunctionComponent = ({
isSelected,
...rest
}) => {
+ const isHrefValid = !href || validateHref(href);
+ const disabled = _disabled || !isHrefValid;
+ const isDisabled = _isDisabled || !isHrefValid;
+
// If in the loading state, force disabled to true
const buttonIsDisabled = isLoading || isDisabled || disabled;
diff --git a/src/components/button/button_icon/button_icon.tsx b/src/components/button/button_icon/button_icon.tsx
index 95d76cbe5b4..df43b17c6f7 100644
--- a/src/components/button/button_icon/button_icon.tsx
+++ b/src/components/button/button_icon/button_icon.tsx
@@ -37,6 +37,7 @@ import {
import { IconType, IconSize, EuiIcon } from '../../icon';
import { ButtonSize } from '../button';
+import { validateHref } from '../../../services/security/href_validator';
export type EuiButtonIconColor =
| 'accent'
@@ -104,7 +105,7 @@ export const EuiButtonIcon: FunctionComponent = ({
iconType,
iconSize = 'm',
color = 'primary',
- isDisabled,
+ isDisabled: _isDisabled,
href,
type = 'button',
target,
@@ -113,6 +114,9 @@ export const EuiButtonIcon: FunctionComponent = ({
isSelected,
...rest
}) => {
+ const isHrefValid = !href || validateHref(href);
+ const isDisabled = _isDisabled || !isHrefValid;
+
const ariaHidden = rest['aria-hidden'];
const isAriaHidden = ariaHidden === 'true' || ariaHidden === true;
diff --git a/src/components/card/card.tsx b/src/components/card/card.tsx
index 8bb054d8672..c73aae897f6 100644
--- a/src/components/card/card.tsx
+++ b/src/components/card/card.tsx
@@ -37,6 +37,7 @@ import {
euiCardSelectableColor,
} from './card_select';
import { htmlIdGenerator } from '../../services/accessibility';
+import { validateHref } from '../../services/security/href_validator';
type CardAlignment = 'left' | 'center' | 'right';
@@ -176,7 +177,7 @@ export const SIZES = keysOf(paddingSizeToClassNameMap);
export const EuiCard: FunctionComponent = ({
className,
description,
- isDisabled,
+ isDisabled: _isDisabled,
title,
titleElement = 'span',
titleSize = 's',
@@ -198,6 +199,9 @@ export const EuiCard: FunctionComponent = ({
paddingSize = 'm',
...rest
}) => {
+ const isHrefValid = !href || validateHref(href);
+ const isDisabled = _isDisabled || !isHrefValid;
+
/**
* For a11y, we simulate the same click that's provided on the title when clicking the whole card
* without having to make the whole card a button or anchor tag.
diff --git a/src/components/context_menu/__snapshots__/context_menu_panel.test.tsx.snap b/src/components/context_menu/__snapshots__/context_menu_panel.test.tsx.snap
index e69be2472d8..6e7aa554036 100644
--- a/src/components/context_menu/__snapshots__/context_menu_panel.test.tsx.snap
+++ b/src/components/context_menu/__snapshots__/context_menu_panel.test.tsx.snap
@@ -449,7 +449,7 @@ exports[`EuiContextMenuPanel updating items and content updates to items should
-
+
Option A
@@ -458,7 +458,7 @@ exports[`EuiContextMenuPanel updating items and content updates to items should
-
+
Option B
@@ -480,7 +480,7 @@ exports[`EuiContextMenuPanel updating items and content updates to items should
-
+
Option A
@@ -489,7 +489,7 @@ exports[`EuiContextMenuPanel updating items and content updates to items should
-
+
Option B
@@ -539,7 +539,7 @@ exports[`EuiContextMenuPanel updating items and content updates to items should
-
+
Option A
@@ -548,7 +548,7 @@ exports[`EuiContextMenuPanel updating items and content updates to items should
-
+
Option B
@@ -570,7 +570,7 @@ exports[`EuiContextMenuPanel updating items and content updates to items should
-
+
Option A
@@ -579,7 +579,7 @@ exports[`EuiContextMenuPanel updating items and content updates to items should
-
+
Option B
diff --git a/src/components/context_menu/context_menu_item.tsx b/src/components/context_menu/context_menu_item.tsx
index a3e2d457fe7..d115e35384d 100644
--- a/src/components/context_menu/context_menu_item.tsx
+++ b/src/components/context_menu/context_menu_item.tsx
@@ -33,6 +33,7 @@ import { EuiIcon } from '../icon';
import { EuiToolTip, ToolTipPositions } from '../tool_tip';
import { getSecureRelForTarget } from '../../services';
+import { validateHref } from '../../services/security/href_validator';
export type EuiContextMenuItemIcon = ReactElement | string | HTMLElement;
@@ -90,7 +91,7 @@ export class EuiContextMenuItem extends Component {
hasPanel,
icon,
buttonRef,
- disabled,
+ disabled: _disabled,
layoutAlign = 'center',
toolTipTitle,
toolTipContent,
@@ -102,6 +103,9 @@ export class EuiContextMenuItem extends Component {
} = this.props;
let iconInstance;
+ const isHrefValid = !href || validateHref(href);
+ const disabled = _disabled || !isHrefValid;
+
if (icon) {
switch (typeof icon) {
case 'string':
diff --git a/src/components/control_bar/__snapshots__/control_bar.test.tsx.snap b/src/components/control_bar/__snapshots__/control_bar.test.tsx.snap
index 47388bb5679..442d43367cd 100644
--- a/src/components/control_bar/__snapshots__/control_bar.test.tsx.snap
+++ b/src/components/control_bar/__snapshots__/control_bar.test.tsx.snap
@@ -304,7 +304,9 @@ exports[`EuiControlBar props leftOffset is rendered 1`] = `
className="euiControlBar__button"
color="ghost"
data-test-subj="dts"
+ disabled={false}
element="button"
+ isDisabled={false}
onClick={[Function]}
size="s"
type="button"
@@ -312,6 +314,7 @@ exports[`EuiControlBar props leftOffset is rendered 1`] = `
& {
@@ -53,9 +54,10 @@ export const EuiHeaderLogo: FunctionComponent = ({
}) => {
const classes = classNames('euiHeaderLogo', className);
const secureRel = getSecureRelForTarget({ href, rel, target });
+ const isHrefValid = !href || validateHref(href);
return (
= ({
- isDisabled,
+ isDisabled: _isDisabled,
label,
children,
className,
@@ -106,6 +107,9 @@ export const EuiKeyPadMenuItem: FunctionComponent = ({
target,
...rest
}) => {
+ const isHrefValid = !href || validateHref(href);
+ const isDisabled = _isDisabled || !isHrefValid;
+
const classes = classNames(
'euiKeyPadMenuItem',
{
diff --git a/src/components/link/link.tsx b/src/components/link/link.tsx
index c1147511cd7..cc4cb87738b 100644
--- a/src/components/link/link.tsx
+++ b/src/components/link/link.tsx
@@ -29,6 +29,7 @@ import { EuiI18n, useEuiI18n } from '../i18n';
import { CommonProps, ExclusiveUnion, keysOf } from '../common';
import { getSecureRelForTarget } from '../../services';
import { EuiScreenReaderOnly } from '../accessibility';
+import { validateHref } from '../../services/security/href_validator';
export type EuiLinkType = 'button' | 'reset' | 'submit';
export type EuiLinkColor =
@@ -99,11 +100,14 @@ const EuiLink = forwardRef(
rel,
type = 'button',
onClick,
- disabled,
+ disabled: _disabled,
...rest
},
ref
) => {
+ const isHrefValid = !href || validateHref(href);
+ const disabled = _disabled || !isHrefValid;
+
const externalLinkIcon = (
(
);
- if (href === undefined) {
+ if (href === undefined || !isHrefValid) {
const buttonProps = {
className: classNames(
'euiLink',
diff --git a/src/components/list_group/list_group_item.tsx b/src/components/list_group/list_group_item.tsx
index ea22d918bc6..311858b2ff9 100644
--- a/src/components/list_group/list_group_item.tsx
+++ b/src/components/list_group/list_group_item.tsx
@@ -36,6 +36,7 @@ import { useInnerText } from '../inner_text';
import { ExclusiveUnion, CommonProps } from '../common';
import { getSecureRelForTarget } from '../../services';
+import { validateHref } from '../../services/security/href_validator';
type ItemSize = 'xs' | 's' | 'm' | 'l';
const sizeToClassNameMap: { [size in ItemSize]: string } = {
@@ -147,7 +148,7 @@ export type EuiListGroupItemProps = CommonProps &
export const EuiListGroupItem: FunctionComponent = ({
label,
isActive = false,
- isDisabled = false,
+ isDisabled: _isDisabled = false,
href,
target,
rel,
@@ -163,6 +164,9 @@ export const EuiListGroupItem: FunctionComponent = ({
buttonRef,
...rest
}) => {
+ const isHrefValid = !href || validateHref(href);
+ const isDisabled = _isDisabled || !isHrefValid;
+
const classes = classNames(
'euiListGroupItem',
sizeToClassNameMap[size],
diff --git a/src/components/markdown_editor/plugins/markdown_default_plugins.tsx b/src/components/markdown_editor/plugins/markdown_default_plugins.tsx
index db8e4b8a00e..68e7804b3fd 100644
--- a/src/components/markdown_editor/plugins/markdown_default_plugins.tsx
+++ b/src/components/markdown_editor/plugins/markdown_default_plugins.tsx
@@ -22,6 +22,7 @@ import remark2rehype from 'remark-rehype';
import rehype2react from 'rehype-react';
import * as MarkdownTooltip from './markdown_tooltip';
import * as MarkdownCheckbox from './markdown_checkbox';
+import { markdownLinkValidator } from './markdown_link_validator';
import React, { createElement } from 'react';
import { EuiLink } from '../../link';
import { EuiCodeBlock, EuiCode } from '../../code';
@@ -37,6 +38,7 @@ export const getDefaultEuiMarkdownParsingPlugins = (): PluggableList => [
[emoji, { emoticon: true }],
[MarkdownTooltip.parser, {}],
[MarkdownCheckbox.parser, {}],
+ [markdownLinkValidator, {}],
];
export const defaultParsingPlugins = getDefaultEuiMarkdownParsingPlugins();
diff --git a/src/components/markdown_editor/plugins/markdown_link_validator.test.tsx b/src/components/markdown_editor/plugins/markdown_link_validator.test.tsx
new file mode 100644
index 00000000000..2b394fa3212
--- /dev/null
+++ b/src/components/markdown_editor/plugins/markdown_link_validator.test.tsx
@@ -0,0 +1,86 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { validateUrl, mutateLinkToText } from './markdown_link_validator';
+import { validateHref } from '../../../services/security/href_validator';
+
+describe('validateURL', () => {
+ it('approves of https:', () => {
+ expect(validateUrl('https:')).toBeTruthy();
+ });
+ it('approves of http:', () => {
+ expect(validateUrl('http:')).toBeTruthy();
+ });
+ it('approves of absolute relative links', () => {
+ expect(validateUrl('/')).toBeTruthy();
+ });
+ it('approves of relative protocols', () => {
+ expect(validateUrl('//')).toBeTruthy();
+ });
+ it('rejects a url starting with http with not an s following', () => {
+ expect(validateUrl('httpm:')).toBeFalsy();
+ });
+ it('rejects a directory relative link', () => {
+ expect(validateUrl('./')).toBeFalsy();
+ expect(validateUrl('../')).toBeFalsy();
+ });
+ it('rejects a word', () => {
+ expect(validateUrl('word')).toBeFalsy();
+ });
+ it('rejects gopher', () => {
+ expect(validateUrl('gopher:')).toBeFalsy();
+ });
+ it('rejects javascript', () => {
+ // eslint-disable-next-line no-script-url
+ expect(validateUrl('javascript:')).toBeFalsy();
+ // eslint-disable-next-line no-script-url
+ expect(validateHref('javascript:alert()')).toBeFalsy();
+ });
+});
+
+describe('mutateLinkToText', () => {
+ it('mutates', () => {
+ expect(
+ mutateLinkToText({
+ type: 'link',
+ url: 'https://cats.com',
+ title: null,
+ children: [{ value: 'Cats' }],
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "type": "text",
+ "value": "[Cats](https://cats.com)",
+ }
+ `);
+ expect(
+ mutateLinkToText({
+ type: 'link',
+ url: 'https://cats.com',
+ title: null,
+ children: [],
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "type": "text",
+ "value": "[](https://cats.com)",
+ }
+ `);
+ });
+});
diff --git a/src/components/markdown_editor/plugins/markdown_link_validator.tsx b/src/components/markdown_editor/plugins/markdown_link_validator.tsx
new file mode 100644
index 00000000000..9b10919c423
--- /dev/null
+++ b/src/components/markdown_editor/plugins/markdown_link_validator.tsx
@@ -0,0 +1,54 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import visit from 'unist-util-visit';
+
+interface LinkOrTextNode {
+ type: string;
+ url?: string;
+ title?: string | null;
+ value?: string;
+ children?: Array<{ value: string }>;
+}
+
+export function markdownLinkValidator() {
+ return (ast: any) => {
+ visit(ast, 'link', (_node: unknown) => {
+ const node = _node as LinkOrTextNode;
+
+ if (!validateUrl(node.url!)) {
+ mutateLinkToText(node);
+ }
+ });
+ };
+}
+
+export function mutateLinkToText(node: LinkOrTextNode) {
+ node.type = 'text';
+ node.value = `[${node.children![0]?.value || ''}](${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);
+}
diff --git a/src/components/side_nav/side_nav_item.tsx b/src/components/side_nav/side_nav_item.tsx
index 4549127e8ab..141d69cdcf5 100644
--- a/src/components/side_nav/side_nav_item.tsx
+++ b/src/components/side_nav/side_nav_item.tsx
@@ -30,6 +30,7 @@ import { CommonProps } from '../common';
import { EuiIcon } from '../icon';
import { getSecureRelForTarget } from '../../services';
+import { validateHref } from '../../services/security/href_validator';
type ItemProps = CommonProps & {
href?: string;
@@ -124,7 +125,7 @@ export function EuiSideNavItem<
isParent,
icon,
onClick,
- href,
+ href: _href,
rel,
target,
items,
@@ -134,6 +135,8 @@ export function EuiSideNavItem<
className,
...rest
}: EuiSideNavItemProps) {
+ const isHrefValid = !_href || validateHref(_href);
+ const href = isHrefValid ? _href : '';
let childItems;
if (items && isOpen) {
diff --git a/src/components/tabs/tab.tsx b/src/components/tabs/tab.tsx
index 91bcdee1783..dbdd47bd34a 100644
--- a/src/components/tabs/tab.tsx
+++ b/src/components/tabs/tab.tsx
@@ -26,6 +26,7 @@ import React, {
import classNames from 'classnames';
import { CommonProps, ExclusiveUnion } from '../common';
import { getSecureRelForTarget } from '../../services';
+import { validateHref } from '../../services/security/href_validator';
export interface EuiTabProps extends CommonProps {
isSelected?: boolean;
@@ -49,12 +50,15 @@ export const EuiTab: FunctionComponent = ({
isSelected,
children,
className,
- disabled,
+ disabled: _disabled,
href,
target,
rel,
...rest
}) => {
+ const isHrefValid = !href || validateHref(href);
+ const disabled = _disabled || !isHrefValid;
+
const classes = classNames('euiTab', className, {
'euiTab-isSelected': isSelected,
'euiTab-isDisabled': disabled,
diff --git a/src/components/tabs/tabbed_content/__snapshots__/tabbed_content.test.tsx.snap b/src/components/tabs/tabbed_content/__snapshots__/tabbed_content.test.tsx.snap
index b57764ff603..cd9bf61bcc7 100644
--- a/src/components/tabs/tabbed_content/__snapshots__/tabbed_content.test.tsx.snap
+++ b/src/components/tabs/tabbed_content/__snapshots__/tabbed_content.test.tsx.snap
@@ -95,6 +95,7 @@ exports[`EuiTabbedContent behavior when uncontrolled, the selected tab should up
aria-controls="generated-id"
aria-selected={false}
className="euiTab"
+ disabled={false}
id="es"
onClick={[Function]}
role="tab"
@@ -120,6 +121,7 @@ exports[`EuiTabbedContent behavior when uncontrolled, the selected tab should up
aria-selected={true}
className="euiTab euiTab-isSelected"
data-test-subj="kibanaTab"
+ disabled={false}
id="kibana"
onClick={[Function]}
role="tab"
diff --git a/src/services/security/href_validator.test.tsx b/src/services/security/href_validator.test.tsx
new file mode 100644
index 00000000000..5f85288a129
--- /dev/null
+++ b/src/services/security/href_validator.test.tsx
@@ -0,0 +1,57 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { validateHref } from './href_validator';
+
+describe('validateHref', () => {
+ it('approves of https:', () => {
+ expect(validateHref('https:')).toBeTruthy();
+ });
+ it('approves of http:', () => {
+ expect(validateHref('http:')).toBeTruthy();
+ });
+ it('approves of absolute relative hrefs', () => {
+ expect(validateHref('/')).toBeTruthy();
+ });
+ it('approves of relative protocols', () => {
+ expect(validateHref('//')).toBeTruthy();
+ });
+ it('approves of url starting with http with not an s following', () => {
+ expect(validateHref('httpm:')).toBeTruthy();
+ });
+ it('approves of directory relative href', () => {
+ expect(validateHref('./')).toBeTruthy();
+ expect(validateHref('../')).toBeTruthy();
+ });
+ it('approves of word', () => {
+ expect(validateHref('word')).toBeTruthy();
+ });
+ it('approves of gopher', () => {
+ expect(validateHref('gopher:')).toBeTruthy();
+ });
+ it('approves of authenticated hrefs', () => {
+ expect(validateHref('http://username:password@example.com/')).toBeTruthy();
+ });
+ it('rejects javascript', () => {
+ // eslint-disable-next-line no-script-url
+ expect(validateHref('javascript:')).toBeFalsy();
+ // eslint-disable-next-line no-script-url
+ expect(validateHref('javascript:alert()')).toBeFalsy();
+ });
+});
diff --git a/src/services/security/href_validator.tsx b/src/services/security/href_validator.tsx
new file mode 100644
index 00000000000..31d2477b399
--- /dev/null
+++ b/src/services/security/href_validator.tsx
@@ -0,0 +1,27 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import URL from 'url-parse';
+
+export function validateHref(href: string) {
+ // check href and treat it as invalid if it uses the javascript: protocol
+ const parts = new URL(href);
+ // eslint-disable-next-line no-script-url
+ return parts.protocol !== 'javascript:';
+}
diff --git a/yarn.lock b/yarn.lock
index df073effd24..da1379502d5 100755
--- a/yarn.lock
+++ b/yarn.lock
@@ -1857,6 +1857,11 @@
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
+"@types/url-parse@^1.4.3":
+ version "1.4.3"
+ resolved "https://registry.yarnpkg.com/@types/url-parse/-/url-parse-1.4.3.tgz#fba49d90f834951cb000a674efee3d6f20968329"
+ integrity sha512-4kHAkbV/OfW2kb5BLVUuUMoumB3CP8rHqlw48aHvFy5tf9ER0AfOonBlX29l/DD68G70DmyhRlSYfQPSYpC5Vw==
+
"@types/uuid@^8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f"
@@ -12932,10 +12937,10 @@ querystring@0.2.0:
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=
-querystringify@^2.0.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.0.tgz#7ded8dfbf7879dcc60d0a644ac6754b283ad17ef"
- integrity sha512-sluvZZ1YiTLD5jsqZcDmFyV2EwToyXZBfpoVOmktMmW+VEnhgakFHnasVph65fOjGPTWN0Nw3+XQaSeMayr0kg==
+querystringify@^2.1.1:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
+ integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==
quick-lru@^1.0.0:
version "1.1.0"
@@ -16273,12 +16278,12 @@ url-parse-lax@^3.0.0:
dependencies:
prepend-http "^2.0.0"
-url-parse@^1.4.3:
- version "1.4.3"
- resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.3.tgz#bfaee455c889023219d757e045fa6a684ec36c15"
- integrity sha512-rh+KuAW36YKo0vClhQzLLveoj8FwPJNu65xLb7Mrt+eZht0IPT0IXgSv8gcMegZ6NvjJUALf6Mf25POlMwD1Fw==
+url-parse@^1.4.3, url-parse@^1.4.7:
+ version "1.4.7"
+ resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278"
+ integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==
dependencies:
- querystringify "^2.0.0"
+ querystringify "^2.1.1"
requires-port "^1.0.0"
url-to-options@^1.0.1: