]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-23249 Fix SSF-656 & SSF-657
authorDavid Cho-Lerat <david.cho-lerat@sonarsource.com>
Thu, 3 Oct 2024 08:09:00 +0000 (10:09 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 8 Oct 2024 20:02:47 +0000 (20:02 +0000)
30 files changed:
server/sonar-web/.eslintrc
server/sonar-web/design-system/.eslintrc
server/sonar-web/design-system/package.json
server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx
server/sonar-web/design-system/src/components/Text.tsx
server/sonar-web/design-system/src/helpers/index.ts
server/sonar-web/design-system/src/sonar-aligned/helpers/__tests__/sanitize-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/sonar-aligned/helpers/index.ts
server/sonar-web/design-system/src/sonar-aligned/helpers/sanitize.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/background-tasks/components/AnalysisWarningsModal.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/ActivationFormModal.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/CustomRuleFormModal.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsDescription.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsParameters.tsx
server/sonar-web/src/main/js/apps/issues/components/IssueReviewHistory.tsx
server/sonar-web/src/main/js/apps/overview/components/AnalysisWarningsModal.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotReviewHistory.tsx
server/sonar-web/src/main/js/apps/sessions/components/Login.tsx
server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx
server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx
server/sonar-web/src/main/js/apps/settings/components/inputs/InputForFormattedText.tsx
server/sonar-web/src/main/js/apps/web-api/components/Action.tsx
server/sonar-web/src/main/js/apps/web-api/components/Domain.tsx
server/sonar-web/src/main/js/apps/web-api/components/Params.tsx
server/sonar-web/src/main/js/components/rules/RuleDescription.tsx
server/sonar-web/src/main/js/helpers/__tests__/code-difference-test.tsx
server/sonar-web/src/main/js/helpers/__tests__/sanitize-test.ts [deleted file]
server/sonar-web/src/main/js/helpers/code-difference.ts
server/sonar-web/src/main/js/helpers/sanitize.ts [deleted file]
server/sonar-web/yarn.lock

index 49071ab4dbf021837e38f6c9e54bdbfc9b47b035..7da42df461d8a656da9efbcfec33ec4e6cc68ec9 100644 (file)
       ],
       1
     ],
