Skip to content

Commit 5b991d0

Browse files
authored
Merge pull request #6 from Cascades-CSS/sanitization
Add output sanitization
2 parents 88f69d3 + cdd8c87 commit 5b991d0

File tree

10 files changed

+252
-20
lines changed

10 files changed

+252
-20
lines changed

.github/workflows/unit-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jobs:
1818
ignored,
1919
import,
2020
nth-child,
21+
sanitize,
2122
selector
2223
]
2324
steps:

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ An options object can be passed as the second argument to `cssToHtml()` to custo
8686
| `fill` | `fill` * | Fill the DOM with duplicate elements up to the desired location. Eg: <br/> `span#fourth:nth-child(4) {}` <br/> Will become: <br/> `<span></span><span></span><span></span><span id="fourth"></span>`. |
8787
| | `no-fill` | Don't fill. Eg: <br/> `span#fourth:nth-child(4) {}` <br/> Will become: <br/> `<span id="fourth"></span>`. |
8888
| `imports` | `include` | Fetch imported stylesheets and include them in the HTML generation process. |
89-
| | `style-only` * | Ignore `@import` rules. |
89+
| | `style-only` * | Ignore `@import` rules when generating HTML. |
9090
| `mergeNth` | `merge` * | Elements generated from `:nth-` selectors will be merged with any similar element occupying the desired location. |
9191
| | `no-merge` | These elements will not be merged. |
92+
| `sanitize` | `all` * | Sanitize the generated HTML using [DOMPurify](https://github.com/cure53/DOMPurify). |
93+
| | `imports` | Only sanitize the HTML generated from imported stylesheets. |
94+
| | `off` | Don't sanitize the generated HTML. |

package-lock.json

Lines changed: 44 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@
3131
},
3232
"homepage": "https://github.com/CSS-Canvas/CSS-to-HTML#readme",
3333
"devDependencies": {
34-
"@playwright/test": "^1.35.1"
34+
"@playwright/test": "^1.35.1",
35+
"@types/dompurify": "^3.0.5"
3536
},
3637
"dependencies": {
37-
"css-selector-parser": "^2.3.2"
38+
"css-selector-parser": "^2.3.2",
39+
"dompurify": "^3.1.2"
3840
}
3941
}

src/Descriptor.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { AstRule } from 'css-selector-parser';
2-
import { replaceTextNode } from './Utility.js';
2+
import { replaceTextNode, sanitizeElement } from './Utility.js';
33

