Skip to content

Commit 900fdf5

Browse files
authored
Merge branch 'develop' into dependabot/npm_and_yarn/lodash-4.18.1
2 parents ef0904a + b8f7032 commit 900fdf5

10 files changed

Lines changed: 147 additions & 33 deletions

File tree

src/core/render/compiler/code.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import * as Prism from 'prismjs';
22
// See https://github.com/PrismJS/prism/pull/1367
33
import 'prismjs/components/prism-markup-templating.js';
4-
import checkLangDependenciesAllLoaded from '../../util/prism.js';
4+
import checkLangDependenciesAllLoaded, {
5+
sanitizeCodeLang,
6+
} from '../../util/prism.js';
57

68
export const highlightCodeCompiler = ({ renderer }) =>
79
(renderer.code = function ({ text, lang = 'markup' }) {
8-
checkLangDependenciesAllLoaded(lang);
9-
const langOrMarkup = Prism.languages[lang] || Prism.languages.markup;
10+
const { escapedLang, prismLang } = sanitizeCodeLang(lang);
11+
12+
checkLangDependenciesAllLoaded(prismLang);
13+
const langOrMarkup = Prism.languages[prismLang] || Prism.languages.markup;
1014
const code = Prism.highlight(
1115
text.replace(/@DOCSIFY_QM@/g, '`'),
1216
langOrMarkup,
13-
lang,
17+
prismLang,
1418
);
1519

16-
return /* html */ `<pre data-lang="${lang}" class="language-${lang}"><code class="lang-${lang} language-${lang}" tabindex="0">${code}</code></pre>`;
20+
return /* html */ `<pre data-lang="${escapedLang}" class="language-${escapedLang}"><code class="lang-${escapedLang} language-${escapedLang}" tabindex="0">${code}</code></pre>`;
1721
});

src/core/render/compiler/image.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getAndRemoveConfig } from '../utils.js';
1+
import { escapeHtml, getAndRemoveConfig } from '../utils.js';
22
import { isAbsolutePath, getPath, getParentPath } from '../../router/util.js';
33

44
export const imageCompiler = ({ renderer, contentBase, router }) =>
@@ -14,7 +14,7 @@ export const imageCompiler = ({ renderer, contentBase, router }) =>
1414
}
1515

1616
if (title) {
17-
attrs.push(`title="${title}"`);
17+
attrs.push(`title="${escapeHtml(title)}"`);
1818
}
1919

2020
if (config.size) {
@@ -42,7 +42,7 @@ export const imageCompiler = ({ renderer, contentBase, router }) =>
4242
url = getPath(contentBase, getParentPath(router.getCurrentPath()), href);
4343
}
4444

