Repository navigation
refactor: [M3-7990] - Replace sanitize-html with DOM friendly alternative #10378
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
39ecacd
12b6279
ae93738
093abe0
32bd50b
f8859a6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@linode/manager": Tech Stories | ||
| --- | ||
|
|
||
| Replace sanitize-html with dompurify ([#10378](https://github.com/linode/manager/pull/10378)) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,6 @@ import { useTheme } from '@mui/material'; | |
| import { useSnackbar } from 'notistack'; | ||
| import * as React from 'react'; | ||
| import { useParams } from 'react-router-dom'; | ||
| import sanitize from 'sanitize-html'; | ||
|
|
||
| import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; | ||
| import { Drawer } from 'src/components/Drawer'; | ||
|
|
@@ -19,6 +18,7 @@ import { | |
| import { useGrants, useProfile } from 'src/queries/profile'; | ||
| import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; | ||
| import { getEntityIdsByPermission } from 'src/utilities/grants'; | ||
| import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; | ||
|
|
||
| interface Props { | ||
| helperText: string; | ||
|
|
@@ -94,10 +94,14 @@ export const AddLinodeDrawer = (props: Props) => { | |
| }; | ||
|
|
||
| const errorNotice = () => { | ||
| let errorMsg = sanitize(localError || '', { | ||
| allowedAttributes: {}, | ||
| allowedTags: [], // Disallow all HTML tags, | ||
| }); | ||
| let errorMsg = sanitizeHTML({ | ||
| sanitizeOptions: { | ||
| ALLOWED_ATTR: [], | ||
| ALLOWED_TAGS: [], // Disallow all HTML tags, | ||
| }, | ||
| sanitizingTier: 'strict', | ||
| text: localError || '', | ||
| }).toString(); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we need to satisfy the types here with toString() would love to confirm those changes are good with people familiar with this particular Firewall files. Maybe @carrillo-erik ? |
||
| // match something like: Linode <linode_label> (ID <linode_id>) | ||
|
|
||
| const linode = /Linode (.+?) \(ID ([^\)]+)\)/i.exec(errorMsg); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,77 +1,81 @@ | ||
| import sanitize from 'sanitize-html'; | ||
| import DOMPurify from 'dompurify'; | ||
|
|
||
| import { allowedHTMLAttr } from 'src/constants'; | ||
|
|
||
| import { getAllowedHTMLTags, isURLValid } from './sanitizeHTML.utils'; | ||
|
|
||
| import type { AllowedHTMLTagsTier } from './sanitizeHTML.utils'; | ||
| import type { IOptions } from 'sanitize-html'; | ||
| import type { Config } from 'dompurify'; | ||
|
|
||
| type DisallowedTagsMode = 'discard' | 'escape'; | ||
|
|
||
| export interface SanitizeOptions extends Config { | ||
| disallowedTagsMode?: DisallowedTagsMode; | ||
| } | ||
|
|
||
| interface SanitizeHTMLOptions { | ||
| allowMoreTags?: string[]; | ||
| disallowedTagsMode?: IOptions['disallowedTagsMode']; | ||
| options?: IOptions; | ||
| disallowedTagsMode?: DisallowedTagsMode; | ||
| sanitizeOptions?: Config; | ||
| sanitizingTier: AllowedHTMLTagsTier; | ||
| text: string; | ||
| } | ||
|
|
||
| export const sanitizeHTML = ({ | ||
| allowMoreTags, | ||
| disallowedTagsMode = 'escape', | ||
| options = {}, | ||
| sanitizeOptions: options = {}, | ||
| sanitizingTier, | ||
| text, | ||
| }: SanitizeHTMLOptions) => | ||
| sanitize(text, { | ||
| allowedAttributes: { | ||
| '*': allowedHTMLAttr, | ||
| // "target" and "rel" are allowed because they are handled in the | ||
| // transformTags map below. | ||
| a: [...allowedHTMLAttr, 'target', 'rel'], | ||
| }, | ||
| allowedClasses: { | ||
| span: ['version'], | ||
| }, | ||
| allowedTags: getAllowedHTMLTags(sanitizingTier, allowMoreTags), | ||
| disallowedTagsMode, | ||
| transformTags: { | ||
| // This transformation function does the following to anchor tags: | ||
| // 1. Turns the <a /> into a <span /> if the "href" is invalid | ||
| // 2. Adds `rel="noopener noreferrer" if _target is "blank" (for security) | ||
| // 3. Removes "target" attribute if it's anything other than "_blank" | ||
| // 4. Removes custom "rel" attributes | ||
| }: SanitizeHTMLOptions) => { | ||
| DOMPurify.setConfig({ | ||
| ALLOWED_ATTR: allowedHTMLAttr, | ||
| ALLOWED_TAGS: getAllowedHTMLTags(sanitizingTier, allowMoreTags), | ||
| KEEP_CONTENT: disallowedTagsMode === 'discard' ? false : true, | ||
| RETURN_DOM: false, | ||
| RETURN_DOM_FRAGMENT: false, | ||
| RETURN_TRUSTED_TYPE: false, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. RETURN_TRUSTED_TYPE: false, |
||
| ...options, | ||
| }); | ||
|
|
||
| a: (tagName, attribs) => { | ||
| // If the URL is invalid, transform to a span. | ||
| const href = attribs.href ?? ''; | ||
| if (href && !isURLValid(href)) { | ||
| return { | ||
| attribs: {}, | ||
| tagName: 'span', | ||
| }; | ||
| } | ||
| // Define transform function for anchor tags | ||
| const anchorHandler = (node: HTMLAnchorElement) => { | ||
| const href = node.getAttribute('href') ?? ''; | ||
|
|
||
| // If this link opens a new tab, add "noopener noreferrer" for security. | ||
| const target = attribs.target ?? ''; | ||
| if (target && target === '_blank') { | ||
| return { | ||
| attribs: { | ||
| ...attribs, | ||
| rel: 'noopener noreferrer', | ||
| }, | ||
| tagName, | ||
| }; | ||
| } | ||
| // If the URL is invalid, transform to a span. | ||
| if (href && !isURLValid(href)) { | ||
| const span = document.createElement('span'); | ||
| span.setAttribute('class', node.getAttribute('class') || ''); | ||
|
|
||
| // Otherwise we don't want to allow the "rel" or "target" attributes. | ||
| delete attribs.rel; | ||
| delete attribs.target; | ||
| if (node.parentNode) { | ||
| node.parentNode.replaceChild(span, node); | ||
| } | ||
| } else { | ||
| // If this link opens a new tab, add "noopener noreferrer" for security. | ||
| const target = node.getAttribute('target') || ''; | ||
| if (target === '_blank') { | ||
| node.setAttribute('rel', 'noopener noreferrer'); | ||
| } else { | ||
| node.removeAttribute('rel'); | ||
| node.removeAttribute('target'); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| return { | ||
| attribs, | ||
| tagName, | ||
| }; | ||
| }, | ||
| }, | ||
| ...options, | ||
| }).trim(); | ||
| // Register hooks for DOMPurify | ||
| DOMPurify.addHook('uponSanitizeElement', (node, data) => { | ||
| if (data.tagName === 'a') { | ||
| anchorHandler(node as HTMLAnchorElement); | ||
| } else if (data.tagName === 'span') { | ||
| // Allow class attribute only for span elements | ||
| const classAttr = node.getAttribute('class'); | ||
| if (classAttr && classAttr.trim() !== 'version') { | ||
| node.removeAttribute('class'); | ||
| } | ||
| } | ||
| }); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. legacy handlers which I hope to get rid of soon once we refactor event generators |
||
|
|
||
| // Perform sanitization | ||
| const output = DOMPurify.sanitize(text); | ||
| return output.trim(); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We allow them, but then we strip them all unless it is for
<span class="version" />This will be all cleaned up once we get rid of out eventMessageGenerator transformers, which is part of this epic