tungsten 0.2.24 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ContentNode.d.ts +7 -0
- package/dist/ContentNode.d.ts.map +1 -1
- package/dist/Instance/Instance.d.ts.map +1 -1
- package/dist/Instance/Instance.js +7 -5
- package/dist/Instance/gotoRedirect.sxyt.d.ts +2 -0
- package/dist/Instance/gotoRedirect.sxyt.d.ts.map +1 -0
- package/dist/Instance/gotoRedirect.sxyt.js +148 -0
- package/dist/Servers/DevServer.d.ts +7 -0
- package/dist/Servers/DevServer.d.ts.map +1 -1
- package/dist/Servers/DevServer.js +38 -1
- package/dist/Servers/routing.d.ts.map +1 -1
- package/dist/Servers/routing.js +10 -1
- package/dist/build/compileJavascript.d.ts.map +1 -1
- package/dist/build/compileJavascript.js +86 -1
- package/dist/build/compileStyles.d.ts +1 -1
- package/dist/build/compileStyles.d.ts.map +1 -1
- package/dist/build/compileStyles.js +10 -4
- package/dist/build/compileUserApp.d.ts.map +1 -1
- package/dist/build/compileUserApp.js +7 -2
- package/dist/components/RawHtml.d.ts +36 -0
- package/dist/components/RawHtml.d.ts.map +1 -0
- package/dist/components/RawHtml.js +30 -0
- package/dist/components/RawHtml.sxyt.d.ts +2 -0
- package/dist/components/RawHtml.sxyt.d.ts.map +1 -0
- package/dist/components/RawHtml.sxyt.js +104 -0
- package/dist/dev/liveReloadHub.d.ts +6 -0
- package/dist/dev/liveReloadHub.d.ts.map +1 -0
- package/dist/dev/liveReloadHub.js +12 -0
- package/dist/helpers/getFetchCompatibleHeaders.d.ts.map +1 -1
- package/dist/hydrate/hydrate.d.ts.map +1 -1
- package/dist/hydrate/hydrate.js +11 -4
- package/dist/hydrate/reconcileHydratedAttributes.d.ts +24 -0
- package/dist/hydrate/reconcileHydratedAttributes.d.ts.map +1 -0
- package/dist/hydrate/reconcileHydratedAttributes.js +73 -0
- package/dist/hydrate/reconcileHydratedAttributes.sxyt.d.ts +2 -0
- package/dist/hydrate/reconcileHydratedAttributes.sxyt.d.ts.map +1 -0
- package/dist/hydrate/reconcileHydratedAttributes.sxyt.js +173 -0
- package/dist/indexInner.d.ts +4 -0
- package/dist/indexInner.d.ts.map +1 -1
- package/dist/indexInner.js +3 -0
- package/dist/innerHtmlFromProps.d.ts +16 -0
- package/dist/innerHtmlFromProps.d.ts.map +1 -0
- package/dist/innerHtmlFromProps.js +31 -0
- package/dist/innerHtmlFromProps.sxyt.d.ts +2 -0
- package/dist/innerHtmlFromProps.sxyt.d.ts.map +1 -0
- package/dist/innerHtmlFromProps.sxyt.js +99 -0
- package/dist/postcssPlugins/TransformCssNames.d.ts +14 -1
- package/dist/postcssPlugins/TransformCssNames.d.ts.map +1 -1
- package/dist/postcssPlugins/TransformCssNames.js +97 -95
- package/dist/rawHtml/HtmlAllowList.d.ts +17 -0
- package/dist/rawHtml/HtmlAllowList.d.ts.map +1 -0
- package/dist/rawHtml/HtmlAllowList.js +1 -0
- package/dist/rawHtml/decodeHtmlEntities.d.ts +8 -0
- package/dist/rawHtml/decodeHtmlEntities.d.ts.map +1 -0
- package/dist/rawHtml/decodeHtmlEntities.js +105 -0
- package/dist/rawHtml/htmlParsingTags.d.ts +23 -0
- package/dist/rawHtml/htmlParsingTags.d.ts.map +1 -0
- package/dist/rawHtml/htmlParsingTags.js +55 -0
- package/dist/rawHtml/parseHtml.d.ts +35 -0
- package/dist/rawHtml/parseHtml.d.ts.map +1 -0
- package/dist/rawHtml/parseHtml.js +214 -0
- package/dist/rawHtml/parseHtml.sxyt.d.ts +2 -0
- package/dist/rawHtml/parseHtml.sxyt.d.ts.map +1 -0
- package/dist/rawHtml/parseHtml.sxyt.js +239 -0
- package/dist/rawHtml/safeHtmlAllowList.d.ts +18 -0
- package/dist/rawHtml/safeHtmlAllowList.d.ts.map +1 -0
- package/dist/rawHtml/safeHtmlAllowList.js +47 -0
- package/dist/rawHtml/tokenizeHtml.d.ts +25 -0
- package/dist/rawHtml/tokenizeHtml.d.ts.map +1 -0
- package/dist/rawHtml/tokenizeHtml.js +148 -0
- package/dist/render/processHead.d.ts.map +1 -1
- package/dist/render/processHead.js +10 -0
- package/dist/render/processProps.d.ts.map +1 -1
- package/dist/render/processProps.js +14 -2
- package/dist/render/processProps.sxyt.d.ts +2 -0
- package/dist/render/processProps.sxyt.d.ts.map +1 -0
- package/dist/render/processProps.sxyt.js +90 -0
- package/dist/render/renderingTreeToHtml.d.ts.map +1 -1
- package/dist/render/renderingTreeToHtml.js +8 -0
- package/dist/routing/RouteTypes.d.ts +1 -1
- package/dist/routing/RouteTypes.d.ts.map +1 -1
- package/dist/routing/routeSymbols.d.ts +9 -0
- package/dist/routing/routeSymbols.d.ts.map +1 -0
- package/dist/routing/routeSymbols.js +4 -0
- package/dist/update/integrateUpdateVdom.d.ts.map +1 -1
- package/dist/update/integrateUpdateVdom.js +28 -7
- package/package.json +3 -3
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { $props, $type } from "../contentNodeSymbols.js";
|
|
2
|
+
import { htmlVoidTags } from "../render/htmlVoidTags.js";
|
|
3
|
+
import { tokenizeHtml } from "./tokenizeHtml.js";
|
|
4
|
+
import { htmlRawTextTags, htmlTableStructureTags, impliedEndTag } from "./htmlParsingTags.js";
|
|
5
|
+
const allowEverything = {
|
|
6
|
+
tag: () => true,
|
|
7
|
+
attribute: () => true
|
|
8
|
+
};
|
|
9
|
+
function makeAllowCheck(allowList) {
|
|
10
|
+
if (allowList === undefined) return allowEverything;
|
|
11
|
+
const tags = new Set(allowList.tags);
|
|
12
|
+
const attributes = new Set(allowList.attributes);
|
|
13
|
+
const perTag = new Map();
|
|
14
|
+
for (const tag in allowList.tagAttributes) {
|
|
15
|
+
perTag.set(tag, new Set(allowList.tagAttributes[tag]));
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
tag: name => tags.has(name),
|
|
19
|
+
attribute: (tag, name) => attributes.has(name) || (perTag.get(tag)?.has(name) ?? false)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// REGEX: on followed by at least one letter, e.g. onclick, onerror
|
|
23
|
+
const eventAttribute = /^on[a-z]/i;
|
|
24
|
+
const urlAttributes = {
|
|
25
|
+
href: true,
|
|
26
|
+
src: true,
|
|
27
|
+
action: true,
|
|
28
|
+
formaction: true,
|
|
29
|
+
srcset: true,
|
|
30
|
+
background: true
|
|
31
|
+
};
|
|
32
|
+
/** URL attributes that navigate, where a document-producing scheme is a risk. */
|
|
33
|
+
const navigationAttributes = {
|
|
34
|
+
href: true,
|
|
35
|
+
action: true,
|
|
36
|
+
formaction: true
|
|
37
|
+
};
|
|
38
|
+
const executableSchemes = {
|
|
39
|
+
javascript: true,
|
|
40
|
+
vbscript: true
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The scheme of a URL as a browser would read it, or `''` if it has none.
|
|
44
|
+
*
|
|
45
|
+
* Whitespace and control characters are skipped rather than rejected, because
|
|
46
|
+
* a browser ignores them too — `"java\tscript:alert(1)"` and `" javascript:..."`
|
|
47
|
+
* both run, so a check that did not skip them would be trivially side-stepped.
|
|
48
|
+
*/
|
|
49
|
+
function urlScheme(url) {
|
|
50
|
+
let scheme = '';
|
|
51
|
+
for (const character of url) {
|
|
52
|
+
const code = character.charCodeAt(0);
|
|
53
|
+
if (code <= 0x20 || code === 0x7f) continue;
|
|
54
|
+
if (character === ':') return scheme.toLowerCase();
|
|
55
|
+
scheme += character;
|
|
56
|
+
}
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
function blockedUrl(attributeName, value) {
|
|
60
|
+
const scheme = urlScheme(value);
|
|
61
|
+
if (scheme in executableSchemes) return true;
|
|
62
|
+
// data: URLs can carry a whole HTML document, so they are barred from
|
|
63
|
+
// anything that navigates. They stay allowed on src, where inline images
|
|
64
|
+
// are ordinary and useful.
|
|
65
|
+
return scheme === 'data' && attributeName in navigationAttributes;
|
|
66
|
+
}
|
|
67
|
+
function attributesToProps(tag, attributes, sanitize, allow) {
|
|
68
|
+
const props = {};
|
|
69
|
+
for (const name in attributes) {
|
|
70
|
+
const value = attributes[name];
|
|
71
|
+
if (value === undefined) continue;
|
|
72
|
+
// Tungsten treats on* props as event listeners, not attributes.
|
|
73
|
+
if (eventAttribute.test(name)) continue;
|
|
74
|
+
// Checked against the written HTML name, before the mapping below.
|
|
75
|
+
if (!allow.attribute(tag, name.toLowerCase())) continue;
|
|
76
|
+
if (sanitize && name.toLowerCase() in urlAttributes && typeof value === 'string' && blockedUrl(name.toLowerCase(), value)) continue;
|
|
77
|
+
// `class` is dropped by processProps, which reads `className`, and a
|
|
78
|
+
// `style` string throws in jssToCss, which expects the object form.
|
|
79
|
+
// `css` is the raw-string equivalent it accepts.
|
|
80
|
+
if (name === 'class') props.className = value;else if (name === 'style') props.css = value;else props[name] = value;
|
|
81
|
+
}
|
|
82
|
+
return props;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Whether an element survives, and if not, how it goes.
|
|
86
|
+
*
|
|
87
|
+
* Elements holding program text rather than prose are dropped whole — there is
|
|
88
|
+
* no reader's content inside them to preserve. Everything else is unwrapped.
|
|
89
|
+
*/
|
|
90
|
+
function removalFor(name, sanitize, allow) {
|
|
91
|
+
if (sanitize && name === 'script') return 'drop';
|
|
92
|
+
if (allow.tag(name)) return undefined;
|
|
93
|
+
return name in htmlRawTextTags ? 'drop' : 'unwrap';
|
|
94
|
+
}
|
|
95
|
+
function toContentNode({
|
|
96
|
+
name,
|
|
97
|
+
props,
|
|
98
|
+
children
|
|
99
|
+
}) {
|
|
100
|
+
const nodeProps = {
|
|
101
|
+
...props
|
|
102
|
+
};
|
|
103
|
+
// Match the shape JSX produces: one child is passed directly, not wrapped.
|
|
104
|
+
if (children.length === 1) nodeProps.children = children[0];else if (children.length > 1) nodeProps.children = children;
|
|
105
|
+
return {
|
|
106
|
+
[$type]: name,
|
|
107
|
+
[$props]: nodeProps
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Parses raw HTML into a Tungsten content tree.
|
|
112
|
+
*
|
|
113
|
+
* Never throws, whatever it is given. Unclosed tags are closed, stray end tags
|
|
114
|
+
* are ignored, and anything that is not markup is kept as text — the result is
|
|
115
|
+
* always a renderable tree.
|
|
116
|
+
*
|
|
117
|
+
* The point of parsing rather than injecting the markup is that the result is
|
|
118
|
+
* an ordinary tree: it renders, hydrates and reconciles like any other content,
|
|
119
|
+
* with no opaque region the framework cannot see into. That means the parser
|
|
120
|
+
* has to apply the browser's own nesting rules, or the tree it produces and the
|
|
121
|
+
* DOM the browser builds from the rendered markup would disagree.
|
|
122
|
+
*/
|
|
123
|
+
export function parseHtml(html, options = {}) {
|
|
124
|
+
if (typeof html !== 'string' || html === '') return null;
|
|
125
|
+
const sanitize = options.sanitize !== false;
|
|
126
|
+
const allow = makeAllowCheck(options.allowList);
|
|
127
|
+
const root = {
|
|
128
|
+
name: '',
|
|
129
|
+
props: {},
|
|
130
|
+
children: []
|
|
131
|
+
};
|
|
132
|
+
const stack = [root];
|
|
133
|
+
// Always non-empty: the root is never popped.
|
|
134
|
+
const current = () => stack[stack.length - 1];
|
|
135
|
+
const close = downTo => {
|
|
136
|
+
while (stack.length > downTo) {
|
|
137
|
+
const element = stack.pop(); // eslint-disable-line no-type-assertion/no-type-assertion -- guarded by the loop
|
|
138
|
+
if (element.removal === 'drop') continue;
|
|
139
|
+
if (element.removal === 'unwrap') current().children.push(...element.children);else current().children.push(toContentNode(element));
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
for (const token of tokenizeHtml(html)) {
|
|
143
|
+
if (token.kind === 'text') {
|
|
144
|
+
// a dropped element's text goes with it
|
|
145
|
+
if (current().removal === 'drop') continue;
|
|
146
|
+
if (token.text === '') continue;
|
|
147
|
+
if (current().name in htmlTableStructureTags) {
|
|
148
|
+
// Whitespace between table tags is dropped rather than kept.
|
|
149
|
+
// HTML parsers disagree over whether it survives inside a
|
|
150
|
+
// table, and this tree has to match whichever one the browser
|
|
151
|
+
// uses. Emitting none settles it: there is then nothing in the
|
|
152
|
+
// rendered markup for a parser to have an opinion about, and
|
|
153
|
+
// between table cells it makes no visual difference anyway.
|
|
154
|
+
if (token.text.trim() === '') continue;
|
|
155
|
+
// Foster parenting: a browser relocates text with something in
|
|
156
|
+
// it to just before the enclosing table. That table is still
|
|
157
|
+
// open on the stack, and is pushed to its parent only when it
|
|
158
|
+
// closes, so appending to the parent now places the text ahead
|
|
159
|
+
// of it — exactly where the browser puts it.
|
|
160
|
+
let tableIndex = -1;
|
|
161
|
+
for (let i = stack.length - 1; i > 0; i--) {
|
|
162
|
+
if (stack[i].name === 'table') {
|
|
163
|
+
tableIndex = i;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const host = tableIndex > 0 ? stack[tableIndex - 1] : current();
|
|
168
|
+
host.children.push(token.text);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
current().children.push(token.text);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (token.kind === 'open') {
|
|
175
|
+
// An element that is going away is still opened and tracked, so
|
|
176
|
+
// the markup inside it is accounted for rather than surfacing as
|
|
177
|
+
// stray text in its parent.
|
|
178
|
+
const removal = removalFor(token.name, sanitize, allow);
|
|
179
|
+
// apply the browser's implied end tags before opening
|
|
180
|
+
while (stack.length > 1 && impliedEndTag(current().name, token.name)) {
|
|
181
|
+
close(stack.length - 1);
|
|
182
|
+
}
|
|
183
|
+
const element = {
|
|
184
|
+
name: token.name,
|
|
185
|
+
props: removal === undefined ? attributesToProps(token.name, token.attributes, sanitize, allow) : {},
|
|
186
|
+
children: [],
|
|
187
|
+
...(removal === undefined ? {} : {
|
|
188
|
+
removal
|
|
189
|
+
})
|
|
190
|
+
};
|
|
191
|
+
if (token.selfClosing || token.name in htmlVoidTags) {
|
|
192
|
+
if (removal === undefined) current().children.push(toContentNode(element));
|
|
193
|
+
} else {
|
|
194
|
+
stack.push(element);
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// close token - find the nearest matching open element
|
|
199
|
+
let found = -1;
|
|
200
|
+
for (let i = stack.length - 1; i > 0; i--) {
|
|
201
|
+
if (stack[i].name === token.name) {
|
|
202
|
+
found = i;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// no match: a stray end tag, which browsers ignore
|
|
207
|
+
if (found !== -1) close(found);
|
|
208
|
+
}
|
|
209
|
+
// close anything the markup left open
|
|
210
|
+
close(1);
|
|
211
|
+
if (root.children.length === 0) return null;
|
|
212
|
+
if (root.children.length === 1) return root.children[0] ?? null;
|
|
213
|
+
return root.children;
|
|
214
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parseHtml.sxyt.d.ts","sourceRoot":"","sources":["../../src/rawHtml/parseHtml.sxyt.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe } from 'sxy-test-runner';
|
|
2
|
+
import { expect } from 'chai';
|
|
3
|
+
import { parseHtml } from "./parseHtml.js";
|
|
4
|
+
import { safeHtmlAllowList } from "./safeHtmlAllowList.js";
|
|
5
|
+
import { decodeHtmlEntities } from "./decodeHtmlEntities.js";
|
|
6
|
+
import { tokenizeHtml } from "./tokenizeHtml.js";
|
|
7
|
+
import { renderingNodeToHtml } from "../render/renderingTreeToHtml.js";
|
|
8
|
+
import { $props, $type } from "../contentNodeSymbols.js";
|
|
9
|
+
// `found: true` short-circuits the <head> handling these tests do not need.
|
|
10
|
+
// eslint-disable-next-line no-type-assertion/no-type-assertion
|
|
11
|
+
const noHeadData = {
|
|
12
|
+
found: true
|
|
13
|
+
};
|
|
14
|
+
// Renders a parsed tree back to markup. The renderer takes a RenderingNode,
|
|
15
|
+
// which for plain HTML content is structurally what parseHtml returns.
|
|
16
|
+
const render = async content => renderingNodeToHtml(
|
|
17
|
+
// eslint-disable-next-line no-type-assertion/no-type-assertion
|
|
18
|
+
content, false, noHeadData, null);
|
|
19
|
+
const roundTrip = html => render(parseHtml(html));
|
|
20
|
+
describe('decodeHtmlEntities()', ({
|
|
21
|
+
it
|
|
22
|
+
}) => {
|
|
23
|
+
it('decodes named, decimal and hex references', () => {
|
|
24
|
+
expect(decodeHtmlEntities('£100 & more')).to.equal('£100 & more');
|
|
25
|
+
expect(decodeHtmlEntities('AB')).to.equal('AB');
|
|
26
|
+
expect(decodeHtmlEntities('AB')).to.equal('AB');
|
|
27
|
+
});
|
|
28
|
+
it('leaves unknown and malformed references exactly as written', () => {
|
|
29
|
+
expect(decodeHtmlEntities('¬areal; &')).to.equal('¬areal; &');
|
|
30
|
+
expect(decodeHtmlEntities('a & b')).to.equal('a & b');
|
|
31
|
+
});
|
|
32
|
+
it('does not throw on out-of-range numeric references', () => {
|
|
33
|
+
expect(decodeHtmlEntities('�')).to.equal('�');
|
|
34
|
+
expect(decodeHtmlEntities('�')).to.equal('�');
|
|
35
|
+
expect(decodeHtmlEntities('�')).to.equal('�');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('tokenizeHtml()', ({
|
|
39
|
+
it
|
|
40
|
+
}) => {
|
|
41
|
+
it('reads attributes in every quoting style', () => {
|
|
42
|
+
const [token] = tokenizeHtml('<a href="/x" title=\'y\' data-n=3 hidden>');
|
|
43
|
+
expect(token?.kind).to.equal('open');
|
|
44
|
+
expect(token?.kind === 'open' && token.attributes).to.deep.equal({
|
|
45
|
+
href: '/x',
|
|
46
|
+
title: 'y',
|
|
47
|
+
'data-n': '3',
|
|
48
|
+
hidden: true
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it('drops comments and doctypes', () => {
|
|
52
|
+
expect(tokenizeHtml('<!doctype html><!-- hi --><b>x</b>').map(token => token.kind)).to.deep.equal(['open', 'text', 'close']);
|
|
53
|
+
});
|
|
54
|
+
it('treats a bare < as text', () => {
|
|
55
|
+
expect(tokenizeHtml('5 < 6 and 7 > 2')).to.deep.equal([{
|
|
56
|
+
kind: 'text',
|
|
57
|
+
text: '5 < 6 and 7 > 2'
|
|
58
|
+
}]);
|
|
59
|
+
});
|
|
60
|
+
it('takes script content as literal text, not markup', () => {
|
|
61
|
+
expect(tokenizeHtml('<script>if (a<b) x()</script>').map(token => token.kind)).to.deep.equal(['open', 'text', 'close']);
|
|
62
|
+
});
|
|
63
|
+
// scanning for the first > would cut this tag short and build a tree the
|
|
64
|
+
// browser disagrees with
|
|
65
|
+
it('does not end a tag at a > inside a quoted attribute value', () => {
|
|
66
|
+
const [token] = tokenizeHtml('<a title="a > b" href="/x">');
|
|
67
|
+
expect(token?.kind === 'open' && token.attributes).to.deep.equal({
|
|
68
|
+
title: 'a > b',
|
|
69
|
+
href: '/x'
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('parseHtml()', ({
|
|
74
|
+
it
|
|
75
|
+
}) => {
|
|
76
|
+
it('returns null for empty and non-string input', () => {
|
|
77
|
+
expect(parseHtml('')).to.equal(null);
|
|
78
|
+
// eslint-disable-next-line no-type-assertion/no-type-assertion
|
|
79
|
+
expect(parseHtml(undefined)).to.equal(null);
|
|
80
|
+
});
|
|
81
|
+
it('builds an element tree with decoded text', async () => {
|
|
82
|
+
const content = parseHtml('<p>Bonus £100</p>');
|
|
83
|
+
// eslint-disable-next-line no-type-assertion/no-type-assertion
|
|
84
|
+
const node = content;
|
|
85
|
+
expect(node[$type]).to.equal('p');
|
|
86
|
+
expect(node[$props].children).to.equal('Bonus £100');
|
|
87
|
+
});
|
|
88
|
+
it('maps class to className and style to css, which the renderer expects', async () => {
|
|
89
|
+
expect(await roundTrip('<p class="a b" style="color: red">x</p>')).to.equal('<p class="a b" style="color: red">x</p>');
|
|
90
|
+
});
|
|
91
|
+
it('keeps sibling paragraphs as siblings rather than nesting them', async () => {
|
|
92
|
+
expect(await roundTrip('<p>one</p><p>two</p>')).to.equal('<p>one</p><p>two</p>');
|
|
93
|
+
});
|
|
94
|
+
// The rule that broke the dashboard: a browser closes an open <p> at the
|
|
95
|
+
// next block-level start tag, so the parser must too, or the tree and the
|
|
96
|
+
// DOM disagree and hydration walks off the end.
|
|
97
|
+
it('applies the implied end tag that closes a p at a block element', async () => {
|
|
98
|
+
expect(await roundTrip('<p>one<div>two</div>')).to.equal('<p>one</p><div>two</div>');
|
|
99
|
+
});
|
|
100
|
+
it('closes list items and table cells implicitly', async () => {
|
|
101
|
+
expect(await roundTrip('<ul><li>a<li>b</ul>')).to.equal('<ul><li>a</li><li>b</li></ul>');
|
|
102
|
+
expect(await roundTrip('<table><tbody><tr><td>a<td>b</tbody></table>')).to.equal('<table><tbody><tr><td>a</td><td>b</td></tr></tbody></table>');
|
|
103
|
+
});
|
|
104
|
+
// Parsers disagree over whether whitespace survives inside a table, so the
|
|
105
|
+
// markup carries none — the tree and the DOM then agree either way.
|
|
106
|
+
it('drops whitespace between table tags', async () => {
|
|
107
|
+
expect(await roundTrip('<table>\n<tbody>\n<tr><td>a</td></tr>\n</tbody>\n</table>')).to.equal('<table><tbody><tr><td>a</td></tr></tbody></table>');
|
|
108
|
+
});
|
|
109
|
+
it('foster-parents real text out to just before the table', async () => {
|
|
110
|
+
expect(await roundTrip('<table><tbody>stray<tr><td>a</td></tr></tbody></table>')).to.equal('stray<table><tbody><tr><td>a</td></tr></tbody></table>');
|
|
111
|
+
});
|
|
112
|
+
it('leaves stray text alone in lists and selects, which do not relocate it', async () => {
|
|
113
|
+
expect(await roundTrip('<ul>loose<li>a</li></ul>')).to.equal('<ul>loose<li>a</li></ul>');
|
|
114
|
+
});
|
|
115
|
+
it('handles void and self-closing elements', async () => {
|
|
116
|
+
expect(await roundTrip('<p>a<br>b<img src="/x" />c</p>')).to.equal('<p>a<br />b<img src="/x" />c</p>');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('parseHtml() resilience', ({
|
|
120
|
+
it
|
|
121
|
+
}) => {
|
|
122
|
+
// "Safe" here means it always produces something renderable, whatever it
|
|
123
|
+
// is handed. None of these may throw.
|
|
124
|
+
const malformed = ['<p>unclosed', '</p>stray end tag', '<p><b>crossed</p></b>', '<<>><p>x</p>', '<p class="unterminated>x</p>', '<!-- never closed', '<p', '<>', '<p><p><p><p>deep', '&&&;&#;&#x;', '<div><span></div></span>', '<TABLE><TR><TD>caps</TD></TR></TABLE>'];
|
|
125
|
+
for (const html of malformed) {
|
|
126
|
+
it(`does not throw on ${JSON.stringify(html)}`, async () => {
|
|
127
|
+
const content = parseHtml(html);
|
|
128
|
+
const rendered = await render(content);
|
|
129
|
+
expect(rendered).to.be.a('string');
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
it('keeps the text of an unclosed element', async () => {
|
|
133
|
+
expect(await roundTrip('<p>unclosed')).to.equal('<p>unclosed</p>');
|
|
134
|
+
});
|
|
135
|
+
it('ignores a stray end tag but keeps its text', async () => {
|
|
136
|
+
expect(await roundTrip('</p>after')).to.equal('after');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe('parseHtml() sanitizing', ({
|
|
140
|
+
it
|
|
141
|
+
}) => {
|
|
142
|
+
it('drops script elements together with their contents', async () => {
|
|
143
|
+
expect(await roundTrip('<p>a</p><script>alert(1)</script><p>b</p>')).to.equal('<p>a</p><p>b</p>');
|
|
144
|
+
});
|
|
145
|
+
it('drops on* handler attributes', async () => {
|
|
146
|
+
expect(await roundTrip('<p onclick="alert(1)" onerror="x()" title="keep">a</p>')).to.equal('<p title="keep">a</p>');
|
|
147
|
+
});
|
|
148
|
+
it('drops javascript: urls, including obfuscated ones', async () => {
|
|
149
|
+
expect(await roundTrip('<a href="javascript:alert(1)">x</a>')).to.equal('<a>x</a>');
|
|
150
|
+
expect(await roundTrip('<a href=" JaVaScRiPt:alert(1)">x</a>')).to.equal('<a>x</a>');
|
|
151
|
+
// browsers ignore control characters inside the scheme, so this runs
|
|
152
|
+
expect(await roundTrip('<a href="java\tscript:alert(1)">x</a>')).to.equal('<a>x</a>');
|
|
153
|
+
expect(await roundTrip('<a href="vbscript:x">x</a>')).to.equal('<a>x</a>');
|
|
154
|
+
expect(await roundTrip('<a href="/safe">x</a>')).to.equal('<a href="/safe">x</a>');
|
|
155
|
+
expect(await roundTrip('<a href="https://x.test/">x</a>')).to.equal('<a href="https://x.test/">x</a>');
|
|
156
|
+
});
|
|
157
|
+
it('bars data: urls from navigation but allows them as image sources', async () => {
|
|
158
|
+
expect(await roundTrip('<a href="data:text/html,hello">x</a>')).to.equal('<a>x</a>');
|
|
159
|
+
expect(await roundTrip('<img src="data:image/png;base64,AAAA" />')).to.equal('<img src="data:image/png;base64,AAAA" />');
|
|
160
|
+
});
|
|
161
|
+
it('keeps handler attributes out even when sanitizing is off', async () => {
|
|
162
|
+
// Tungsten reads on* props as event listeners, so a string there is a
|
|
163
|
+
// hydration fault, not just a security question.
|
|
164
|
+
const content = parseHtml('<p onclick="alert(1)">a</p>', {
|
|
165
|
+
sanitize: false
|
|
166
|
+
});
|
|
167
|
+
expect(await render(content)).to.equal('<p>a</p>');
|
|
168
|
+
});
|
|
169
|
+
it('keeps scripts when sanitizing is turned off', async () => {
|
|
170
|
+
const content = parseHtml('<div><script>x()</script></div>', {
|
|
171
|
+
sanitize: false
|
|
172
|
+
});
|
|
173
|
+
expect(await render(content)).to.equal('<div><script>x()</script></div>');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
describe('parseHtml() allow lists', ({
|
|
177
|
+
it
|
|
178
|
+
}) => {
|
|
179
|
+
const withList = (html, allowList) => render(parseHtml(html, {
|
|
180
|
+
allowList
|
|
181
|
+
}));
|
|
182
|
+
const onlyEmphasis = {
|
|
183
|
+
tags: ['p', 'em'],
|
|
184
|
+
attributes: []
|
|
185
|
+
};
|
|
186
|
+
it('keeps listed tags and unwraps unlisted ones, preserving their text', async () => {
|
|
187
|
+
expect(await withList('<p>keep <em>this</em> and <b>that</b></p>', onlyEmphasis)).to.equal('<p>keep <em>this</em> and that</p>');
|
|
188
|
+
});
|
|
189
|
+
it('unwraps an unlisted element without losing its listed descendants', async () => {
|
|
190
|
+
expect(await withList('<div><p>inner</p></div>', onlyEmphasis)).to.equal('<p>inner</p>');
|
|
191
|
+
});
|
|
192
|
+
it('drops program-text elements entirely rather than unwrapping them', async () => {
|
|
193
|
+
// unwrapping <style> would put its CSS on the page as visible text
|
|
194
|
+
expect(await withList('<p>a</p><style>p { color: red }</style>', onlyEmphasis)).to.equal('<p>a</p>');
|
|
195
|
+
});
|
|
196
|
+
it('keeps only listed attributes', async () => {
|
|
197
|
+
const list = {
|
|
198
|
+
tags: ['p'],
|
|
199
|
+
attributes: ['title']
|
|
200
|
+
};
|
|
201
|
+
expect(await withList('<p title="t" id="i" data-x="1">a</p>', list)).to.equal('<p title="t">a</p>');
|
|
202
|
+
});
|
|
203
|
+
it('applies per-tag attributes only to their own tag', async () => {
|
|
204
|
+
const list = {
|
|
205
|
+
tags: ['a', 'p'],
|
|
206
|
+
attributes: [],
|
|
207
|
+
tagAttributes: {
|
|
208
|
+
a: ['href']
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
expect(await withList('<a href="/x">link</a><p href="/y">para</p>', list)).to.equal('<a href="/x">link</a><p>para</p>');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe('safeHtmlAllowList', ({
|
|
215
|
+
it
|
|
216
|
+
}) => {
|
|
217
|
+
const safely = html => render(parseHtml(html, {
|
|
218
|
+
allowList: safeHtmlAllowList
|
|
219
|
+
}));
|
|
220
|
+
it('keeps ordinary authored rich text intact', async () => {
|
|
221
|
+
const html = '<h2>Title</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>' + '<ul><li>one</li><li>two</li></ul>';
|
|
222
|
+
expect(await safely(html)).to.equal(html);
|
|
223
|
+
});
|
|
224
|
+
it('keeps tables with their layout attributes', async () => {
|
|
225
|
+
const html = '<table><tbody><tr><th align="left">H</th><td colspan="2">C</td></tr></tbody></table>';
|
|
226
|
+
expect(await safely(html)).to.equal(html);
|
|
227
|
+
});
|
|
228
|
+
it('keeps links and images with their own attributes', async () => {
|
|
229
|
+
expect(await safely('<a href="/x" target="_blank" rel="noopener">go</a>')).to.equal('<a href="/x" target="_blank" rel="noopener">go</a>');
|
|
230
|
+
expect(await safely('<img src="/a.png" alt="a" width="10" />')).to.equal('<img src="/a.png" alt="a" width="10" />');
|
|
231
|
+
});
|
|
232
|
+
it('strips style attributes, which can disguise the page without any script', async () => {
|
|
233
|
+
expect(await safely('<p style="position:fixed;top:0">a</p>')).to.equal('<p>a</p>');
|
|
234
|
+
});
|
|
235
|
+
it('removes embedding and form elements but keeps their text', async () => {
|
|
236
|
+
expect(await safely('<iframe src="//evil"></iframe><p>a</p>')).to.equal('<p>a</p>');
|
|
237
|
+
expect(await safely('<form><p>a</p></form>')).to.equal('<p>a</p>');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HtmlAllowList } from './HtmlAllowList.js';
|
|
2
|
+
/**
|
|
3
|
+
* A conservative allow list for authored rich text — the shape a CMS or WYSIWYG
|
|
4
|
+
* field produces: headings, paragraphs, lists, tables, links, images, emphasis.
|
|
5
|
+
*
|
|
6
|
+
* Reach for this via `<RawHtml safe />`, or import it and spread it into your
|
|
7
|
+
* own list where an application needs more.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately absent:
|
|
10
|
+
*
|
|
11
|
+
* - `style`, on any element. Attacker-controlled CSS can reposition and
|
|
12
|
+
* disguise page furniture, so it is a real vector even without script.
|
|
13
|
+
* - `script`, `iframe`, `object`, `embed`, `form`, `input`, `button`.
|
|
14
|
+
* - `target` without `rel`: `target="_blank"` hands the opener window to the
|
|
15
|
+
* linked page, so `rel` is allowed alongside it to be set to `noopener`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const safeHtmlAllowList: HtmlAllowList;
|
|
18
|
+
//# sourceMappingURL=safeHtmlAllowList.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safeHtmlAllowList.d.ts","sourceRoot":"","sources":["../../src/rawHtml/safeHtmlAllowList.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAEvD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,iBAAiB,EAAE,aAmC/B,CAAA"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A conservative allow list for authored rich text — the shape a CMS or WYSIWYG
|
|
3
|
+
* field produces: headings, paragraphs, lists, tables, links, images, emphasis.
|
|
4
|
+
*
|
|
5
|
+
* Reach for this via `<RawHtml safe />`, or import it and spread it into your
|
|
6
|
+
* own list where an application needs more.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately absent:
|
|
9
|
+
*
|
|
10
|
+
* - `style`, on any element. Attacker-controlled CSS can reposition and
|
|
11
|
+
* disguise page furniture, so it is a real vector even without script.
|
|
12
|
+
* - `script`, `iframe`, `object`, `embed`, `form`, `input`, `button`.
|
|
13
|
+
* - `target` without `rel`: `target="_blank"` hands the opener window to the
|
|
14
|
+
* linked page, so `rel` is allowed alongside it to be set to `noopener`.
|
|
15
|
+
*/
|
|
16
|
+
export const safeHtmlAllowList = {
|
|
17
|
+
tags: [
|
|
18
|
+
// text flow
|
|
19
|
+
'p', 'br', 'hr', 'span', 'div', 'blockquote', 'pre', 'code',
|
|
20
|
+
// headings
|
|
21
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
22
|
+
// emphasis
|
|
23
|
+
'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'sub', 'sup', 'small', 'mark',
|
|
24
|
+
// lists
|
|
25
|
+
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
|
26
|
+
// tables
|
|
27
|
+
'table', 'caption', 'colgroup', 'col', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
|
28
|
+
// media and links
|
|
29
|
+
'a', 'img', 'figure', 'figcaption'],
|
|
30
|
+
attributes: ['class', 'id', 'title', 'dir', 'lang'],
|
|
31
|
+
tagAttributes: {
|
|
32
|
+
a: ['href', 'target', 'rel', 'name'],
|
|
33
|
+
img: ['src', 'alt', 'width', 'height', 'loading'],
|
|
34
|
+
td: ['colspan', 'rowspan', 'align', 'valign', 'scope', 'headers'],
|
|
35
|
+
th: ['colspan', 'rowspan', 'align', 'valign', 'scope', 'headers', 'abbr'],
|
|
36
|
+
tr: ['align', 'valign'],
|
|
37
|
+
table: ['align', 'summary'],
|
|
38
|
+
thead: ['align', 'valign'],
|
|
39
|
+
tbody: ['align', 'valign'],
|
|
40
|
+
tfoot: ['align', 'valign'],
|
|
41
|
+
col: ['span', 'align', 'valign', 'width'],
|
|
42
|
+
colgroup: ['span', 'align', 'valign', 'width'],
|
|
43
|
+
ol: ['start', 'type', 'reversed'],
|
|
44
|
+
ul: ['type'],
|
|
45
|
+
li: ['value']
|
|
46
|
+
}
|
|
47
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type HtmlAttributes = Record<string, string | true>;
|
|
2
|
+
export type HtmlToken = {
|
|
3
|
+
kind: 'text';
|
|
4
|
+
text: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'open';
|
|
7
|
+
name: string;
|
|
8
|
+
attributes: HtmlAttributes;
|
|
9
|
+
selfClosing: boolean;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'close';
|
|
12
|
+
name: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Splits raw markup into text, start and end tokens.
|
|
16
|
+
*
|
|
17
|
+
* Never throws. Anything that is not well-formed markup — a stray `<`, an
|
|
18
|
+
* unterminated tag, a comment that is never closed — is emitted as text or
|
|
19
|
+
* skipped, matching what a browser does with the same input.
|
|
20
|
+
*
|
|
21
|
+
* Comments and doctypes are dropped: Tungsten uses comment nodes as its own
|
|
22
|
+
* component markers, so carrying authored ones through would confuse hydration.
|
|
23
|
+
*/
|
|
24
|
+
export declare function tokenizeHtml(html: string): HtmlToken[];
|
|
25
|
+
//# sourceMappingURL=tokenizeHtml.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenizeHtml.d.ts","sourceRoot":"","sources":["../../src/rawHtml/tokenizeHtml.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAA;AAE1D,MAAM,MAAM,SAAS,GACf;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,cAAc,CAAC;IAAC,WAAW,EAAE,OAAO,CAAA;CAAE,GAChF;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAoDrC;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,CA4FtD"}
|