45-
return /* html */ `<img src="${url}" data-origin="${href}" alt="${text}" ${attrs.join(
45+
return /* html */ `<img src="${escapeHtml(url)}" data-origin="${escapeHtml(href)}" alt="${escapeHtml(text)}" ${attrs.join(
4646
' ',
4747
)} />`;
4848
});

src/core/render/compiler/link.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getAndRemoveConfig } from '../utils.js';
1+
import { escapeHtml, getAndRemoveConfig } from '../utils.js';
22
import { isAbsolutePath } from '../../router/util.js';
33

44
export const linkCompiler = ({
@@ -65,8 +65,8 @@ export const linkCompiler = ({
6565
}
6666

6767
if (title) {
68-
attrs.push(`title="${title}"`);
68+
attrs.push(`title="${escapeHtml(title)}"`);
6969
}
7070

71-
return /* html */ `<a href="${href}" ${attrs.join(' ')}>${text}</a>`;
71+
return /* html */ `<a href="${escapeHtml(href)}" ${attrs.join(' ')}>${text}</a>`;
7272
});

src/core/render/compiler/media.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { escapeHtml } from '../utils';
2+
13
export const compileMedia = {
24
markdown(url) {
35
return {
@@ -11,19 +13,19 @@ export const compileMedia = {
1113
},
1214
iframe(url, title) {
1315
return {
14-
html: `<iframe src="${url}" ${
16+
html: `<iframe src="${escapeHtml(url)}" ${
1517
title || 'width=100% height=400'
1618
}></iframe>`,
1719
};
1820
},
1921
video(url, title) {
2022
return {
21-
html: `<video src="${url}" ${title || 'controls'}>Not Supported</video>`,
23+
html: `<video src="${escapeHtml(url)}" ${title || 'controls'}>Not Supported</video>`,
2224
};
2325
},
2426
audio(url, title) {
2527
return {
26-
html: `<audio src="${url}" ${title || 'controls'}>Not Supported</audio>`,
28+
html: `<audio src="${escapeHtml(url)}" ${title || 'controls'}>Not Supported</audio>`,
2729
};
2830
},
2931
code(url, title) {

src/core/render/utils.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,21 @@ export function getAndRemoveDocsifyIgnoreConfig(content = '') {
9494
ignoreSubHeading,
9595
});
9696
}
97+
98+
/**
99+
* Escape HTML special characters in a string to prevent XSS attacks.
100+
*
101+
* @param string
102+
* @returns {string}
103+
*/
104+
export function escapeHtml(string) {
105+
const entityMap = {
106+
'&': '&amp;',
107+
'<': '&lt;',
108+
'>': '&gt;',
109+
'"': '&quot;',
110+
"'": '&#39;',
111+
};
112+
113+
return String(string).replace(/[&<>"']/g, s => entityMap[s]);
114+
}

src/core/util/prism.js

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as Prism from 'prismjs';
2+
import { escapeHtml } from '../render/utils.js';
23
/**
34
*
45
* The dependencies map which syncs from
@@ -220,6 +221,28 @@ const lang_aliases = {
220221
// preventing duplicate calculations and avoiding repeated warning messages.
221222
const depTreeCache = {};
222223

224+
/**
225+
* Normalizes the declared code-block language and provides a safe HTML value.
226+
*
227+
* - `codeLang`: normalized user-declared language (fallback: `markup`)
228+
* - `prismLang`: resolved Prism language key used for dependency/highlight lookup
229+
* - `escapedLang`: escaped language for safe insertion into HTML attributes
230+
*
231+
* @param {*} lang
232+
* @returns {{codeLang: string, prismLang: any|string, escapedLang: string}}
233+
*/
234+
export const sanitizeCodeLang = lang => {
235+
const codeLang =
236+
typeof lang === 'string' && lang.trim().length ? lang.trim() : 'markup';
237+
const prismLang = lang_aliases[codeLang] || codeLang;
238+
239+
return {
240+
codeLang,
241+
prismLang,
242+
escapedLang: escapeHtml(codeLang),
243+
};
244+
};
245+
223246
/**
224247
* PrismJs language dependencies required a specific order to load.
225248
* Try to check and print a warning message if some dependencies missing or in wrong order.
@@ -254,11 +277,11 @@ export default function checkLangDependenciesAllLoaded(lang) {
254277
depTreeCache[lang] = depTree;
255278

256279
if (!dummy.loaded) {
257-
const prettyOutput = prettryPrint(depTree, 1);
280+
const prettyOutput = prettyPrint(depTree, 1);
258281
// eslint-disable-next-line no-console
259282
console.warn(
260283
`The language '${lang}' required dependencies for code block highlighting are not satisfied.`,
261-
`Priority dependencies from low to high, consider to place all the necessary dependencie by priority (higher first): \n`,
284+
`Priority dependencies from low to high, consider to place all the necessary dependencies by priority (higher first): \n`,
262285
prettyOutput,
263286
);
264287
}
@@ -288,11 +311,11 @@ const buildAndCheckDepTree = (lang, parent, dummy) => {
288311
parent.dependencies.push(cur);
289312
};
290313

291-
const prettryPrint = (depTree, level) => {
314+
const prettyPrint = (depTree, level) => {
292315
let cur = `${' '.repeat(level * 3)} ${depTree.cur} ${depTree.loaded ? '(+)' : '(-)'}`;
293316
if (depTree.dependencies.length) {
294317
depTree.dependencies.forEach(dep => {
295-
cur += prettryPrint(dep, level + 1);
318+
cur += prettyPrint(dep, level + 1);
296319
});
297320
}
298321
return '\n' + cur;

src/plugins/search/component.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { escapeHtml, search } from './search.js';
1+
import { search } from './search.js';
22
import cssText from './style.css';
3+
import { escapeHtml } from '../../core/render/utils.js';
34

45
let NO_DATA_TEXT = '';
56

src/plugins/search/search.js

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
getAndRemoveConfig,
33
getAndRemoveDocsifyIgnoreConfig,
44
removeAtag,
5+
escapeHtml,
56
} from '../../core/render/utils.js';
67
import { markdownToTxt } from './markdown-to-txt.js';
78
import Dexie from 'dexie';
@@ -54,18 +55,6 @@ function resolveIndexKey(namespace) {
5455
: LOCAL_STORAGE.INDEX_KEY;
5556
}
5657

57-
export function escapeHtml(string) {
58-
const entityMap = {
59-
'&': '&amp;',
60-
'<': '&lt;',
61-
'>': '&gt;',
62-
'"': '&quot;',
63-
"'": '&#39;',
64-
};
65-
66-
return String(string).replace(/[&<>"']/g, s => entityMap[s]);
67-
}
68-
6958
function getAllPaths(router) {
7059
const paths = [];
7160

test/integration/example.test.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,9 @@ describe('Creating a Docsify site (integration tests in Jest)', function () {
228228
# Text between
229229
230230
[filename](_media/example3.js ':include :fragment=something_else_not_code')
231-
231+
232232
[filename](_media/example4.js ':include :fragment=demo')
233-
233+
234234
# Text after
235235
`,
236236
},
@@ -303,4 +303,26 @@ Command | Description | Parameters
303303
expect(mainText).toContain('Something');
304304
expect(mainText).toContain('this is include content');
305305
});
306+
307+
test.each([
308+
{ type: 'iframe', selector: 'iframe' },
309+
{ type: 'video', selector: 'video' },
310+
{ type: 'audio', selector: 'audio' },
311+
])('embed %s escapes URL for XSS safety', async ({ type, selector }) => {
312+
const dangerousUrl = 'https://example.com/?q="><svg/onload=alert(1)>';
313+
314+
await docsifyInit({
315+
markdown: {
316+
homepage: `[media](${dangerousUrl} ':include :type=${type}')`,
317+
},
318+
});
319+
320+
expect(
321+
await waitForFunction(() => !!document.querySelector(selector)),
322+
).toBe(true);
323+
324+
const mediaElm = document.querySelector(selector);
325+
expect(mediaElm.getAttribute('src')).toBe(dangerousUrl);
326+
expect(mediaElm.hasAttribute('onload')).toBe(false);
327+
});
306328
});

test/integration/render.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,42 @@ Text</p></div>"
145145
});
146146
});
147147

148+
// Code
149+
// ---------------------------------------------------------------------------
150+
describe('code', function () {
151+
beforeEach(async () => {
152+
await docsifyInit();
153+
});
154+
155+
test('escapes language metadata to prevent attribute injection', async function () {
156+
const output = window.marked(stripIndent`
157+
\`\`\`js" onmouseover="alert(1)
158+
const answer = 42;
159+
\`\`\`
160+
`);
161+
162+
expect(output).not.toContain('" onmouseover="alert(1)');
163+
expect(output).toContain(
164+
'data-lang="js&quot; onmouseover=&quot;alert(1)"',
165+
);
166+
expect(output).toContain(
167+
'class="language-js&quot; onmouseover=&quot;alert(1)"',
168+
);
169+
});
170+
171+
test('keeps declared language class for normal fences', async function () {
172+
const output = window.marked(stripIndent`
173+
\`\`\`js
174+
const answer = 42;
175+
\`\`\`
176+
`);
177+
178+
expect(output).toContain('data-lang="js"');
179+
expect(output).toContain('class="language-js"');
180+
expect(output).toContain('token keyword');
181+
});
182+
});
183+
148184
// Images
149185
// ---------------------------------------------------------------------------
150186
describe('images', function () {
@@ -205,6 +241,16 @@ Text</p></div>"
205241
'"<p><img src="http://imageUrl" data-origin="http://imageUrl" alt="alt text" width="50" /></p>"',
206242
);
207243
});
244+
245+
test('escapes image alt and title to prevent attribute injection', async function () {
246+
const output = window.marked(
247+
'![alt" onerror="alert(1)](http://imageUrl \'title" onerror="alert(1)\')',
248+
);
249+
250+
expect(output).not.toContain(' onerror="alert(1)"');
251+
expect(output).toContain('alt="alt&quot; onerror=&quot;alert(1)"');
252+
expect(output).toContain('title="title&quot; onerror=&quot;alert(1)"');
253+
});
208254
});
209255

210256
// Headings
@@ -341,6 +387,15 @@ Text</p></div>"
341387
`"<p><a href="http://url" target="_blank" rel="noopener" id="someCssID">alt text</a></p>"`,
342388
);
343389
});
390+
391+
test('escapes link title to prevent attribute injection', async function () {
392+
const output = window.marked(
393+
`[alt text](http://url 'title" onclick="alert(1)')`,
394+
);
395+
396+
expect(output).not.toContain(' onclick="alert(1)"');
397+
expect(output).toContain('title="title&quot; onclick=&quot;alert(1)"');
398+
});
344399
});
345400

346401
// Skip Link

0 commit comments

Comments
 (0)