44
/**
55
* Valid positioning pseudo classes.
@@ -44,8 +44,8 @@ function parsePositionFormula (a: number, b: number): number | false {
4444
* Describes an element based on pieces of a selector.
4545
*/
4646
export class Descriptor {
47-
public rule;
48-
public element;
47+
public rule: AstRule;
48+
public element: HTMLElement;
4949
public combinator = '';
5050
public position = {
5151
explicit: false,
@@ -55,9 +55,11 @@ export class Descriptor {
5555
};
5656
public invalid = false;
5757
private rawContent = '';
58+
private sanitize = false;
5859

59-
constructor (rule: AstRule, content?: string) {
60+
constructor (rule: AstRule, content?: string, sanitize = false) {
6061
this.rule = rule;
62+
this.sanitize = sanitize;
6163

6264
// Create the element.
6365
let tag = 'div';
@@ -96,6 +98,11 @@ export class Descriptor {
9698
}
9799
}
98100

101+
// Sanitize the element.
102+
if (this.sanitize) {
103+
this.element = sanitizeElement(this.element);
104+
}
105+
99106
// Set the content.
100107
if (content) {
101108
this.content = content;
@@ -170,6 +177,11 @@ export class Descriptor {
170177
else {
171178
replaceTextNode(this.element, value);
172179
}
180+
181+
// Sanitize the element.
182+
if (this.sanitize) {
183+
this.element = sanitizeElement(this.element);
184+
}
173185
}
174186

175187
/**

src/Generator.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
import { createParser } from 'css-selector-parser';
2-
import type { AstRule } from 'css-selector-parser';
2+
import type { AstRule, AstSelector } from 'css-selector-parser';
33
import { Descriptor } from './Descriptor.js';
4-
import { createCSSOM, elementsAreComparable, mergeElements } from './Utility.js';
4+
import { createCSSOM, elementsAreComparable, mergeElements, sanitizeElement } from './Utility.js';
55

66
export class Options {
77
duplicates?: 'preserve' | 'remove';
88
fill?: 'fill' | 'no-fill';
99
imports?: 'include' | 'style-only';
1010
mergeNth?: 'merge' | 'no-merge';
11+
sanitize?: 'all' | 'imports' | 'off';
1112
}
1213

1314
const parse = createParser({ syntax: 'progressive' });
1415

1516
class Rule {
16-
rule;
17-
selectorAst;
17+
rule: CSSStyleRule;
18+
sanitize: boolean;
19+
selectorAst: AstSelector;
1820

19-
constructor (rule: CSSStyleRule) {
21+
constructor (rule: CSSStyleRule, sanitize = false) {
2022
this.rule = rule;
23+
this.sanitize = sanitize;
2124
this.selectorAst = parse(rule.selectorText);
2225
}
2326
}
@@ -47,12 +50,12 @@ export async function cssToHtml (css: CSSRuleList | string, options: Options = {
4750
const rules = new Array<Rule>();
4851
const importSet = new Set<string>();
4952

50-
async function parseRules (source: CSSRuleList, urlBase: string): Promise<void> {
53+
async function parseRules (source: CSSRuleList, urlBase: string, sanitize?: boolean): Promise<void> {
5154
let seenStyleRule = false;
5255
for (const rule of Object.values(source!)) {
5356
if (rule instanceof CSSStyleRule) {
5457
seenStyleRule = true;
55-
rules.push(new Rule(rule));
58+
rules.push(new Rule(rule, sanitize));
5659
}
5760
// Fetch the content of imported stylesheets.
5861
else if (rule instanceof CSSImportRule && !seenStyleRule && options.imports === 'include') {
@@ -63,23 +66,23 @@ export async function cssToHtml (css: CSSRuleList | string, options: Options = {
6366
if (resource.status !== 200) throw new Error(`Response status for stylesheet "${url.href}" was ${resource.status}.`);
6467
const text = await resource.text();
6568
const importedRule = createCSSOM(text);
66-
if (importedRule) await parseRules(importedRule, url.href);
69+
if (importedRule) await parseRules(importedRule, url.href, options.sanitize === 'imports');
6770
}
6871
}
6972
}
7073
}
7174
await parseRules(styleRules, window.location.href);
7275

7376
// Populate the DOM.
74-
for (const { rule, selectorAst } of rules) {
77+
for (const { rule, sanitize, selectorAst } of rules) {
7578
// Traverse each rule nest of the selector AST.
7679
for (const r of selectorAst.rules) {
7780
const nest = new Array<Descriptor>();
7881
let invalidNest = false;
7982
// Create a descriptor for each of the nested selectors.
8083
let next: AstRule | undefined = r;
8184
do {
82-
const descriptor = new Descriptor(next);
85+
const descriptor = new Descriptor(next, undefined, sanitize);
8386
if (descriptor.invalid) {
8487
invalidNest = true;
8588
next = undefined;
@@ -180,5 +183,13 @@ export async function cssToHtml (css: CSSRuleList | string, options: Options = {
180183
}
181184
}
182185

186+
if (options.sanitize !== 'off' && options.sanitize !== 'imports') {
187+
const cleanHtml = sanitizeElement(output);
188+
if (cleanHtml instanceof HTMLBodyElement) return cleanHtml;
189+
const body = document.createElement('body');
190+
body.append(cleanHtml);
191+
return body;
192+
}
193+
183194
return output;
184195
}

src/Utility.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import DOMPurify from 'dompurify';
2+
13
/**
24
* Create a CSS Object Model from a string of CSS.
35
* @param css The CSS string.
@@ -80,3 +82,16 @@ export function replaceTextNode (element: HTMLElement | Element, value: string):
8082
element.append(value);
8183
}
8284
}
85+
86+
/**
87+
* Sanitize a given HTML element with DOMPurify.
88+
* @param element The element to sanitize.
89+
* @returns A sanitized copy of the given element.
90+
*/
91+
export function sanitizeElement (element: HTMLElement): HTMLElement {
92+
const sanitizedElement = DOMPurify.sanitize(element, { RETURN_DOM: true });
93+
if (sanitizedElement instanceof HTMLBodyElement && !(element instanceof HTMLBodyElement)) {
94+
return sanitizedElement.firstElementChild as HTMLElement;
95+
}
96+
return sanitizedElement;
97+
}

test-server/public/import4.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
img.dangerous[onload="console.log('danger')"] {
2+
content: 'image.png';
3+
}

0 commit comments

Comments
 (0)