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 () {