Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/core/render/compiler/code.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import * as Prism from 'prismjs';
// See https://github.com/PrismJS/prism/pull/1367
import 'prismjs/components/prism-markup-templating.js';
import checkLangDependenciesAllLoaded from '../../util/prism.js';
import checkLangDependenciesAllLoaded, {
sanitizeCodeLang,
} from '../../util/prism.js';

export const highlightCodeCompiler = ({ renderer }) =>
(renderer.code = function ({ text, lang = 'markup' }) {
checkLangDependenciesAllLoaded(lang);
const langOrMarkup = Prism.languages[lang] || Prism.languages.markup;
const { escapedLang, prismLang } = sanitizeCodeLang(lang);

checkLangDependenciesAllLoaded(prismLang);
const langOrMarkup = Prism.languages[prismLang] || Prism.languages.markup;
const code = Prism.highlight(
text.replace(/@DOCSIFY_QM@/g, '`'),
langOrMarkup,
lang,
prismLang,
);

return /* html */ `<pre data-lang="${lang}" class="language-${lang}"><code class="lang-${lang} language-${lang}" tabindex="0">${code}</code></pre>`;
return /* html */ `<pre data-lang="${escapedLang}" class="language-${escapedLang}"><code class="lang-${escapedLang} language-${escapedLang}" tabindex="0">${code}</code></pre>`;
});
18 changes: 18 additions & 0 deletions src/core/render/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,21 @@ export function getAndRemoveDocsifyIgnoreConfig(content = '') {
ignoreSubHeading,
});
}

/**
* Escape HTML special characters in a string to prevent XSS attacks.
*
* @param string
* @returns {string}
*/
export function escapeHtml(string) {
const entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};

return String(string).replace(/[&<>"']/g, s => entityMap[s]);
}
31 changes: 27 additions & 4 deletions src/core/util/prism.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Prism from 'prismjs';
import { escapeHtml } from '../render/utils.js';
/**
*
* The dependencies map which syncs from
Expand Down Expand Up @@ -220,6 +221,28 @@ const lang_aliases = {
// preventing duplicate calculations and avoiding repeated warning messages.
const depTreeCache = {};

/**
* Normalizes the declared code-block language and provides a safe HTML value.
*
* - `codeLang`: normalized user-declared language (fallback: `markup`)
* - `prismLang`: resolved Prism language key used for dependency/highlight lookup
* - `escapedLang`: escaped language for safe insertion into HTML attributes
*
* @param {*} lang
* @returns {{codeLang: string, prismLang: any|string, escapedLang: string}}
*/
export const sanitizeCodeLang = lang => {
const codeLang =
typeof lang === 'string' && lang.trim().length ? lang.trim() : 'markup';
const prismLang = lang_aliases[codeLang] || codeLang;

return {
codeLang,
prismLang,
escapedLang: escapeHtml(codeLang),
};
};

/**
* PrismJs language dependencies required a specific order to load.
* Try to check and print a warning message if some dependencies missing or in wrong order.
Expand Down Expand Up @@ -254,11 +277,11 @@ export default function checkLangDependenciesAllLoaded(lang) {
depTreeCache[lang] = depTree;

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

const prettryPrint = (depTree, level) => {
const prettyPrint = (depTree, level) => {
let cur = `${' '.repeat(level * 3)} ${depTree.cur} ${depTree.loaded ? '(+)' : '(-)'}`;
if (depTree.dependencies.length) {
depTree.dependencies.forEach(dep => {
cur += prettryPrint(dep, level + 1);
cur += prettyPrint(dep, level + 1);
});
}
return '\n' + cur;
Expand Down
3 changes: 2 additions & 1 deletion src/plugins/search/component.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { escapeHtml, search } from './search.js';
import { search } from './search.js';
import cssText from './style.css';
import { escapeHtml } from '../../core/render/utils.js';

let NO_DATA_TEXT = '';

Expand Down
13 changes: 1 addition & 12 deletions src/plugins/search/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
getAndRemoveConfig,
getAndRemoveDocsifyIgnoreConfig,
removeAtag,
escapeHtml,
} from '../../core/render/utils.js';
import { markdownToTxt } from './markdown-to-txt.js';
import Dexie from 'dexie';
Expand Down Expand Up @@ -54,18 +55,6 @@ function resolveIndexKey(namespace) {
: LOCAL_STORAGE.INDEX_KEY;
}

export function escapeHtml(string) {
const entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};

return String(string).replace(/[&<>"']/g, s => entityMap[s]);
}

function getAllPaths(router) {
const paths = [];

Expand Down
36 changes: 36 additions & 0 deletions test/integration/render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,42 @@ Text</p></div>"
});
});

// Code
// ---------------------------------------------------------------------------
describe('code', function () {
beforeEach(async () => {
await docsifyInit();
});

test('escapes language metadata to prevent attribute injection', async function () {
const output = window.marked(stripIndent`
\`\`\`js" onmouseover="alert(1)
const answer = 42;
\`\`\`
`);

expect(output).not.toContain('" onmouseover="alert(1)');
expect(output).toContain(
'data-lang="js&quot; onmouseover=&quot;alert(1)"',
);
expect(output).toContain(
'class="language-js&quot; onmouseover=&quot;alert(1)"',
);
});

test('keeps declared language class for normal fences', async function () {
const output = window.marked(stripIndent`
\`\`\`js
const answer = 42;
\`\`\`
`);

expect(output).toContain('data-lang="js"');
expect(output).toContain('class="language-js"');
expect(output).toContain('token keyword');
});
});

// Images
// ---------------------------------------------------------------------------
describe('images', function () {
Expand Down