diff options
author | David Cho-Lerat <david.cho-lerat@sonarsource.com> | 2024-10-03 10:09:00 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-10-08 20:02:47 +0000 |
commit | c9ecf7fa2f9af18f474a2aea2c7c3cefd3a6e80f (patch) | |
tree | 40b9f0694c1bd3baaee84ade9fa4059b851c7ae2 /server/sonar-web/design-system | |
parent | 12664adb48e6b95e521b0e05d81f916af2a88bc4 (diff) | |
download | sonarqube-c9ecf7fa2f9af18f474a2aea2c7c3cefd3a6e80f.tar.gz sonarqube-c9ecf7fa2f9af18f474a2aea2c7c3cefd3a6e80f.zip |
SONAR-23249 Fix SSF-656 & SSF-657
Diffstat (limited to 'server/sonar-web/design-system')
8 files changed, 470 insertions, 19 deletions
diff --git a/server/sonar-web/design-system/.eslintrc b/server/sonar-web/design-system/.eslintrc index b6218abddcd..01e7937531f 100644 --- a/server/sonar-web/design-system/.eslintrc +++ b/server/sonar-web/design-system/.eslintrc @@ -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", diff --git a/server/sonar-web/design-system/package.json b/server/sonar-web/design-system/package.json index 866433a8085..2e459a6c9ee 100644 --- a/server/sonar-web/design-system/package.json +++ b/server/sonar-web/design-system/package.json @@ -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", diff --git a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx index 57cd229e71f..693c3cdde83 100644 --- a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx +++ b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx @@ -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> ); } diff --git a/server/sonar-web/design-system/src/components/Text.tsx b/server/sonar-web/design-system/src/components/Text.tsx index 08437dcce22..25df5241e02 100644 --- a/server/sonar-web/design-system/src/components/Text.tsx +++ b/server/sonar-web/design-system/src/components/Text.tsx @@ -17,10 +17,12 @@ * 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} diff --git a/server/sonar-web/design-system/src/helpers/index.ts b/server/sonar-web/design-system/src/helpers/index.ts index 73dd1464b22..7af0536ce90 100644 --- a/server/sonar-web/design-system/src/helpers/index.ts +++ b/server/sonar-web/design-system/src/helpers/index.ts @@ -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 index 00000000000..b8be9b99daf --- /dev/null +++ b/server/sonar-web/design-system/src/sonar-aligned/helpers/__tests__/sanitize-test.tsx @@ -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 & HtmlParser</title> +<link rel=stylesheet type="text/css" src=foo/bar.css /> +<body + bgcolor=white + linkcolor = "blue" + onload="document.writeln( + "<p>properly escaped code in a handler</p>");" +> + +<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("<b>hi</b>");</gxp:attr> +</input> + +<pre><div id=notarealtag onclick=notcode()></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("<b>hi</b>"); + <pre><div id=notarealtag onclick=notcode()></pre> + < notatag + < < < all in one text block > > > + <% # some php code here + write("<pre>$horriblySyntacticConstruct1</pre> + "); + %> + */alert('hi'); + */alert('hi');--> + */alert('hi');--> ' } --> + --> + <textarea><!-- Zoicks </textarea>--> + 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 ]]> + <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("<b>hi</b>"); + <pre><div id=notarealtag onclick=notcode()></pre> + < notatag + < < < all in one text block > > > + <% # some php code here + write("<pre>$horriblySyntacticConstruct1</pre> + "); + %> + */alert('hi'); + */alert('hi');--> + */alert('hi');--> ' } --> + --> + <textarea><!-- Zoicks </textarea>--> + 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 ]]> + <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("<b>hi</b>"); + <pre><div id=notarealtag onclick=notcode()></pre> + < notatag + < < < all in one text block > > > + <% # some php code here + write("<pre>$horriblySyntacticConstruct1</pre> + "); + %> + */alert('hi'); + */alert('hi');--> + */alert('hi');--> ' } --> + --> + <!-- Zoicks --> + 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 ]]> + <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("<b>hi</b>"); + <div id=notarealtag onclick=notcode()> + < notatag + < < < all in one text block > > > + <% # some php code here + write("$horriblySyntacticConstruct1 + "); + %> + */alert('hi'); + */alert('hi');--> + */alert('hi');--> ' } --> + --> + <!-- Zoicks --> + 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 ]]> + <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(); + }); +}); diff --git a/server/sonar-web/design-system/src/sonar-aligned/helpers/index.ts b/server/sonar-web/design-system/src/sonar-aligned/helpers/index.ts index 863797b2d1f..e288f05e8ee 100644 --- a/server/sonar-web/design-system/src/sonar-aligned/helpers/index.ts +++ b/server/sonar-web/design-system/src/sonar-aligned/helpers/index.ts @@ -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 index 00000000000..1426c567efd --- /dev/null +++ b/server/sonar-web/design-system/src/sonar-aligned/helpers/sanitize.tsx @@ -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) }, + }); |