Skip to content

Commit c563d6c

Browse files
authored
Fix HTML filter bypasses in Markdown preview, and harden further (#1694)
2 parents 12eba64 + 9d785c6 commit c563d6c

3 files changed

Lines changed: 76 additions & 20 deletions

File tree

assets/js/defang.js

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ const allowedTags = new Set([
1414
'A', 'IMG',
1515
]);
1616

17-
const allowedAttributes = new Set([
17+
export const allowedAttributes = new Set([
1818
'title', 'alt', 'colspan', 'rowspan', 'start', 'type',
1919
]);
2020

21+
export const conditionallyAllowedAttributes = [
22+
'href', 'src', 'style', 'class',
23+
];
24+
2125
const allowedSchemes = new Set([
2226
'http:', 'https:', 'mailto:', 'irc:', 'ircs:', 'magnet:',
2327
]);
@@ -66,22 +70,48 @@ export const tryGetCleanHref = s => {
6670
return u.pathname;
6771
};
6872

69-
const defang = (node, isBody) => {
70-
for (let i = node.childNodes.length; i--;) {
71-
const child = node.childNodes[i];
73+
// clobbering-safe access
74+
const NP = Node.prototype;
75+
const npGetter = k => Object.getOwnPropertyDescriptor(NP, k).get;
76+
const getFirstChild = npGetter('firstChild');
77+
const getNextSibling = npGetter('nextSibling');
7278

73-
if (child.nodeType === 1) {
74-
defang(child, false);
79+
const defang = (node, isBody) => {
80+
for (let child = getFirstChild.call(node); child;) {
81+
// `child` may be removed (and possibly replaced with multiple nodes) either by recursive `defang` or `default` case
82+
const nextSibling = getNextSibling.call(child);
83+
84+
switch (child.nodeType) {
85+
case 1:
86+
defang(child, false);
87+
break;
88+
89+
case 3:
90+
// text nodes are always allowed
91+
break;
92+
93+
default:
94+
// NOTE: also covers clobbered `nodeType`
95+
NP.removeChild.call(node, child);
7596
}
97+
98+
child = nextSibling;
7699
}
77100

78-
if (!isBody && !allowedTags.has(node.nodeName)) {
79-
while (node.hasChildNodes()) {
80-
node.parentNode.insertBefore(node.firstChild, node);
101+
// NOTE: also covers clobbered `nodeName`, and no `allowedTags` elements support named properties, so `hasAttribute` won't even throw
102+
if (!isBody && (!allowedTags.has(node.nodeName) || node.hasAttribute('is'))) {
103+
// n.b. merging this with the previous loop would be easy to get dangerously wrong
104+
const childNodes = document.createDocumentFragment();
105+
106+
for (let child = getFirstChild.call(node); child;) {
107+
const nextSibling = getNextSibling.call(child);
108+
childNodes.appendChild(child);
109+
child = nextSibling;
81110
}
82111

83-
node.parentNode.removeChild(node);
112+
Element.prototype.replaceWith.call(node, childNodes);
84113
} else {
114+
// no `allowedTags` elements support named properties, so `node`'s properties are unclobbered in this branch
85115
for (let i = node.attributes.length; i--;) {
86116
const attribute = node.attributes[i];
87117
let cleanHref;

assets/js/scripts.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* global marked */
22
import autosize_ from 'autosize';
3-
import defang from './defang.js';
3+
import defang, {allowedAttributes, conditionallyAllowedAttributes} from './defang.js';
44
import {byClass} from './dom.js';
55
import initEmbed from './embed.js';
66
import {forEach, some} from './util/array-like.js';
@@ -222,10 +222,36 @@ const loadMarked = () => {
222222
document.body.appendChild(markedScript);
223223
};
224224

225+
// Clobbering-safe access. A document *from `DOMParser` specifically* doesn't seem to have named property access on Chromium 146, but nothing in the DOM/HTML/`DOMParser` specs distinguish that case as far as I can tell; anyway, it does have named property access on Firefox 149.
226+
const getBody = Object.getOwnPropertyDescriptor(Document.prototype, 'body').get;
227+
228+
/**
229+
* Parses a string of HTML into a partially sanitized container element if the environment supports the HTML Sanitizer API, and an unsanitized one otherwise.
230+
*
231+
* This partial sanitization removes attributes that are never allowed on any element (especially `name` and `id`, preventing clobbering) and anything that can execute scripts (try not to reintroduce such things in `weasylMarkdown`) as defense in depth. Unfortunately, `<script>`, `<iframe>`, and `<object>` elements are completely removed, instead of behaving like `defang`; configuring these as `replaceWithChildrenElements` doesn't work with "safe" functions. `javascript:` links are also removed instead of getting the `replaceBadLinks` treatment.
232+
*/
233+
let parseHtml;
234+
235+
if (Document.parseHTML) {
236+
// some of this configuration is already implied by the use of a "safe" function like `parseHTML`, but it's nice to be explicit
237+
const presanitizer = new Sanitizer({
238+
// NOTE: does not remove `is`
239+
attributes: [...allowedAttributes, ...conditionallyAllowedAttributes],
240+
comments: false,
241+
});
242+
presanitizer.removeUnsafe();
243+
244+
parseHtml = html => Document.parseHTML(html, {sanitizer: presanitizer}).body;
245+
} else {
246+
parseHtml = html => {
247+
const doc = new DOMParser().parseFromString(html, 'text/html');
248+
return getBody.call(doc);
249+
};
250+
}
251+
225252
const renderMarkdown = (content, container) => {
226-
const markdown = marked(content, markdownOptions);
227-
const sanitizeDocument = new DOMParser().parseFromString(markdown, 'text/html');
228-
const fragment = sanitizeDocument.body;
253+
const renderedMarkdown = marked(content, markdownOptions);
254+
const fragment = parseHtml(renderedMarkdown);
229255

230256
weasylMarkdown(fragment);
231257
defang(fragment, true);

assets/js/weasyl-markdown.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const replaceBadLinks = fragment => {
2525
});
2626
};
2727

28+
// XXX: Properties of `fragment` and `child` can be clobbered here. For now, they just break the preview or user links at worst, which is "fine".
2829
const addUserLinks = fragment => {
2930
for (let i = 0; i < fragment.childNodes.length; i++) {
3031
const child = fragment.childNodes[i];
@@ -92,7 +93,7 @@ const weasylMarkdown = fragment => {
9293
const links = fragment.getElementsByTagName('a');
9394

9495
forEach(links, link => {
95-
const href = link.getAttribute('href');
96+
const href = link.getAttribute('href') || '';
9697
const i = href.indexOf(':');
9798
const scheme = href.substring(0, i);
9899
const user = href.substring(i + 1);
@@ -130,19 +131,20 @@ const weasylMarkdown = fragment => {
130131
const images = fragment.querySelectorAll('img');
131132

132133
forEach(images, image => {
133-
const src = image.getAttribute('src');
134+
const src = image.getAttribute('src') || '';
134135
const i = src.indexOf(':');
135136
const scheme = src.substring(0, i);
136137
const link = document.createElement('a');
137138

139+
image.replaceWith(link);
140+
138141
if (scheme === 'user') {
139142
const user = src.substring(i + 1);
140143
image.className = 'user-icon';
141144
image.src = '/~' + user + '/avatar';
142145

143146
link.href = '/~' + user;
144147

145-
image.parentNode.replaceChild(link, image);
146148
link.appendChild(image);
147149

148150
if (image.alt) {
@@ -157,10 +159,8 @@ const weasylMarkdown = fragment => {
157159
image.title = '';
158160
}
159161
} else {
160-
link.href = image.src;
162+
link.href = image.src; // XXX: reintroduction point for `javascript:` URLs after presanitizer
161163
link.appendChild(document.createTextNode(image.alt || image.src));
162-
163-
image.parentNode.replaceChild(link, image);
164164
}
165165
});
166166

0 commit comments

Comments
 (0)