diff --git a/packages/block-text/package-lock.json b/packages/block-text/package-lock.json index a890a54..fac17c6 100644 --- a/packages/block-text/package-lock.json +++ b/packages/block-text/package-lock.json @@ -1,18 +1,20 @@ { "name": "@usewaypoint/block-text", - "version": "0.0.4", + "version": "0.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@usewaypoint/block-text", - "version": "0.0.4", + "version": "0.0.5", "license": "MIT", "dependencies": { - "markdown-parser-react": "^1.1.2" + "insane": "^2.6.2", + "marked": "^12.0.2" }, "devDependencies": { - "@testing-library/react": "^14.2.1" + "@testing-library/react": "^14.2.1", + "@types/insane": "^1.0.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18", @@ -234,6 +236,12 @@ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true }, + "node_modules/@types/insane": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/insane/-/insane-1.0.0.tgz", + "integrity": "sha512-9FNbmwdaQezEszc5B/w4kRSpMJMOVj+gX7CKSbBCFO4WPiUqKO3HJlUNXzjtus0w5tF2BOJoKTbyps/Envlg/Q==", + "dev": true + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -315,6 +323,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assignment": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/assignment/-/assignment-2.0.0.tgz", + "integrity": "sha512-naMULXjtgCs9SVUEtyvJNt68aF18em7/W+dhbR59kbz9cXWPEvUkCun2tqlgqRPSqZaKPpqLc5ZnwL8jVmJRvw==" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -650,6 +663,23 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/he/-/he-0.5.0.tgz", + "integrity": "sha512-DoufbNNOFzwRPy8uecq+j+VCPQ+JyDelHTmSgygrA5TsR8Cbw4Qcir5sGtWiusB4BdT89nmlaVDhSJOqC/33vw==", + "bin": { + "he": "bin/he" + } + }, + "node_modules/insane": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/insane/-/insane-2.6.2.tgz", + "integrity": "sha512-BqEL1CJsjJi+/C/zKZxv31zs3r6zkLH5Nz1WMFb7UBX2KHY2yXDpbFTSEmNHzomBbGDysIfkTX55A0mQZ2CQiw==", + "dependencies": { + "assignment": "2.0.0", + "he": "0.5.0" + } + }, "node_modules/internal-slot": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", @@ -882,6 +912,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -898,12 +929,15 @@ "lz-string": "bin/bin.js" } }, - "node_modules/markdown-parser-react": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/markdown-parser-react/-/markdown-parser-react-1.1.2.tgz", - "integrity": "sha512-MNLHekU1xOwKZLJK4NMWJDL9pNnJdKx2jdsHfAF4+Y5rF4tD/S/OuNehd4X46/KcJzBfas19pePVcwQoibpeNg==", - "dependencies": { - "react": "^18.2.0" + "node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" } }, "node_modules/object-inspect": { @@ -997,6 +1031,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, diff --git a/packages/block-text/package.json b/packages/block-text/package.json index 692d336..f5ca587 100644 --- a/packages/block-text/package.json +++ b/packages/block-text/package.json @@ -1,6 +1,6 @@ { "name": "@usewaypoint/block-text", - "version": "0.0.4", + "version": "0.0.5", "description": "@usewaypoint/document compatible Text component", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -25,9 +25,11 @@ "zod": "^1 || ^2 || ^3" }, "devDependencies": { - "@testing-library/react": "^14.2.1" + "@testing-library/react": "^14.2.1", + "@types/insane": "^1.0.0" }, "dependencies": { - "markdown-parser-react": "^1.1.2" + "insane": "^2.6.2", + "marked": "^12.0.2" } } diff --git a/packages/block-text/src/EmailMarkdown.tsx b/packages/block-text/src/EmailMarkdown.tsx new file mode 100644 index 0000000..b390c89 --- /dev/null +++ b/packages/block-text/src/EmailMarkdown.tsx @@ -0,0 +1,106 @@ +import insane, { AllowedTags } from 'insane'; +import { marked, Renderer } from 'marked'; +import React, { CSSProperties, useMemo } from 'react'; + +const ALLOWED_TAGS: AllowedTags[] = [ + 'a', + 'article', + 'b', + 'blockquote', + 'br', + 'caption', + 'code', + 'del', + 'details', + 'div', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'i', + 'img', + 'ins', + 'kbd', + 'li', + 'main', + 'ol', + 'p', + 'pre', + 'section', + 'span', + 'strong', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'th', + 'thead', + 'tr', + 'u', + 'ul', +]; +const GENERIC_ALLOWED_ATTRIBUTES = ['style', 'title']; + +function sanitizer(html: string): string { + return insane(html, { + allowedTags: ALLOWED_TAGS, + allowedAttributes: { + ...ALLOWED_TAGS.reduce>((res, tag) => { + res[tag] = [...GENERIC_ALLOWED_ATTRIBUTES]; + return res; + }, {}), + img: ['src', 'srcset', 'alt', 'width', 'height', ...GENERIC_ALLOWED_ATTRIBUTES], + table: ['width', ...GENERIC_ALLOWED_ATTRIBUTES], + td: ['width', ...GENERIC_ALLOWED_ATTRIBUTES], + a: ['href', 'target', ...GENERIC_ALLOWED_ATTRIBUTES], + }, + }); +} + +class CustomRenderer extends Renderer { + table(header: string, body: string) { + return ` + +${header} + +${body} +
`; + } + + link(href: string, title: string | null, text: string) { + if (!title) { + return `${text}`; + } + return `${text}`; + } +} + +function renderMarkdownString(str: string): string { + const html = marked.parse(str, { + async: false, + breaks: true, + gfm: true, + pedantic: false, + silent: false, + renderer: new CustomRenderer(), + }); + if (typeof html !== 'string') { + throw new Error('marked.parse did not return a string'); + } + return sanitizer(html); +} + +type Props = { + style: CSSProperties; + markdown: string; +}; +export default function EmailMarkdown({ markdown, ...props }: Props) { + const data = useMemo(() => renderMarkdownString(markdown), [markdown]); + return
; +} diff --git a/packages/block-text/src/__snapshots__/index.spec.tsx.snap b/packages/block-text/src/__snapshots__/index.spec.tsx.snap index 5adf23a..dfc9161 100644 --- a/packages/block-text/src/__snapshots__/index.spec.tsx.snap +++ b/packages/block-text/src/__snapshots__/index.spec.tsx.snap @@ -9,11 +9,55 @@ exports[`block-text renders with default values 1`] = ` exports[`block-text renders with safe markdown 1`] = `
-
-

- This is <span>markdown</span> -

-
+

+ This + + text + + block has the + + Markdown + + option + + turned on + + . +

+ + +
    + + +
  • + One +
  • + + +
  • + Two +
  • + + +
  • + Three +
  • + + +
+ + +

+ Powered by + + Waypoint + +

+ +
`; @@ -25,3 +69,94 @@ exports[`block-text renders without markdown 1`] = `
`; + +exports[`block-text sanitizes HTML 1`] = ` + +
+ + + + + + +

+ + a + +
+ + Basic + +
+ + Local Storage + +
+ + CaseInsensitive + +
+ + URL + +

+ + +

+ + In Quotes + +
+ [a](j a v a s c r i p t:prompt(document.cookie)) +
+ + a + +
+ + a + +
+ Uh oh... +
+ Uh oh... +
+ Escape SRC - onload +
+ Escape SRC - onerror +

+ + +
+
+`; diff --git a/packages/block-text/src/index.spec.tsx b/packages/block-text/src/index.spec.tsx index bd26bd6..fb8592d 100644 --- a/packages/block-text/src/index.spec.tsx +++ b/packages/block-text/src/index.spec.tsx @@ -9,12 +9,49 @@ describe('block-text', () => { expect(render().asFragment()).toMatchSnapshot(); }); + it('sanitizes HTML', () => { + expect( + render( + alert(1) + + +[a](javascript:prompt(document.cookie)) +[Basic](javascript:alert('Basic')) +[Local Storage](javascript:alert(JSON.stringify(localStorage))) +[CaseInsensitive](JaVaScRiPt:alert('CaseInsensitive')) +[URL](javascript://www.google.com%0Aalert('URL')) + +[In Quotes]('javascript:alert("InQuotes")') +[a](j a v a s c r i p t:prompt(document.cookie)) +[a](data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K) +[a](javascript:window.onerror=alert;throw%201) +![Uh oh...]("onerror="alert('XSS')) +![Uh oh...](https://www.example.com/image.png"onload="alert('XSS')) +![Escape SRC - onload](https://www.example.com/image.png"onload="alert('ImageOnLoad')) +![Escape SRC - onerror]("onerror="alert('ImageOnError')) +`, + }} + /> + ).asFragment() + ).toMatchSnapshot(); + }); + it('renders with safe markdown', () => { expect( render( markdown`, + text: `This text block has the **Markdown** option *turned on*. + +- One +- Two +- Three + +Powered by [Waypoint](https://usewaypoint.com)`, markdown: true, }} /> diff --git a/packages/block-text/src/index.tsx b/packages/block-text/src/index.tsx index 0cdee67..83d9d3a 100644 --- a/packages/block-text/src/index.tsx +++ b/packages/block-text/src/index.tsx @@ -1,7 +1,8 @@ -import Markdown from 'markdown-parser-react'; import React, { CSSProperties } from 'react'; import { z } from 'zod'; +import EmailMarkdown from './EmailMarkdown'; + const FONT_FAMILY_SCHEMA = z .enum([ 'MODERN_SANS', @@ -101,11 +102,7 @@ export function Text({ style, props }: TextProps) { const text = props?.text ?? TextPropsDefaults.text; if (props?.markdown) { - return ( -
- -
- ); + return ; } return
{text}
; }