diff --git a/src/core/render/compiler/code.js b/src/core/render/compiler/code.js index bfe57413b9..f9af670a0a 100644 --- a/src/core/render/compiler/code.js +++ b/src/core/render/compiler/code.js @@ -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 */ `
${code}
`; + return /* html */ `
${code}
`; }); diff --git a/src/core/render/utils.js b/src/core/render/utils.js index f402118043..2fd98e2768 100644 --- a/src/core/render/utils.js +++ b/src/core/render/utils.js @@ -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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + + return String(string).replace(/[&<>"']/g, s => entityMap[s]); +} diff --git a/src/core/util/prism.js b/src/core/util/prism.js index 3f381701ee..1136d7055d 100644 --- a/src/core/util/prism.js +++ b/src/core/util/prism.js @@ -1,4 +1,5 @@ import * as Prism from 'prismjs'; +import { escapeHtml } from '../render/utils.js'; /** * * The dependencies map which syncs from @@ -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. @@ -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, ); } @@ -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; diff --git a/src/plugins/search/component.js b/src/plugins/search/component.js index 8d447c9cb1..59c012d5d6 100644 --- a/src/plugins/search/component.js +++ b/src/plugins/search/component.js @@ -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 = ''; diff --git a/src/plugins/search/search.js b/src/plugins/search/search.js index 6e3b2e0704..38c074b02d 100644 --- a/src/plugins/search/search.js +++ b/src/plugins/search/search.js @@ -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'; @@ -54,18 +55,6 @@ function resolveIndexKey(namespace) { : LOCAL_STORAGE.INDEX_KEY; } -export function escapeHtml(string) { - const entityMap = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - }; - - return String(string).replace(/[&<>"']/g, s => entityMap[s]); -} - function getAllPaths(router) { const paths = []; diff --git a/test/integration/render.test.js b/test/integration/render.test.js index 22442ebccb..876321bb75 100644 --- a/test/integration/render.test.js +++ b/test/integration/render.test.js @@ -145,6 +145,42 @@ Text

" }); }); + // 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" onmouseover="alert(1)"', + ); + expect(output).toContain( + 'class="language-js" onmouseover="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 () {