CVE-2019-17579: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: SonarSource SonarQube before 7.8 has XSS in project links on account/projects.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2019-10-14T15:15Z
Repository: https://github.com/SonarSource/sonarqube
Language: Other
Files changed: server/sonar-web/src/main/js/apps/account/projects/ProjectCard.tsx, server/sonar-web/src/main/js/apps/account/projects/__tests__/ProjectCard-test.tsx, server/sonar-web/src/main/js/apps/overview/meta/MetaLink.css, server/sonar-web/src/main/js/apps/overview/meta/MetaLink.tsx, server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaLink-test.tsx, server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaLink-test.tsx.snap, server/sonar-web/src/main/js/apps/overview/styles.css, server/sonar-web/src/main/js/apps/projectLinks/utils.ts
Commit date: 2019-03-29 09:44:41 +0100
Commit message: SONAR-11867, SSF-74 Fix XSS in project links on account/projects

Diff: diff --git a/server/sonar-web/src/main/js/apps/account/projects/ProjectCard.tsx b/server/sonar-web/src/main/js/apps/account/projects/ProjectCard.tsx
index b2cef470d7..879d5c2b52 100644
--- a/server/sonar-web/src/main/js/apps/account/projects/ProjectCard.tsx
+++ b/server/sonar-web/src/main/js/apps/account/projects/ProjectCard.tsx
@@ -16,26 +16,39 @@
  * You should have received a copy of the GNU Lesser General Public License
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { sortBy } from 'lodash';
 import { Link } from 'react-router';
 import DateFromNow from '../../../components/intl/DateFromNow';
 import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
 import HelpTooltip from '../../../components/controls/HelpTooltip';
 import Level from '../../../components/ui/Level';
-import ProjectLinkIcon from '../../../components/icons-components/ProjectLinkIcon';
 import Tooltip from '../../../components/controls/Tooltip';