+    "react/forbid-component-props": [
+      "error",
+      {
+        "forbid": [
+          {
+            "propName": "dangerouslySetInnerHTML",
+            "message": "Use the SafeHTMLInjection component instead of 'dangerouslySetInnerHTML', to prevent CSS injection along other XSS attacks"
+          }
+        ]
+      }
+    ],
+    "react/forbid-dom-props": [
+      "error",
+      {
+        "forbid": [
+          {
+            "propName": "dangerouslySetInnerHTML",
+            "message": "Use the SafeHTMLInjection component instead of 'dangerouslySetInnerHTML', to prevent CSS injection along other XSS attacks"
+          }
+        ]
+      }
+    ],
     "react/forbid-elements": [
       "warn",
       {
index b6218abddcd7681862a4d61795416fa6b4ce6fe6..01e7937531f2c737eec1f679b0e87d86a2899e93 100644 (file)
@@ -5,6 +5,28 @@
   "rules": {
     // Custom SonarCloud config that differs from eslint-config-sonarqube
     "camelcase": "off",
+    "react/forbid-component-props": [
+      "error",
+      {
+        "forbid": [
+          {
+            "propName": "dangerouslySetInnerHTML",
+            "message": "Use the SafeHTMLInjection component instead of 'dangerouslySetInnerHTML', to prevent CSS injection along other XSS attacks"
+          }
+        ]
+      }
+    ],
+    "react/forbid-dom-props": [
+      "error",
+      {
+        "forbid": [
+          {
+            "propName": "dangerouslySetInnerHTML",
+            "message": "Use the SafeHTMLInjection component instead of 'dangerouslySetInnerHTML', to prevent CSS injection along other XSS attacks"
+          }
+        ]
+      }
+    ],
     "react/jsx-sort-props": "error",
     "react/jsx-pascal-case": [2, { "allowNamespace": true }],
     "react/jsx-no-constructed-context-values": "error",
index 866433a808587a981ef207eaeafae30b0c21b798..2e459a6c9eeb96cbcae05165a32cf4858c8fe30b 100644 (file)
@@ -72,6 +72,7 @@
     "d3-shape": "3.2.0",
     "d3-zoom": "3.0.0",
     "date-fns": "4.1.0",
+    "dompurify": "3.1.7",
     "lodash": "4.17.21",
     "react": "18.3.1",
     "react-day-picker": "8.10.0",
index 57cd229e71f1752708bf67e27dd0256c60b0cd80..693c3cdde83f655008132df9879c09b41e610d51 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import styled from '@emotion/styled';
 import classNames from 'classnames';
 import hljs, { HighlightResult } from 'highlight.js';
@@ -26,6 +27,7 @@ import abap from 'highlightjs-sap-abap';
 import tw from 'twin.macro';
 import { themeColor, themeContrast } from '../helpers/theme';
 import { hljsIssueIndicatorPlugin, hljsUnderlinePlugin } from '../sonar-aligned';
+import { SafeHTMLInjection, SanitizeLevel } from '../sonar-aligned/helpers/sanitize';
 
 hljs.registerLanguage('abap', abap);
 hljs.registerLanguage('apex', apex);
@@ -47,6 +49,7 @@ interface Props {
   escapeDom?: boolean;
   htmlAsString: string;
   language?: string;
+  sanitizeLevel?: SanitizeLevel;
   wrap?: boolean | 'words';
 }
 
@@ -60,8 +63,15 @@ const htmlDecode = (escapedCode: string) => {
   return doc.documentElement.textContent ?? '';
 };
 
-export function CodeSyntaxHighlighter(props: Props) {
-  const { className, htmlAsString, language, wrap, escapeDom = true } = props;
+export function CodeSyntaxHighlighter(props: Readonly<Props>) {
+  const {
+    className,
+    escapeDom = true,
+    htmlAsString,
+    language,
+    sanitizeLevel = SanitizeLevel.FORBID_STYLE,
+    wrap,
+  } = props;
   let highlightedHtmlAsString = htmlAsString;
 
   htmlAsString.match(GLOBAL_REGEXP)?.forEach((codeBlock) => {
@@ -95,16 +105,15 @@ export function CodeSyntaxHighlighter(props: Props) {
   });
 
   return (
-    <StyledSpan
-      className={classNames(
-        `hljs ${className ?? ''}`,
-        { 'code-wrap': wrap },
-        { 'wrap-words': wrap === 'words' },
-      )}
-      // Safe: value is escaped by highlight.js
-      // eslint-disable-next-line react/no-danger
-      dangerouslySetInnerHTML={{ __html: highlightedHtmlAsString }}
-    />
+    <SafeHTMLInjection htmlAsString={highlightedHtmlAsString} sanitizeLevel={sanitizeLevel}>
+      <StyledSpan
+        className={classNames(
+          `hljs ${className ?? ''}`,
+          { 'code-wrap': wrap },
+          { 'wrap-words': wrap === 'words' },
+        )}
+      />
+    </SafeHTMLInjection>
   );
 }
 
index 08437dcce22345d888478564048e078a9156efd7..25df5241e02bbbb20c8bf8d8a7d45c7ebc66b4dc 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import styled from '@emotion/styled';
 import { ElementType } from 'react';
 import tw from 'twin.macro';
 import { themeColor, themeContrast } from '../helpers/theme';
+import { SafeHTMLInjection } from '../sonar-aligned/helpers/sanitize';
 
 interface TextBoldProps {
   className?: string;
@@ -32,12 +34,9 @@ interface TextBoldProps {
  */
 export function TextBold({ match, name, className }: TextBoldProps) {
   return match ? (
-    <StyledText
-      className={className}
-      // Safe: comes from the search engine, that injects bold tags into component names
-      // eslint-disable-next-line react/no-danger
-      dangerouslySetInnerHTML={{ __html: match }}
-    />
+    <SafeHTMLInjection htmlAsString={match}>
+      <StyledText className={className} />
+    </SafeHTMLInjection>
   ) : (
     <StyledText className={className} title={name}>
       {name}
@@ -47,7 +46,7 @@ export function TextBold({ match, name, className }: TextBoldProps) {
 
 /** @deprecated Use Text (with `isSubdued` prop) from Echoes instead.
  */
-export function TextMuted({ text, className }: { className?: string; text: string }) {
+export function TextMuted({ text, className }: Readonly<{ className?: string; text: string }>) {
   return (
     <StyledMutedText className={className} title={text}>
       {text}
index 73dd1464b22ae56b6b5646e441c00e02bcac78b8..7af0536ce9066863596152fdec535cb20aa43eed 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 export * from './colors';
 export * from './constants';
 export * from './keyboard';
diff --git a/server/sonar-web/design-system/src/sonar-aligned/helpers/__tests__/sanitize-test.tsx b/server/sonar-web/design-system/src/sonar-aligned/helpers/__tests__/sanitize-test.tsx
new file mode 100644 (file)
index 0000000..b8be9b9
--- /dev/null
@@ -0,0 +1,294 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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.
+ */
+
+import { render, screen } from '@testing-library/react';
+import {
+  SafeHTMLInjection,
+  sanitizeHTMLNoSVGNoMathML,
+  sanitizeHTMLRestricted,
+  sanitizeHTMLToPreventCSSInjection,
+  sanitizeHTMLUserInput,
+} from '../sanitize';
+
+/*
+ * Test code borrowed from OWASP's sanitizer tests
+ * https://github.com/OWASP/java-html-sanitizer/blob/master/src/test/resources/org/owasp/html/htmllexerinput1.html
+ */
+const tainted = `<?xml version="not-even-close"?>
+
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+
+<!-- a test input for HtmlLexer -->
+
+<html lang="en" xml:lang="en">
+<head>
+<title>Test File For HtmlLexer &amp; HtmlParser</title>
+<link rel=stylesheet type="text/css" src=foo/bar.css />
+<body
+ bgcolor=white
+ linkcolor = "blue"
+ onload="document.writeln(
+  &quot;&lt;p&gt;properly escaped code in a handler&lt;/p&gt;&quot;);"
+>
+
+<script type="text/javascript"><!--
+document.writeln("<p>Some initialization code in global context</p>");
+--></script>
+
+<script type="text/javascript">
+// hi there
+document.writeln("<p>More initialization</p>");
+</script>
+
+<div id=clickydiv onclick="handleClicky(event)"
+ ondblclick=this.onclick(event);return(false)>
+Clicky
+</div>
+
+<input id=foo>
+<gxp:attr name="onchange">alert("&lt;b&gt;hi&lt;/b&gt;");</gxp:attr>
+</input>
+
+<pre>&lt;div id=notarealtag onclick=notcode()&gt;</pre>
+
+<!-- some tokenization corner cases -->
+
+< notatag <atag/>
+
+</ notatag> </redundantlyclosed/>
+
+<messyattributes a=b=c d="e"f=g h =i j= k l = m checked n="o"/>
+
+< < < all in one text block > > >
+
+<xmp>Make sure that <!-- comments don't obscure the xmp close</xmp>
+<% # some php code here
+write("<pre>$horriblySyntacticConstruct1</pre>\n\n");
+%>
+<script type="text/javascript"><!--
+alert("hello world");
+// --></script>
+
+<script>/* </script> */alert('hi');</script>
+<script><!--/* </script> */alert('hi');--></script>
+
+<xmp style=color:blue><!--/* </xmp> */alert('hi');--></xmp>
+
+<style><!-- p { contentf: '</style>' } --></style>
+<style>Foo<!-- > </style> --></style>
+<textarea><!-- Zoicks </textarea>--></textarea>
+<!-- An escaping text span start may share its U+002D HYPHEN-MINUS characters
+   - with its corresponding escaping text span end. -->
+<script><!--></script>
+<script><!---></script>
+<script><!----></script>
+
+This is <b>bold</b> and this is <i>italic</i> and this is <u>underlined</u>.
+<br />
+A <blockquote>quote</blockquote> and a <code>code</code> and a <pre>pre</pre>.
+An <h1>h1</h1> and an <h2>h2</h2> and an <h3>h3</h3> and an <h4>h4</h4> and an <h5>h5</h5> and an <h6>h6</h6>.
+An <ol><li>ol</li></ol> and a <ul><li>ul</li></ul> and a <p style="color:blue">p</p>.
+A <strong>strong</strong> and a <a href="foo" ping="pong" rel="noopener" target="__blank" >link</a>
+
+<a href="javascript:alert('hello')" target="_blank">this is wrong</a>
+
+<svg><text>SVG isn't always allowed</text></svg>
+
+<math xmlns="http://www.w3.org/1998/Math/MathML">
+  <infinity />
+</math>
+
+</body>
+</html>
+<![CDATA[ No such thing as a CDATA> section in HTML ]]>
+<script>a<b</script>
+<img src=foo.gif /><a href=><a href=/>
+<span title=malformed attribs' do=don't id=foo checked onclick="a<b">Bar</span>`;
+
+describe('sanitizeHTMLToPreventCSSInjection', () => {
+  it('should strip off style attributes', () => {
+    const clean = `
+    <div id="clickydiv">
+    Clicky
+    </div>
+    <input id="foo">
+    alert("&lt;b&gt;hi&lt;/b&gt;");
+    <pre>&lt;div id=notarealtag onclick=notcode()&gt;</pre>
+    &lt; notatag
+    &lt; &lt; &lt; all in one text block &gt; &gt; &gt;
+    &lt;% # some php code here
+    write("<pre>$horriblySyntacticConstruct1</pre>
+    ");
+    %&gt;
+     */alert('hi');
+     */alert('hi');--&gt;
+     */alert('hi');--&gt; ' } --&gt;
+     --&gt;
+    <textarea>&lt;!-- Zoicks </textarea>--&gt;
+    This is <b>bold</b> and this is <i>italic</i> and this is <u>underlined</u>.
+    <br>
+    A <blockquote>quote</blockquote> and a <code>code</code> and a <pre>pre</pre>.
+    An <h1>h1</h1> and an <h2>h2</h2> and an <h3>h3</h3> and an <h4>h4</h4> and an <h5>h5</h5> and an <h6>h6</h6>.
+    An <ol><li>ol</li></ol> and a <ul><li>ul</li></ul> and a <p>p</p>.
+    A <strong>strong</strong> and a <a rel="noopener" href="foo">link</a>
+    <a>this is wrong</a>
+    <svg><text>SVG isn't always allowed</text></svg>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+    </math>
+     section in HTML ]]&gt;
+    <img src="foo.gif"><a href=""></a><a href="/">
+    <span checked="" id="foo" title="malformed">Bar</span></a>`;
+
+    expect(sanitizeHTMLToPreventCSSInjection(tainted).trimEnd().replace(/\s+/g, ' ')).toBe(
+      clean.replace(/\s+/g, ' '),
+    );
+  });
+});
+
+describe('sanitizeHTMLNoSVGNoMathML', () => {
+  it('should not allow MathML and SVG', () => {
+    const clean = `
+    <div id="clickydiv">
+    Clicky
+    </div>
+    <input id="foo">
+    alert("&lt;b&gt;hi&lt;/b&gt;");
+    <pre>&lt;div id=notarealtag onclick=notcode()&gt;</pre>
+    &lt; notatag
+    &lt; &lt; &lt; all in one text block &gt; &gt; &gt;
+    &lt;% # some php code here
+    write("<pre>$horriblySyntacticConstruct1</pre>
+    ");
+    %&gt;
+     */alert('hi');
+     */alert('hi');--&gt;
+     */alert('hi');--&gt; ' } --&gt;
+     --&gt;
+    <textarea>&lt;!-- Zoicks </textarea>--&gt;
+    This is <b>bold</b> and this is <i>italic</i> and this is <u>underlined</u>.
+    <br>
+    A <blockquote>quote</blockquote> and a <code>code</code> and a <pre>pre</pre>.
+    An <h1>h1</h1> and an <h2>h2</h2> and an <h3>h3</h3> and an <h4>h4</h4> and an <h5>h5</h5> and an <h6>h6</h6>.
+    An <ol><li>ol</li></ol> and a <ul><li>ul</li></ul> and a <p>p</p>.
+    A <strong>strong</strong> and a <a rel="noopener" href="foo">link</a>
+    <a>this is wrong</a>
+     section in HTML ]]&gt;
+    <img src="foo.gif"><a href=""></a><a href="/">
+    <span checked="" id="foo" title="malformed">Bar</span></a>`;
+
+    expect(sanitizeHTMLNoSVGNoMathML(tainted).trimEnd().replace(/\s+/g, ' ')).toBe(
+      clean.replace(/\s+/g, ' '),
+    );
+  });
+});
+
+describe('sanitizeHTMLUserInput', () => {
+  it('should preserve only specific formatting tags and attributes', () => {
+    const clean = `
+    Clicky
+    alert("&lt;b&gt;hi&lt;/b&gt;");
+    <pre>&lt;div id=notarealtag onclick=notcode()&gt;</pre>
+    &lt; notatag
+    &lt; &lt; &lt; all in one text block &gt; &gt; &gt;
+    &lt;% # some php code here
+    write("<pre>$horriblySyntacticConstruct1</pre>
+    ");
+    %&gt;
+     */alert('hi');
+     */alert('hi');--&gt;
+     */alert('hi');--&gt; ' } --&gt;
+     --&gt;
+    &lt;!-- Zoicks --&gt;
+    This is <b>bold</b> and this is <i>italic</i> and this is underlined.
+    <br>
+    A <blockquote>quote</blockquote> and a <code>code</code> and a <pre>pre</pre>.
+    An <h1>h1</h1> and an <h2>h2</h2> and an <h3>h3</h3> and an <h4>h4</h4> and an <h5>h5</h5> and an <h6>h6</h6>.
+    An <ol><li>ol</li></ol> and a <ul><li>ul</li></ul> and a <p>p</p>.
+    A <strong>strong</strong> and a <a rel="noopener" href="foo">link</a>
+    <a>this is wrong</a>
+     section in HTML ]]&gt;
+    <a href=""></a><a href="/">
+    Bar</a>`;
+
+    expect(sanitizeHTMLUserInput(tainted).trimEnd().replace(/\s+/g, ' ')).toBe(
+      clean.replace(/\s+/g, ' '),
+    );
+  });
+});
+
+describe('sanitizeHTMLRestricted', () => {
+  it('should preserve only a very limited list of formatting tags and attributes', () => {
+    const clean = `
+    Clicky
+    alert("&lt;b&gt;hi&lt;/b&gt;");
+    &lt;div id=notarealtag onclick=notcode()&gt;
+    &lt; notatag
+    &lt; &lt; &lt; all in one text block &gt; &gt; &gt;
+    &lt;% # some php code here
+    write("$horriblySyntacticConstruct1
+    ");
+    %&gt;
+     */alert('hi');
+     */alert('hi');--&gt;
+     */alert('hi');--&gt; ' } --&gt;
+     --&gt;
+    &lt;!-- Zoicks --&gt;
+    This is <b>bold</b> and this is <i>italic</i> and this is underlined.
+    <br>
+    A quote and a <code>code</code> and a pre.
+    An h1 and an h2 and an h3 and an h4 and an h5 and an h6.
+    An <li>ol</li> and a <ul><li>ul</li></ul> and a <p>p</p>.
+    A <strong>strong</strong> and a <a href="foo">link</a>
+    <a>this is wrong</a>
+     section in HTML ]]&gt;
+    <a href=""></a><a href="/">
+    Bar</a>`;
+
+    expect(sanitizeHTMLRestricted(tainted).trimEnd().replace(/\s+/g, ' ')).toBe(
+      clean.replace(/\s+/g, ' '),
+    );
+  });
+});
+
+describe('SafeHTMLInjection', () => {
+  it('should default to a span and the SanitizeLevel.FORBID_STYLE level', () => {
+    const tainted = `
+    <head>
+      <link rel=stylesheet type="text/css" src=foo/bar.css />
+      <style>some style</style>
+    </head>
+  
+    <body>
+      <p style="color:blue">a stylish paragraph</p>
+      
+      <svg><text>SVG isn't always allowed</text></svg>
+
+      <math xmlns="http://www.w3.org/1998/Math/MathML">
+        <infinity />
+      </math>
+    </body>
+    `;
+
+    render(<SafeHTMLInjection htmlAsString={tainted} />);
+
+    expect(screen.getByText('a stylish paragraph')).toBeInTheDocument();
+    expect(screen.getByText("SVG isn't always allowed")).toBeInTheDocument();
+  });
+});
index 863797b2d1f24c006da178b82ce44a590a5ffb5e..e288f05e8ee67e3cde2eeda7e76a42587050f11b 100644 (file)
@@ -18,4 +18,5 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
+export * from './sanitize';
 export * from './tabs';
diff --git a/server/sonar-web/design-system/src/sonar-aligned/helpers/sanitize.tsx b/server/sonar-web/design-system/src/sonar-aligned/helpers/sanitize.tsx
new file mode 100644 (file)
index 0000000..1426c56
--- /dev/null
@@ -0,0 +1,124 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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.
+ */
+
+import dompurify from 'dompurify';
+
+import React from 'react';
+
+const { sanitize } = dompurify;
+
+export enum SanitizeLevel {
+  FORBID_STYLE, // minimum sanitation level to prevent CSS injections
+  FORBID_SVG_MATHML, // adds SVG and MathML exclusion
+  USER_INPUT, // adds restrictions on tags and attributes
+  RESTRICTED, // adds even more restrictions on tags and attributes
+}
+
+export const sanitizeFunctionByLevel = (sanitizeLevel: SanitizeLevel) =>
+  ({
+    [SanitizeLevel.FORBID_STYLE]: sanitizeHTMLToPreventCSSInjection,
+    [SanitizeLevel.FORBID_SVG_MATHML]: sanitizeHTMLNoSVGNoMathML,
+    [SanitizeLevel.USER_INPUT]: sanitizeHTMLUserInput,
+    [SanitizeLevel.RESTRICTED]: sanitizeHTMLRestricted,
+  })[sanitizeLevel];
+
+export const sanitizeHTMLToPreventCSSInjection = (htmlAsString: string) =>
+  sanitize(htmlAsString, {
+    FORBID_ATTR: ['style'],
+    FORBID_TAGS: ['style'],
+  });
+
+export function sanitizeHTMLNoSVGNoMathML(htmlAsString: string) {
+  return sanitize(htmlAsString, {
+    FORBID_ATTR: ['style'],
+    FORBID_TAGS: ['style'],
+    USE_PROFILES: { html: true },
+  });
+}
+
+export function sanitizeHTMLUserInput(htmlAsString: string) {
+  return sanitize(htmlAsString, {
+    ALLOWED_ATTR: ['href', 'rel'],
+    ALLOWED_TAGS: [
+      'a',
+      'b',
+      'blockquote',
+      'br',
+      'code',
+      'h1',
+      'h2',
+      'h3',
+      'h4',
+      'h5',
+      'h6',
+      'i',
+      'li',
+      'ol',
+      'p',
+      'pre',
+      'strong',
+      'ul',
+    ],
+  });
+}
+
+export function sanitizeHTMLRestricted(htmlAsString: string) {
+  return sanitize(htmlAsString, {
+    ALLOWED_ATTR: ['href'],
+    ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'i', 'li', 'p', 'strong', 'ul'],
+  });
+}
+
+/**
+ * Safely injects HTML into an element with no risk of XSS attacks.
+ *
+ * @param children The React element to clone with the sanitized HTML (defaults to a `span`)
+ * @param htmlAsString The HTML string to sanitize and inject (required)
+ * @param sanitizeLevel The level of sanitation to apply (defaults to `SanitizeLevel.FORBID_STYLE`)
+ *
+ * @returns A React element with the sanitized HTML injected, and all other props preserved
+ *
+ * @example
+ * Here's a simple example with no children:
+ * ```
+ * <SafeHTMLInjection htmlAsString={taintedString} />
+ * ```
+ *
+ * @example
+ * Here's an example with a custom `sanitizeLevel` and a child `div`:
+ * ```
+ * <SafeHTMLInjection htmlAsString={taintedString} sanitizeLevel={SanitizeLevel.RESTRICTED}>
+ *   // the HTML will be safely injected in the div below, with the className preserved:
+ *   <div className="someClassThatWillBePreserved" />
+ * </SafeHTMLInjection>
+ * ```
+ */
+export const SafeHTMLInjection = ({
+  children,
+  htmlAsString,
+  sanitizeLevel = SanitizeLevel.FORBID_STYLE,
+}: Readonly<{
+  children?: React.ReactElement;
+  htmlAsString: string;
+  sanitizeLevel?: SanitizeLevel;
+}>) =>
+  React.cloneElement(children ?? <span />, {
+    dangerouslySetInnerHTML: { __html: sanitizeFunctionByLevel(sanitizeLevel)(htmlAsString) },
+  });
index 3671ee2f0f8cd5a0a07b21310fc483dd43cd910b..c6134af333cff56a8f556bb0552bff7ec808fd63 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { Button, ButtonVariety } from '@sonarsource/echoes-react';
-import { FlagMessage, HtmlFormatter, Modal, Spinner } from 'design-system';
+import {
+  FlagMessage,
+  HtmlFormatter,
+  Modal,
+  SafeHTMLInjection,
+  SanitizeLevel,
+  Spinner,
+} from 'design-system';
 import * as React from 'react';
 import { dismissAnalysisWarning, getTask } from '../../../api/ce';
 import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
 import { translate } from '../../../helpers/l10n';
-import { sanitizeStringRestricted } from '../../../helpers/sanitize';
 import { TaskWarning } from '../../../types/tasks';
 import { CurrentUser } from '../../../types/users';
 
@@ -118,11 +125,9 @@ export class AnalysisWarningsModal extends React.PureComponent<Props, State> {
               <div className="sw-flex sw-items-center sw-mt-2">
                 <FlagMessage variant="warning">
                   <HtmlFormatter>
-                    <span
-                      // eslint-disable-next-line react/no-danger
-                      dangerouslySetInnerHTML={{
-                        __html: sanitizeStringRestricted(message.trim().replace(/\n/g, '<br />')),
-                      }}
+                    <SafeHTMLInjection
+                      htmlAsString={message.trim().replace(/\n/g, '<br />')}
+                      sanitizeLevel={SanitizeLevel.RESTRICTED}
                     />
                   </HtmlFormatter>
                 </FlagMessage>
index 7aa212f1358b6b5e77d158513f2d054ee9598649..efa190f33f024fea6e425d172e5e57eb6b36be49 100644 (file)
@@ -27,6 +27,8 @@ import {
   InputTextArea,
   LabelValueSelectOption,
   Note,
+  SafeHTMLInjection,
+  SanitizeLevel,
   Switch,
 } from 'design-system';
 import * as React from 'react';
@@ -35,7 +37,6 @@ import { Profile } from '../../../api/quality-profiles';
 import { useAvailableFeatures } from '../../../app/components/available-features/withAvailableFeatures';
 import DocumentationLink from '../../../components/common/DocumentationLink';
 import { DocLink } from '../../../helpers/doc-links';
-import { sanitizeString } from '../../../helpers/sanitize';
 import { useActivateRuleMutation } from '../../../queries/quality-profiles';
 import { Feature } from '../../../types/features';
 import { IssueSeverity } from '../../../types/issues';
@@ -265,11 +266,12 @@ export default function ActivationFormModal(props: Readonly<Props>) {
                   />
                 )}
                 {param.htmlDesc !== undefined && (
-                  <Note
-                    as="div"
-                    // eslint-disable-next-line react/no-danger
-                    dangerouslySetInnerHTML={{ __html: sanitizeString(param.htmlDesc) }}
-                  />
+                  <SafeHTMLInjection
+                    htmlAsString={param.htmlDesc}
+                    sanitizeLevel={SanitizeLevel.FORBID_SVG_MATHML}
+                  >
+                    <Note as="div" />
+                  </SafeHTMLInjection>
                 )}
               </FormField>
             ))
index 4f7142ab3a9d0a5d5e508d56d8d926dd70fb4383..2c6fb0cd8e698de3e1c1024e9e6004dab349396f 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { HttpStatusCode } from 'axios';
 import {
   ButtonPrimary,
@@ -28,6 +29,8 @@ import {
   LabelValueSelectOption,
   LightLabel,
   Modal,
+  SafeHTMLInjection,
+  SanitizeLevel,
 } from 'design-system';
 import * as React from 'react';
 import { Status } from '~sonar-aligned/types/common';
@@ -36,7 +39,6 @@ import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsEx
 import { RULE_STATUSES } from '../../../helpers/constants';
 import { csvEscape } from '../../../helpers/csv';
 import { translate } from '../../../helpers/l10n';
-import { sanitizeString } from '../../../helpers/sanitize';
 import { latinize } from '../../../helpers/strings';
 import { useCreateRuleMutation, useUpdateRuleMutation } from '../../../queries/rules';
 import {
@@ -294,11 +296,14 @@ export default function CustomRuleFormModal(props: Readonly<Props>) {
               value={actualValue}
             />
           )}
+
           {param.htmlDesc !== undefined && (
-            <LightLabel
-              // eslint-disable-next-line react/no-danger
-              dangerouslySetInnerHTML={{ __html: sanitizeString(param.htmlDesc) }}
-            />
+            <SafeHTMLInjection
+              htmlAsString={param.htmlDesc}
+              sanitizeLevel={SanitizeLevel.FORBID_SVG_MATHML}
+            >
+              <LightLabel />
+            </SafeHTMLInjection>
           )}
         </FormField>
       );
index 0a9ac8ee0d4c106498e638772e40374a2149c712..5b1dffbbc25d7e3b95387caae6386fb6b86632e5 100644 (file)
@@ -23,13 +23,13 @@ import {
   ButtonSecondary,
   CodeSyntaxHighlighter,
   InputTextArea,
+  SanitizeLevel,
 } from 'design-system';
 
 import * as React from 'react';
 import FormattingTips from '../../../components/common/FormattingTips';
 import RuleTabViewer from '../../../components/rules/RuleTabViewer';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { sanitizeUserInput } from '../../../helpers/sanitize';
 import { useUpdateRuleMutation } from '../../../queries/rules';
 import { RuleDetails } from '../../../types/types';
 import RemoveExtendedDescriptionModal from './RemoveExtendedDescriptionModal';
@@ -61,8 +61,9 @@ export default function RuleDetailsDescription(props: Readonly<Props>) {
       {ruleDetails.htmlNote !== undefined && (
         <CodeSyntaxHighlighter
           className="markdown sw-my-6"
-          htmlAsString={sanitizeUserInput(ruleDetails.htmlNote)}
+          htmlAsString={ruleDetails.htmlNote}
           language={ruleDetails.lang}
+          sanitizeLevel={SanitizeLevel.USER_INPUT}
         />
       )}
 
index fc7a6486c66f43411885a00e4082195714cffdde..316e72a51aaa81efbfdd772367aed0626373d3e0 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { CellComponent, Note, SubHeadingHighlight, Table, TableRow } from 'design-system';
+
+import {
+  CellComponent,
+  Note,
+  SafeHTMLInjection,
+  SanitizeLevel,
+  SubHeadingHighlight,
+  Table,
+  TableRow,
+} from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../helpers/l10n';
-import { sanitizeString } from '../../../helpers/sanitize';
 import { RuleParameter } from '../../../types/types';
 
 interface Props {
@@ -38,10 +46,12 @@ export default function RuleDetailsParameters({ params }: Props) {
             <CellComponent>
               <div className="sw-flex sw-flex-col sw-gap-2">
                 {param.htmlDesc !== undefined && (
-                  <div
-                    // eslint-disable-next-line react/no-danger
-                    dangerouslySetInnerHTML={{ __html: sanitizeString(param.htmlDesc) }}
-                  />
+                  <SafeHTMLInjection
+                    htmlAsString={param.htmlDesc}
+                    sanitizeLevel={SanitizeLevel.FORBID_SVG_MATHML}
+                  >
+                    <div />
+                  </SafeHTMLInjection>
                 )}
                 {param.defaultValue !== undefined && (
                   <Note as="div">
index df464f021d5ea167c2c934e9239cdc8cc773d8ba..cc7b2e74d1b2555bf0edbec78d1c5724939fa9d3 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import styled from '@emotion/styled';
 import {
   DangerButtonPrimary,
@@ -26,6 +27,8 @@ import {
   LightLabel,
   Modal,
   PencilIcon,
+  SafeHTMLInjection,
+  SanitizeLevel,
   TrashIcon,
   themeBorder,
 } from 'design-system';
@@ -35,7 +38,6 @@ import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
 import IssueChangelogDiff from '../../../components/issue/components/IssueChangelogDiff';
 import Avatar from '../../../components/ui/Avatar';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { sanitizeUserInput } from '../../../helpers/sanitize';
 import { ReviewHistoryType } from '../../../types/security-hotspots';
 import { Issue, IssueChangelog } from '../../../types/types';
 import HotspotCommentModal from '../../security-hotspots/components/HotspotCommentModal';
@@ -47,7 +49,20 @@ export interface HotspotReviewHistoryProps {
   onEditComment: (key: string, comment: string) => void;
 }
 
-export default function IssueReviewHistory(props: HotspotReviewHistoryProps) {
+const getUpdatedChangelog = ({ changelog }: { changelog: IssueChangelog[] }) =>
+  changelog.map((changelogItem) => {
+    const diffHasIssueStatusChange = changelogItem.diffs.some((diff) => diff.key === 'issueStatus');
+
+    return {
+      ...changelogItem,
+      // If the diff is an issue status change, we remove deprecated status and resolution diffs
+      diffs: changelogItem.diffs.filter(
+        (diff) => !(diffHasIssueStatusChange && ['resolution', 'status'].includes(diff.key)),
+      ),
+    };
+  });
+
+export default function IssueReviewHistory(props: Readonly<HotspotReviewHistoryProps>) {
   const { issue } = props;
   const [changeLog, setChangeLog] = React.useState<IssueChangelog[]>([]);
   const history = useGetIssueReviewHistory(issue, changeLog);
@@ -57,19 +72,8 @@ export default function IssueReviewHistory(props: HotspotReviewHistoryProps) {
   React.useEffect(() => {
     getIssueChangelog(issue.key).then(
       ({ changelog }) => {
-        const updatedChangelog = changelog.map((changelogItem) => {
-          const diffHasIssueStatusChange = changelogItem.diffs.some(
-            (diff) => diff.key === 'issueStatus',
-          );
-
-          return {
-            ...changelogItem,
-            // If the diff is an issue status change, we remove deprecated status and resolution diffs
-            diffs: changelogItem.diffs.filter(
-              (diff) => !(diffHasIssueStatusChange && ['resolution', 'status'].includes(diff.key)),
-            ),
-          };
-        });
+        const updatedChangelog = getUpdatedChangelog({ changelog });
+
         setChangeLog(updatedChangelog);
       },
       () => {},
@@ -85,6 +89,7 @@ export default function IssueReviewHistory(props: HotspotReviewHistoryProps) {
             <div className="sw-typo-semibold sw-mb-1">
               <DateTimeFormatter date={date} />
             </div>
+
             <LightLabel as="div" className="sw-flex sw-gap-2">
               {user.name && (
                 <div className="sw-flex sw-items-center sw-gap-1">
@@ -112,11 +117,9 @@ export default function IssueReviewHistory(props: HotspotReviewHistoryProps) {
 
             {type === ReviewHistoryType.Comment && key && html && markdown && (
               <div className="sw-mt-2 sw-flex sw-justify-between">
-                <CommentBox
-                  className="sw-pl-2 sw-ml-2 sw-typo-default"
-                  // eslint-disable-next-line react/no-danger
-                  dangerouslySetInnerHTML={{ __html: sanitizeUserInput(html) }}
-                />
+                <SafeHTMLInjection htmlAsString={html} sanitizeLevel={SanitizeLevel.USER_INPUT}>
+                  <CommentBox className="sw-pl-2 sw-ml-2 sw-typo-default" />
+                </SafeHTMLInjection>
 
                 {updatable && (
                   <div className="sw-flex sw-gap-6">
@@ -127,6 +130,7 @@ export default function IssueReviewHistory(props: HotspotReviewHistoryProps) {
                       size="small"
                       stopPropagation={false}
                     />
+
                     <DestructiveIcon
                       Icon={TrashIcon}
                       aria-label={translate('issue.comment.delete')}
index 5a4daac6b9e11adab4366e264fe2ce1703b26ee6..3390089477de0e73d52c027f74c8613760abb63c 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { Button, ButtonVariety } from '@sonarsource/echoes-react';
-import { FlagMessage, HtmlFormatter, Modal, Spinner } from 'design-system';
+import {
+  FlagMessage,
+  HtmlFormatter,
+  Modal,
+  SafeHTMLInjection,
+  SanitizeLevel,
+  Spinner,
+} from 'design-system';
 import * as React from 'react';
 import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
 import { translate } from '../../../helpers/l10n';
-import { sanitizeStringRestricted } from '../../../helpers/sanitize';
 import { useDismissBranchWarningMutation } from '../../../queries/branch';
 import { TaskWarning } from '../../../types/tasks';
 import { Component } from '../../../types/types';
@@ -51,11 +58,9 @@ export function AnalysisWarningsModal(props: Props) {
           <div className="sw-flex sw-items-center sw-mt-2">
             <FlagMessage variant="warning">
               <HtmlFormatter>
-                <span
-                  // eslint-disable-next-line react/no-danger
-                  dangerouslySetInnerHTML={{
-                    __html: sanitizeStringRestricted(message.trim().replace(/\n/g, '<br />')),
-                  }}
+                <SafeHTMLInjection
+                  htmlAsString={message.trim().replace(/\n/g, '<br />')}
+                  sanitizeLevel={SanitizeLevel.RESTRICTED}
                 />
               </HtmlFormatter>
             </FlagMessage>
index 5b0cf3fbd3f4f065dcfabc1c49f1ba830380c186..75a88ada80df9345b5c3a18c838014d8b1daf6b1 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import styled from '@emotion/styled';
 import {
   DangerButtonPrimary,
@@ -26,6 +27,8 @@ import {
   LightLabel,
   Modal,
   PencilIcon,
+  SafeHTMLInjection,
+  SanitizeLevel,
   TrashIcon,
   themeBorder,
 } from 'design-system';
@@ -34,7 +37,6 @@ import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
 import IssueChangelogDiff from '../../../components/issue/components/IssueChangelogDiff';
 import Avatar from '../../../components/ui/Avatar';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { sanitizeUserInput } from '../../../helpers/sanitize';
 import { Hotspot, ReviewHistoryType } from '../../../types/security-hotspots';
 import { getHotspotReviewHistory } from '../utils';
 import HotspotCommentModal from './HotspotCommentModal';
@@ -45,7 +47,7 @@ export interface HotspotReviewHistoryProps {
   onEditComment: (key: string, comment: string) => void;
 }
 
-export default function HotspotReviewHistory(props: HotspotReviewHistoryProps) {
+export default function HotspotReviewHistory(props: Readonly<HotspotReviewHistoryProps>) {
   const { hotspot } = props;
   const history = getHotspotReviewHistory(hotspot);
   const [editCommentKey, setEditCommentKey] = React.useState('');
@@ -86,11 +88,9 @@ export default function HotspotReviewHistory(props: HotspotReviewHistoryProps) {
 
             {type === ReviewHistoryType.Comment && key && html && markdown && (
               <div className="sw-mt-2 sw-flex sw-justify-between">
-                <CommentBox
-                  className="sw-pl-2 sw-ml-2 sw-typo-default"
-                  // eslint-disable-next-line react/no-danger
-                  dangerouslySetInnerHTML={{ __html: sanitizeUserInput(html) }}
-                />
+                <SafeHTMLInjection htmlAsString={html} sanitizeLevel={SanitizeLevel.USER_INPUT}>
+                  <CommentBox className="sw-pl-2 sw-ml-2 sw-typo-default" />
+                </SafeHTMLInjection>
 
                 {updatable && (
                   <div className="sw-flex sw-gap-6">
index 21ea7d312784cda77afe153b90d0b54e45308a23..1e30ae1f29e886a0a39cf0590eebf200b733fdcd 100644 (file)
@@ -24,6 +24,8 @@ import {
   Card,
   FlagMessage,
   PageContentFontWrapper,
+  SafeHTMLInjection,
+  SanitizeLevel,
   Title,
   themeBorder,
   themeColor,
@@ -33,7 +35,6 @@ import { Helmet } from 'react-helmet-async';
 import { Image } from '~sonar-aligned/components/common/Image';
 import { Location } from '~sonar-aligned/types/router';
 import { translate } from '../../../helpers/l10n';
-import { sanitizeUserInput } from '../../../helpers/sanitize';
 import { getReturnUrl } from '../../../helpers/urls';
 import { IdentityProvider } from '../../../types/types';
 import LoginForm from './LoginForm';
@@ -69,11 +70,9 @@ export default function Login(props: Readonly<LoginProps>) {
               )}
 
               {message !== undefined && message.length > 0 && (
-                <StyledMessage
-                  className="markdown sw-rounded-2 sw-p-4 sw-mb-6"
-                  // eslint-disable-next-line react/no-danger
-                  dangerouslySetInnerHTML={{ __html: sanitizeUserInput(message) }}
-                />
+                <SafeHTMLInjection htmlAsString={message} sanitizeLevel={SanitizeLevel.USER_INPUT}>
+                  <StyledMessage className="markdown sw-rounded-2 sw-p-4 sw-mb-6" />
+                </SafeHTMLInjection>
               )}
 
               {identityProviders.length > 0 && (
index b2635d566e92114854bd29e73a40de74897d357b..8960482135971206c800ac1f5d48cabdc9faad0e 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { Text, Tooltip } from '@sonarsource/echoes-react';
-import { SubHeading } from 'design-system';
+import { SafeHTMLInjection, SanitizeLevel, SubHeading } from 'design-system';
 import * as React from 'react';
 import { translateWithParameters } from '../../../helpers/l10n';
-import { sanitizeStringRestricted } from '../../../helpers/sanitize';
 import { ExtendedSettingDefinition } from '../../../types/settings';
 import { getPropertyDescription, getPropertyName } from '../utils';
 
@@ -40,11 +40,9 @@ export default function DefinitionDescription({ definition }: Readonly<Props>) {
       </SubHeading>
 
       {description && (
-        <div
-          className="markdown sw-mt-1"
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: sanitizeStringRestricted(description) }}
-        />
+        <SafeHTMLInjection htmlAsString={description} sanitizeLevel={SanitizeLevel.RESTRICTED}>
+          <div className="markdown sw-mt-1" />
+        </SafeHTMLInjection>
       )}
 
       <Tooltip content={translateWithParameters('settings.key_x', definition.key)}>
index bea02959d5f37badd0f08875a980cea2e5278dfc..40c4b3a8362db5fcd5ad8288e58336c21c8f5f5f 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { BasicSeparator, Note, SubTitle } from 'design-system';
+
+import { BasicSeparator, Note, SafeHTMLInjection, SanitizeLevel, SubTitle } from 'design-system';
 import { groupBy, sortBy } from 'lodash';
 import * as React from 'react';
 import { withRouter } from '~sonar-aligned/components/hoc/withRouter';
 import { Location } from '~sonar-aligned/types/router';
-import { sanitizeStringRestricted } from '../../../helpers/sanitize';
 import { SettingDefinitionAndValue } from '../../../types/settings';
 import { Component } from '../../../types/types';
 import { SUB_CATEGORY_EXCLUSIONS } from '../constants';
@@ -93,15 +93,16 @@ class SubCategoryDefinitionsList extends React.PureComponent<SubCategoryDefiniti
                 {subCategory.name}
               </SubTitle>
             )}
+
             {subCategory.description != null && (
-              <Note
-                className="markdown"
-                // eslint-disable-next-line react/no-danger
-                dangerouslySetInnerHTML={{
-                  __html: sanitizeStringRestricted(subCategory.description),
-                }}
-              />
+              <SafeHTMLInjection
+                htmlAsString={subCategory.description}
+                sanitizeLevel={SanitizeLevel.RESTRICTED}
+              >
+                <Note className="markdown" />
+              </SafeHTMLInjection>
             )}
+
             <BasicSeparator className="sw-mt-6" />
             <DefinitionsList
               component={component}
index d2372b36a5f90dd32ad2c637c415b88cb9de1a07..d57d717c4fbef6eaac869685ae9dccd6a3fa86a4 100644 (file)
@@ -24,13 +24,14 @@ import {
   HtmlFormatter,
   InputTextArea,
   PencilIcon,
+  SafeHTMLInjection,
+  SanitizeLevel,
   themeBorder,
   themeColor,
 } from 'design-system';
 import * as React from 'react';
 import FormattingTipsWithLink from '../../../../components/common/FormattingTipsWithLink';
 import { translate } from '../../../../helpers/l10n';
-import { sanitizeUserInput } from '../../../../helpers/sanitize';
 import { DefaultSpecializedInputProps, getPropertyName } from '../../utils';
 
 function InputForFormattedText(
@@ -64,10 +65,12 @@ function InputForFormattedText(
   ) : (
     <>
       <HtmlFormatter>
-        <FormattedPreviewBox
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: sanitizeUserInput(formattedValue ?? '') }}
-        />
+        <SafeHTMLInjection
+          htmlAsString={formattedValue ?? ''}
+          sanitizeLevel={SanitizeLevel.USER_INPUT}
+        >
+          <FormattedPreviewBox />
+        </SafeHTMLInjection>
       </HtmlFormatter>
 
       <ButtonSecondary className="sw-mt-2" onClick={props.onEditing} icon={<PencilIcon />}>
index 8666c9bdbc24b1b04172a9d19921dea2a3132d11..9beb4beb9af7e61edeec5fdeb5b733de1f1ea712 100644 (file)
@@ -17,7 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { Badge, Card, LinkBox, LinkIcon, SubHeading, Tabs } from 'design-system';
+
+import { Badge, Card, LinkBox, LinkIcon, SafeHTMLInjection, SubHeading, Tabs } from 'design-system';
 import * as React from 'react';
 import { queryToSearchString } from '~sonar-aligned/helpers/urls';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
@@ -95,11 +96,9 @@ export default function Action(props: Props) {
         {action.deprecatedSince && <DeprecatedBadge since={action.deprecatedSince} />}
       </header>
 
-      <div
-        className="sw-mt-4 markdown"
-        // Safe: comes from the backend
-        dangerouslySetInnerHTML={{ __html: action.description }}
-      />
+      <SafeHTMLInjection htmlAsString={action.description}>
+        <div className="sw-mt-4 markdown" />
+      </SafeHTMLInjection>
 
       <div className="sw-mt-4">
         <Tabs options={tabOptions} onChange={(opt) => setTab(opt)} value={tab} />
index 711dc6c8c4a1ffd30c48d442939faa8c9e5a5c2d..76ed0840db713bd07ce0f86fa339c764dc695a33 100644 (file)
@@ -17,7 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { SubTitle } from 'design-system';
+
+import { SafeHTMLInjection, SubTitle } from 'design-system';
 import { isEmpty } from 'lodash';
 import * as React from 'react';
 import { WebApi } from '../../../types/types';
@@ -45,11 +46,9 @@ export default function Domain({ domain, query }: Props) {
       </header>
 
       {!isEmpty(domain.description) && (
-        <div
-          className="sw-mt-3 markdown"
-          // Safe: comes from the backend
-          dangerouslySetInnerHTML={{ __html: domain.description }}
-        />
+        <SafeHTMLInjection htmlAsString={domain.description}>
+          <div className="sw-mt-3 markdown" />
+        </SafeHTMLInjection>
       )}
 
       <div className="sw-mt-4 sw-flex sw-flex-col sw-gap-4">
index 865fc766b09d4cec99567b296ba36ddc1cf8c66b..b88f080e86ecfa483f48249c82a132f34d874180 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { ContentCell, DarkLabel, HtmlFormatter, Note, Table, TableRow } from 'design-system';
+
+import {
+  ContentCell,
+  DarkLabel,
+  HtmlFormatter,
+  Note,
+  SafeHTMLInjection,
+  Table,
+  TableRow,
+} from 'design-system';
 import * as React from 'react';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { WebApi } from '../../../types/types';
@@ -108,11 +117,9 @@ export default class Params extends React.PureComponent<Props> {
               {this.renderKey(param)}
 
               <ContentCell>
-                <div
-                  className="markdown"
-                  // Safe: comes from the backend
-                  dangerouslySetInnerHTML={{ __html: param.description }}
-                />
+                <SafeHTMLInjection htmlAsString={param.description}>
+                  <div className="markdown" />
+                </SafeHTMLInjection>
               </ContentCell>
 
               <ContentCell>
index ca1341902e4fbb2ee164a03b84bff8f25ae3b917..a5cf16b7a96a36675d1292b2f74cc2beff67f8af 100644 (file)
@@ -22,6 +22,7 @@ import {
   CodeSyntaxHighlighter,
   FlagMessage,
   HtmlFormatter,
+  SanitizeLevel,
   ToggleButton,
   themeBorder,
   themeColor,
@@ -30,7 +31,6 @@ import * as React from 'react';
 import { RuleDescriptionSection, RuleDescriptionSections } from '../../apps/coding-rules/rule';
 import applyCodeDifferences from '../../helpers/code-difference';
 import { translate, translateWithParameters } from '../../helpers/l10n';
-import { sanitizeString } from '../../helpers/sanitize';
 import { isDefined } from '../../helpers/types';
 import { Cve as CveDetailsType } from '../../types/cves';
 import { CveDetails } from './CveDetails';
@@ -147,8 +147,9 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
           {isDefined(introductionSection) && (
             <CodeSyntaxHighlighter
               className="rule-desc"
-              htmlAsString={sanitizeString(introductionSection)}
+              htmlAsString={introductionSection}
               language={language}
+              sanitizeLevel={SanitizeLevel.FORBID_SVG_MATHML}
             />
           )}
           {defaultContext && (
@@ -180,8 +181,9 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
             <OtherContextOption />
           ) : (
             <CodeSyntaxHighlighter
-              htmlAsString={sanitizeString(selectedContext.content)}
+              htmlAsString={selectedContext.content}
               language={language}
+              sanitizeLevel={SanitizeLevel.FORBID_SVG_MATHML}
             />
           )}
 
@@ -200,14 +202,16 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
         {isDefined(introductionSection) && (
           <CodeSyntaxHighlighter
             className="rule-desc"
-            htmlAsString={sanitizeString(introductionSection)}
+            htmlAsString={introductionSection}
             language={language}
+            sanitizeLevel={SanitizeLevel.FORBID_SVG_MATHML}
           />
         )}
 
         <CodeSyntaxHighlighter
-          htmlAsString={sanitizeString(sections[0].content)}
+          htmlAsString={sections[0].content}
           language={language}
+          sanitizeLevel={SanitizeLevel.FORBID_SVG_MATHML}
         />
 
         {cve && <CveDetails cve={cve} />}
index fe90bf7bfa6804b88fc717c1448fdc4c05c72246..f65317b396d19fb08b492a01ee94ea5c5b57b3e9 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { render } from '@testing-library/react';
+import { SafeHTMLInjection } from 'design-system/lib';
 import React from 'react';
 import applyCodeDifferences from '../code-difference';
 
@@ -146,12 +147,8 @@ public void endpoint(HttpServletRequest request, HttpServletResponse response) t
 
 function renderDom(codeSnippet: string) {
   return render(
-    <div
-      className="markdown"
-      // eslint-disable-next-line react/no-danger
-      dangerouslySetInnerHTML={{
-        __html: codeSnippet,
-      }}
-    />,
+    <SafeHTMLInjection htmlAsString={codeSnippet}>
+      <div className="markdown" />
+    </SafeHTMLInjection>,
   );
 }
diff --git a/server/sonar-web/src/main/js/helpers/__tests__/sanitize-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/sanitize-test.ts
deleted file mode 100644 (file)
index f219b15..0000000
+++ /dev/null
@@ -1,160 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2024 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.
- */
-import { sanitizeString, sanitizeStringRestricted } from '../sanitize';
-
-describe('sanitizeStringRestricted', () => {
-  it('should preserve only specific formatting tags', () => {
-    expect(
-      sanitizeStringRestricted(`
-    Hi <a href="http://example.com" target="_blank">this</a> is <i>in italics</i> and <ul>
-    <li> lists </li>
-    <li> are allowed</li>
-    </ul>
-    <p>
-    as well. This is <b>Amazing</b> and this <strong>bold</strong> <br>
-    and <code>code.is.accepted too</code>
-    </p>
-  `),
-    ).toBe(`
-    Hi <a target="_blank" href="http://example.com">this</a> is <i>in italics</i> and <ul>
-    <li> lists </li>
-    <li> are allowed</li>
-    </ul>
-    <p>
-    as well. This is <b>Amazing</b> and this <strong>bold</strong> <br>
-    and <code>code.is.accepted too</code>
-    </p>
-  `);
-  });
-
-  /*
-   * Test code borrowed from OWASP's sanitizer tests
-   * https://github.com/OWASP/java-html-sanitizer/blob/master/src/test/resources/org/owasp/html/htmllexerinput1.html
-   */
-  it('should strip everything else', () => {
-    const clean = sanitizeStringRestricted(`<?xml version="not-even-close"?>
-
-    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
-    
-    <!-- a test input for HtmlLexer -->
-    
-    <html>
-    <head>
-    <title>Test File For HtmlLexer &amp; HtmlParser</title>
-    <link rel=stylesheet type="text/css" src=foo/bar.css />
-    <body
-     bgcolor=white
-     linkcolor = "blue"
-     onload="document.writeln(
-      &quot;&lt;p&gt;properly escaped code in a handler&lt;/p&gt;&quot;);"
-    >
-    
-    <script type="text/javascript"><!--
-    document.writeln("<p>Some initialization code in global context</p>");
-    --></script>
-    
-    <script type="text/javascript">
-    // hi there
-    document.writeln("<p>More initialization</p>");
-    </script>
-    
-    <div id=clickydiv onclick="handleClicky(event)"
-     ondblclick=this.onclick(event);return(false)>
-    Clicky
-    </div>
-    
-    <input id=foo>
-    <gxp:attr name="onchange">alert("&lt;b&gt;hi&lt;/b&gt;");</gxp:attr>
-    </input>
-    
-    <pre>&lt;div id=notarealtag onclick=notcode()&gt;</pre>
-    
-    <!-- some tokenization corner cases -->
-    
-    < notatag <atag/>
-    
-    </ notatag> </redundantlyclosed/>
-    
-    <messyattributes a=b=c d="e"f=g h =i j= k l = m checked n="o"/>
-    
-    < < < all in one text block > > >
-    
-    <xmp>Make sure that <!-- comments don't obscure the xmp close</xmp>
-    <% # some php code here
-    write("<pre>$horriblySyntacticConstruct1</pre>\n\n");
-    %>
-    <script type="text/javascript"><!--
-    alert("hello world");
-    // --></script>
-    
-    <script>/* </script> */alert('hi');</script>
-    <script><!--/* </script> */alert('hi');--></script>
-    
-    <xmp style=color:blue><!--/* </xmp> */alert('hi');--></xmp>
-    
-    <style><!-- p { contentf: '</style>' } --></style>
-    <style>Foo<!-- > </style> --></style>
-    <textarea><!-- Zoicks </textarea>--></textarea>
-    <!-- An escaping text span start may share its U+002D HYPHEN-MINUS characters
-       - with its corresponding escaping text span end. -->
-    <script><!--></script>
-    <script><!---></script>
-    <script><!----></script>
-    </body>
-    </html>
-    <![CDATA[ No such thing as a CDATA> section in HTML ]]>
-    <script>a<b</script>
-    <img src=foo.gif /><a href=><a href=/>
-    <span title=malformed attribs' do=don't id=foo checked onclick="a<b">Bar</span>`);
-
-    expect(clean.replace(/\s+/g, '')).toBe(
-      `Clickyalert("&lt;b&gt;hi&lt;/b&gt;");&lt;divid=notarealtagonclick=notcode()&gt;&lt;notatag&lt;&lt;&lt;allinonetextblock&gt;&gt;&gt;&lt;%#somephpcodeherewrite("$horriblySyntacticConstruct1");%&gt;*/alert('hi');*/alert('hi');--&gt;*/alert('hi');--&gt;'}--&gt;--&gt;&lt;!--Zoicks--&gt;sectioninHTML]]&gt;<ahref=""></a><ahref="/">Bar</a>`,
-    );
-  });
-});
-
-describe('sanitizeString', () => {
-  it('should not allow MathML and SVG', () => {
-    const tainted = `
-    Hi <a href="javascript:alert('hello')" target="_blank">this</a> is <i>in italics</i> and <ul>
-    <li> lists </li>
-    <li> are allowed</li>
-    </ul>
-    <p class="some-class">
-    as well. This is <b>Amazing</b> and this <strong>bold</strong> <br>
-    and <code>code.is.accepted too</code>
-    </p>
-    <svg><text>SVG isn't allowed</text></svg>
-    <math xmlns="http://www.w3.org/1998/Math/MathML">
-      <infinity />
-    </math>`;
-    const clean = `
-    Hi <a>this</a> is <i>in italics</i> and <ul>
-    <li> lists </li>
-    <li> are allowed</li>
-    </ul>
-    <p class="some-class">
-    as well. This is <b>Amazing</b> and this <strong>bold</strong> <br>
-    and <code>code.is.accepted too</code>
-    </p>`;
-
-    expect(sanitizeString(tainted).trimRight()).toBe(clean);
-  });
-});
index ff7653546b2047a8769b78ede18c7907b74bdb9f..2caaae1046010d70d7943c315c902b849de77592 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { sanitizeHTMLNoSVGNoMathML } from 'design-system';
 import { diffLines } from 'diff';
 import { groupBy, keyBy } from 'lodash';
-import { sanitizeString } from './sanitize';
 
 const NUMBER_OF_EXAMPLES = 2;
 
@@ -85,7 +86,7 @@ function differentiateCode(compliant: string, nonCompliant: string) {
       nonCompliantCode += `<div class='code-removed'>${value}</div>`;
     }
   });
-  return [sanitizeString(nonCompliantCode), sanitizeString(compliantCode)];
+  return [sanitizeHTMLNoSVGNoMathML(nonCompliantCode), sanitizeHTMLNoSVGNoMathML(compliantCode)];
 }
 
 function replaceInDom(current: Element, code: string) {
diff --git a/server/sonar-web/src/main/js/helpers/sanitize.ts b/server/sonar-web/src/main/js/helpers/sanitize.ts
deleted file mode 100644 (file)
index acdae57..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2024 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.
- */
-import { sanitize } from 'dompurify';
-
-export function sanitizeStringRestricted(html: string) {
-  return sanitize(html, {
-    ALLOWED_TAGS: ['b', 'br', 'code', 'i', 'li', 'p', 'strong', 'ul', 'a'],
-    ALLOWED_ATTR: ['target', 'href'],
-  });
-}
-
-export function sanitizeString(html: string) {
-  return sanitize(html, { USE_PROFILES: { html: true } });
-}
-
-export function sanitizeUserInput(html: string) {
-  return sanitize(html, {
-    ALLOWED_TAGS: [
-      'b',
-      'br',
-      'code',
-      'i',
-      'li',
-      'p',
-      'strong',
-      'ul',
-      'ol',
-      'a',
-      'h1',
-      'h2',
-      'h3',
-      'h4',
-      'h5',
-      'h6',
-      'blockquote',
-      'pre',
-    ],
-    ALLOWED_ATTR: ['target', 'href', 'rel'],
-  });
-}
index b8a763a5a3d9114046afabeeba825181526957ea..62f81b59ef4fb12a0b051e51cbe3e98d5fd9b52a 100644 (file)
@@ -8723,6 +8723,7 @@ __metadata:
     d3-shape: 3.2.0
     d3-zoom: 3.0.0
     date-fns: 4.1.0
+    dompurify: 3.1.7
     lodash: 4.17.21
     react: 18.3.1
     react-day-picker: 8.10.0