Skip to content

Commit f8ec7ac

Browse files
authored
fix: normalizing language input and escaping HTML (#2716)
Reported-by: Nishanth <me@shunt.in>
1 parent ee6fef4 commit f8ec7ac

File tree

6 files changed

+93
-22
lines changed

6 files changed

+93
-22
lines changed

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/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/render.test.js

Lines changed: 36 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 () {

0 commit comments

Comments
 (0)