+import MetaLink from '../../overview/meta/MetaLink';
+import { orderLinks } from '../../projectLinks/utils';
 import { translateWithParameters, translate } from '../../../helpers/l10n';
 
 interface Props {
   project: T.MyProject;
 }
 
 export default function ProjectCard({ project }: Props) {
-  const links = sortBy(project.links, 'type');
+  const { links } = project;
+
+  const orderedLinks: T.ProjectLink[] = orderLinks(
+    links.map((link, i) => {
+      const { href, name, type } = link;
+      return {
+        id: `link-${i}`,
+        name,
+        type,
+        url: href
+      };
+    })
+  );
+
   const { lastAnalysisDate } = project;
 
   return (
     <div className="account-project-card clearfix">
       <aside className="account-project-side">
@@ -70,24 +83,15 @@ export default function ProjectCard({ project }: Props) {
 
       <h3 className="account-project-name">
         <Link to={{ pathname: '/dashboard', query: { id: project.key } }}>{project.name}</Link>
       </h3>
 
-      {links.length > 0 && (
+      {orderedLinks.length > 0 && (
         <div className="account-project-links">
           <ul className="list-inline">
-            {links.map(link => (
-              <li key={link.type}>
-                <a
-                  className="link-with-icon"
-                  href={link.href}
-                  rel="nofollow"
-                  target="_blank"
-                  title={link.name}>
-                  <ProjectLinkIcon type={link.type} />
-                </a>
-              </li>
+            {orderedLinks.map(link => (
+              <MetaLink iconOnly={true} key={link.id} link={link} />
             ))}
           </ul>
         </div>
       )}
 
diff --git a/server/sonar-web/src/main/js/apps/account/projects/__tests__/ProjectCard-test.tsx b/server/sonar-web/src/main/js/apps/account/projects/__tests__/ProjectCard-test.tsx
index 9711463737..e74288226c 100644
--- a/server/sonar-web/src/main/js/apps/account/projects/__tests__/ProjectCard-test.tsx
+++ b/server/sonar-web/src/main/js/apps/account/projects/__tests__/ProjectCard-test.tsx
@@ -17,70 +17,62 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
 import { shallow } from 'enzyme';
-import { Link } from 'react-router';
 import ProjectCard from '../ProjectCard';
-import Level from '../../../../components/ui/Level';
-
-const BASE = { key: 'key', links: [], name: 'name' };
 
 it('should render key and name', () => {
-  const project = { ...BASE };
-  const output = shallow(<ProjectCard project={project} />);
-  expect(output.find('.account-project-key').text()).toBe('key');
+  const wrapper = shallowRender();
+  expect(wrapper.find('.account-project-key').text()).toBe('key');
   expect(
-    output
+    wrapper
       .find('.account-project-name')
-      .find(Link)
+      .find('Link')
       .prop('children')
   ).toBe('name');
 });
 
 it('should render description', () => {
-  const project = { ...BASE, description: 'bla' };
-  const output = shallow(<ProjectCard project={project} />);
-  expect(output.find('.account-project-description').text()).toBe('bla');
+  const wrapper = shallowRender({ description: 'bla' });
+  expect(wrapper.find('.account-project-description').text()).toBe('bla');
 });
 
 it('should not render optional fields', () => {
-  const project = { ...BASE };
-  const output = shallow(<ProjectCard project={project} />);
-  expect(output.find('.account-project-description').length).toBe(0);
-  expect(output.find('.account-project-quality-gate').length).toBe(0);
-  expect(output.find('.account-project-links').length).toBe(0);
+  const wrapper = shallowRender();
+  expect(wrapper.find('.account-project-description').length).toBe(0);
+  expect(wrapper.find('.account-project-quality-gate').length).toBe(0);
+  expect(wrapper.find('.account-project-links').length).toBe(0);
 });
 
 it('should render analysis date', () => {
-  const project = { ...BASE, lastAnalysisDate: '2016-05-17' };
-  const output = shallow(<ProjectCard project={project} />);
-  expect(output.find('.account-project-analysis DateFromNow')).toHaveLength(1);
+  const wrapper = shallowRender({ lastAnalysisDate: '2016-05-17' });
+  expect(wrapper.find('.account-project-analysis DateFromNow')).toHaveLength(1);
 });
 
 it('should not render analysis date', () => {
-  const project = { ...BASE };
-  const output = shallow(<ProjectCard project={project} />);
-  expect(output.find('.account-project-analysis').text()).toContain(
+  const wrapper = shallowRender();
+  expect(wrapper.find('.account-project-analysis').text()).toContain(
     'my_account.projects.never_analyzed'
   );
 });
 
 it('should render quality gate status', () => {
-  const project = { ...BASE, qualityGate: 'ERROR' };
-  const output = shallow(<ProjectCard project={project} />);
+  const wrapper = shallowRender({ qualityGate: 'ERROR' });
   expect(
-    output
+    wrapper
       .find('.account-project-quality-gate')
-      .find(Level)
+      .find('Level')
       .prop('level')
   ).toBe('ERROR');
 });
 
 it('should render links', () => {
-  const project = {
-    ...BASE,
-    links: [{ name: 'n', type: 't', href: 'h' }]
-  };
-  const output = shallow(<ProjectCard project={project} />);
-  expect(output.find('.account-project-links').find('li').length).toBe(1);
+  const wrapper = shallowRender({
+    links: [{ name: 'name', type: 'type', href: 'href' }]
+  });
+  expect(wrapper.find('MetaLink').length).toBe(1);
 });
+
+function shallowRender(project: Partial<T.MyProject> = {}) {
+  return shallow(<ProjectCard project={{ key: 'key', links: [], name: 'name', ...project }} />);
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/meta/MetaLink.css b/server/sonar-web/src/main/js/apps/overview/meta/MetaLink.css
new file mode 100644
index 0000000000..132735649b
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/overview/meta/MetaLink.css
@@ -0,0 +1,35 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+.copy-paste-link .overview-key {
+  width: 80%;
+}
+
+.copy-paste-link .close {
+  color: #000;
+  border-bottom: 0;
+  height: 100%;
+  display: inline-block;
+  margin-left: 5px;
+  box-sizing: border-box;
+}
+
+.copy-paste-link .close svg {
+  vertical-align: sub;
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/meta/MetaLink.tsx b/server/sonar-web/src/main/js/apps/overview/meta/MetaLink.tsx
index 95b4609c6d..ad4e696572 100644
--- a/server/sonar-web/src/main/js/apps/overview/meta/MetaLink.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/meta/MetaLink.tsx
@@ -20,12 +20,14 @@
 import * as React from 'react';
 import { getLinkName } from '../../projectLinks/utils';
 import ProjectLinkIcon from '../../../components/icons-components/ProjectLinkIcon';
 import isValidUri from '../../../app/utils/isValidUri';
 import ClearIcon from '../../../components/icons-components/ClearIcon';
+import './MetaLink.css';
 
 interface Props {
+  iconOnly?: boolean;
   link: T.ProjectLink;
 }
 
 interface State {
   expanded: boolean;
@@ -40,21 +42,23 @@ export default class MetaLink extends React.PureComponent<Props, State> {
     event.preventDefault();
     this.setState(s => ({ expanded: !s.expanded }));
   };
 
   render() {
-    const { link } = this.props;
+    const { iconOnly, link } = this.props;
+    const linkTitle = getLinkName(link);
     return (
       <li>
         <a
           className="link-with-icon"
           href={link.url}
           onClick={!isValidUri(link.url) ? this.handleClick : undefined}
           rel="nofollow noreferrer noopener"
-          target="_blank">
+          target="_blank"
+          title={linkTitle}>
           <ProjectLinkIcon className="little-spacer-right" type={link.type} />
-          {getLinkName(link)}
+          {!iconOnly && linkTitle}
         </a>
         {this.state.expanded && (
           <div className="little-spacer-top copy-paste-link">
             <input
               className="overview-key"
diff --git a/server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaLink-test.tsx b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaLink-test.tsx
index 13f5584c9f..bff5b122cb 100644
--- a/server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaLink-test.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaLink-test.tsx
@@ -29,10 +29,11 @@ it('should match snapshot', () => {
     url: 'http://example.com',
     type: 'foo'
   };
 
   expect(shallow(<MetaLink link={link} />)).toMatchSnapshot();
+  expect(shallow(<MetaLink iconOnly={true} link={link} />)).toMatchSnapshot();
 });
 
 it('should render dangerous links as plaintext', () => {
   const link = {
     id: '1',
diff --git a/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaLink-test.tsx.snap b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaLink-test.tsx.snap
index a8f7dbb5bb..256301c711 100644
--- a/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaLink-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaLink-test.tsx.snap
@@ -5,10 +5,11 @@ exports[`should expand and collapse link 1`] = `
   <a
     className="link-with-icon"
     href="scm:git:git@github.com"
     rel="nofollow noreferrer noopener"
     target="_blank"
+    title="Foo"
   >
     <ProjectLinkIcon
       className="little-spacer-right"
       type="foo"
     />
@@ -22,10 +23,11 @@ exports[`should expand and collapse link 2`] = `
   <a
     className="link-with-icon"
     href="scm:git:git@github.com"
     rel="nofollow noreferrer noopener"
     target="_blank"
+    title="Foo"
   >
     <ProjectLinkIcon
       className="little-spacer-right"
       type="foo"
     />
@@ -39,10 +41,11 @@ exports[`should expand and collapse link 3`] = `
   <a
     className="link-with-icon"
     href="scm:git:git@github.com"
     rel="nofollow noreferrer noopener"
     target="_blank"
+    title="Foo"
   >
     <ProjectLinkIcon
       className="little-spacer-right"
       type="foo"
     />
@@ -56,28 +59,47 @@ exports[`should match snapshot 1`] = `
   <a
     className="link-with-icon"
     href="http://example.com"
     rel="nofollow noreferrer noopener"
     target="_blank"
+    title="Foo"
   >
     <ProjectLinkIcon
       className="little-spacer-right"
       type="foo"
     />
     Foo
   </a>
 </li>
 `;
 
+exports[`should match snapshot 2`] = `
+<li>
+  <a
+    className="link-with-icon"
+    href="http://example.com"
+    rel="nofollow noreferrer noopener"
+    target="_blank"
+    title="Foo"
+  >
+    <ProjectLinkIcon
+      className="little-spacer-right"
+      type="foo"
+    />
+  </a>
+</li>
+`;
+
 exports[`should render dangerous links as plaintext 1`] = `
 <li>
   <a
     className="link-with-icon"
     href="javascript:alert(\\"hi\\")"
     onClick={[Function]}
     rel="nofollow noreferrer noopener"
     target="_blank"
+    title="Dangerous"
   >
     <ProjectLinkIcon
       className="little-spacer-right"
       type="dangerous"
     />
diff --git a/server/sonar-web/src/main/js/apps/overview/styles.css b/server/sonar-web/src/main/js/apps/overview/styles.css
index 4ce5c0b5c7..3e552f55f3 100644
--- a/server/sonar-web/src/main/js/apps/overview/styles.css
+++ b/server/sonar-web/src/main/js/apps/overview/styles.css
@@ -472,27 +472,10 @@
 .overview-key {
   width: 100%;
   background-color: transparent !important;
 }
 
-.copy-paste-link .overview-key {
-  width: 90%;
-}
-
-.copy-paste-link .close {
-  color: #000;
-  border-bottom: 0;
-  height: 100%;
-  display: inline-block;
-  margin-left: 5px;
-  box-sizing: border-box;
-}
-
-.copy-paste-link .close svg {
-  vertical-align: sub;
-}
-
 .overview-deleted-profile,
 .overview-deprecated-rules {
   margin: 4px -6px 4px;
   padding: 3px 6px !important;
   border: 1px solid #ebccd1;
diff --git a/server/sonar-web/src/main/js/apps/projectLinks/utils.ts b/server/sonar-web/src/main/js/apps/projectLinks/utils.ts
index 9c09e1339c..e2587c7efd 100644
--- a/server/sonar-web/src/main/js/apps/projectLinks/utils.ts
+++ b/server/sonar-web/src/main/js/apps/projectLinks/utils.ts
@@ -29,11 +29,11 @@ export function isProvided(link: Pick<T.ProjectLink, 'type'>) {
 
 export function orderLinks<T extends NameAndType>(links: T[]) {
   const [provided, unknown] = partition<T>(links, isProvided);
   return [
     ...sortBy(provided, link => PROVIDED_TYPES.indexOf(link.type)),
-    ...sortBy(unknown, link => link.name!.toLowerCase())
+    ...sortBy(unknown, link => link.name && link.name.toLowerCase())
   ];
 }
 
 export function getLinkName(link: NameAndType) {
   return isProvided(link) ? translate('project_links', link.type) : link.name;

Vulnerable code: import { sortBy } from 'lodash';
import ProjectLinkIcon from '../../../components/icons-components/ProjectLinkIcon';
  const links = sortBy(project.links, 'type');
      {links.length > 0 && (
            {links.map(link => (
              <li key={link.type}>
                <a
                  className="link-with-icon"
                  href={link.href}
                  rel="nofollow"
                  target="_blank"
                  title={link.name}>
                  <ProjectLinkIcon type={link.type} />
                </a>
              </li>
import { Link } from 'react-router';
import Level from '../../../../components/ui/Level';

const BASE = { key: 'key', links: [], name: 'name' };
  const project = { ...BASE };
  const output = shallow(<ProjectCard project={project} />);
  expect(output.find('.account-project-key').text()).toBe('key');
    output
      .find(Link)
  const project = { ...BASE, description: 'bla' };
  const output = shallow(<ProjectCard project={project} />);
  expect(output.find('.account-project-description').text()).toBe('bla');
  const project = { ...BASE };
  const output = shallow(<ProjectCard project={project} />);
  expect(output.find('.account-project-description').length).toBe(0);
  expect(output.find('.account-project-quality-gate').length).toBe(0);
  expect(output.find('.account-project-links').length).toBe(0);
  const project = { ...BASE, lastAnalysisDate: '2016-05-17' };
  const output = shallow(<ProjectCard project={project} />);
  expect(output.find('.account-project-analysis DateFromNow')).toHaveLength(1);
  const project = { ...BASE };
  const output = shallow(<ProjectCard project={project} />);
  expect(output.find('.account-project-analysis').text()).toContain(
  const project = { ...BASE, qualityGate: 'ERROR' };
  const output = shallow(<ProjectCard project={project} />);
    output
      .find(Level)
  const project = {
    ...BASE,
    links: [{ name: 'n', type: 't', href: 'h' }]
  };
  const output = shallow(<ProjectCard project={project} />);
  expect(output.find('.account-project-links').find('li').length).toBe(1);
    const { link } = this.props;
          target="_blank">
          {getLinkName(link)}
.copy-paste-link .overview-key {
  width: 90%;
}

.copy-paste-link .close {
  color: #000;
  border-bottom: 0;
  height: 100%;
  display: inline-block;
  margin-left: 5px;
  box-sizing: border-box;
}

.copy-paste-link .close svg {
  vertical-align: sub;
}

    ...sortBy(unknown, link => link.name!.toLowerCase())
Fixed code: import MetaLink from '../../overview/meta/MetaLink';
import { orderLinks } from '../../projectLinks/utils';
  const { links } = project;

  const orderedLinks: T.ProjectLink[] = orderLinks(
    links.map((link, i) => {
      const { href, name, type } = link;
      return {
        id: `link-${i}`,
        name,
        type,
        url: href
      };
    })
  );

      {orderedLinks.length > 0 && (
            {orderedLinks.map(link => (
              <MetaLink iconOnly={true} key={link.id} link={link} />
  const wrapper = shallowRender();
  expect(wrapper.find('.account-project-key').text()).toBe('key');
    wrapper
      .find('Link')
  const wrapper = shallowRender({ description: 'bla' });
  expect(wrapper.find('.account-project-description').text()).toBe('bla');
  const wrapper = shallowRender();
  expect(wrapper.find('.account-project-description').length).toBe(0);
  expect(wrapper.find('.account-project-quality-gate').length).toBe(0);
  expect(wrapper.find('.account-project-links').length).toBe(0);
  const wrapper = shallowRender({ lastAnalysisDate: '2016-05-17' });
  expect(wrapper.find('.account-project-analysis DateFromNow')).toHaveLength(1);
  const wrapper = shallowRender();
  expect(wrapper.find('.account-project-analysis').text()).toContain(
  const wrapper = shallowRender({ qualityGate: 'ERROR' });
    wrapper
      .find('Level')
  const wrapper = shallowRender({
    links: [{ name: 'name', type: 'type', href: 'href' }]
  });
  expect(wrapper.find('MetaLink').length).toBe(1);

function shallowRender(project: Partial<T.MyProject> = {}) {
  return shallow(<ProjectCard project={{ key: 'key', links: [], name: 'name', ...project }} />);
}
/*
 * SonarQube
 * Copyright (C) 2009-2019 SonarSource SA
 * mailto:info AT sonarsource DOT com
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */
.copy-paste-link .overview-key {
  width: 80%;
}

.copy-paste-link .close {
  color: #000;
  border-bottom: 0;
  height: 100%;
  display: inline-block;
  margin-left: 5px;
  box-sizing: border-box;
}

.copy-paste-link .close svg {
  vertical-align: sub;
}
import './MetaLink.css';
  iconOnly?: boolean;
    const { iconOnly, link } = this.props;
    const linkTitle = getLinkName(link);
          target="_blank"
          title={linkTitle}>
          {!iconOnly && linkTitle}
  expect(shallow(<MetaLink iconOnly={true} link={link} />)).toMatchSnapshot();
    title="Foo"
    title="Foo"
    title="Foo"
    title="Foo"
exports[`should match snapshot 2`] = `
<li>
  <a
    className="link-with-icon"
    href="http://example.com"
    rel="nofollow noreferrer noopener"
    target="_blank"
    title="Foo"
  >
    <ProjectLinkIcon
      className="little-spacer-right"
      type="foo"
    />
  </a>
</li>
`;

    title="Dangerous"
    ...sortBy(unknown, link => link.name && link.name.toLowerCase())