From ad0b4aa8dde72702ecd1d74883f0388fb5eead88 Mon Sep 17 00:00:00 2001 From: sy-records <52o@qq52o.cn> Date: Thu, 9 Apr 2026 12:10:57 +0800 Subject: [PATCH] fix: escape HTML in image, link, and media components --- src/core/render/compiler/image.js | 6 +++--- src/core/render/compiler/link.js | 6 +++--- src/core/render/compiler/media.js | 8 +++++--- test/integration/example.test.js | 26 ++++++++++++++++++++++++-- test/integration/render.test.js | 19 +++++++++++++++++++ 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/core/render/compiler/image.js b/src/core/render/compiler/image.js index d113516b61..68bb478b47 100644 --- a/src/core/render/compiler/image.js +++ b/src/core/render/compiler/image.js @@ -1,4 +1,4 @@ -import { getAndRemoveConfig } from '../utils.js'; +import { escapeHtml, getAndRemoveConfig } from '../utils.js'; import { isAbsolutePath, getPath, getParentPath } from '../../router/util.js'; export const imageCompiler = ({ renderer, contentBase, router }) => @@ -14,7 +14,7 @@ export const imageCompiler = ({ renderer, contentBase, router }) => } if (title) { - attrs.push(`title="${title}"`); + attrs.push(`title="${escapeHtml(title)}"`); } if (config.size) { @@ -42,7 +42,7 @@ export const imageCompiler = ({ renderer, contentBase, router }) => url = getPath(contentBase, getParentPath(router.getCurrentPath()), href); } - return /* html */ `${text}`; }); diff --git a/src/core/render/compiler/link.js b/src/core/render/compiler/link.js index 44d2bcc2e3..6c017adcf3 100644 --- a/src/core/render/compiler/link.js +++ b/src/core/render/compiler/link.js @@ -1,4 +1,4 @@ -import { getAndRemoveConfig } from '../utils.js'; +import { escapeHtml, getAndRemoveConfig } from '../utils.js'; import { isAbsolutePath } from '../../router/util.js'; export const linkCompiler = ({ @@ -65,8 +65,8 @@ export const linkCompiler = ({ } if (title) { - attrs.push(`title="${title}"`); + attrs.push(`title="${escapeHtml(title)}"`); } - return /* html */ `${text}`; + return /* html */ `${text}`; }); diff --git a/src/core/render/compiler/media.js b/src/core/render/compiler/media.js index 3fa3cd799b..a35e40e015 100644 --- a/src/core/render/compiler/media.js +++ b/src/core/render/compiler/media.js @@ -1,3 +1,5 @@ +import { escapeHtml } from '../utils'; + export const compileMedia = { markdown(url) { return { @@ -11,19 +13,19 @@ export const compileMedia = { }, iframe(url, title) { return { - html: ``, }; }, video(url, title) { return { - html: ``, + html: ``, }; }, audio(url, title) { return { - html: ``, + html: ``, }; }, code(url, title) { diff --git a/test/integration/example.test.js b/test/integration/example.test.js index 4d5d590269..fd01183ec6 100644 --- a/test/integration/example.test.js +++ b/test/integration/example.test.js @@ -228,9 +228,9 @@ describe('Creating a Docsify site (integration tests in Jest)', function () { # Text between [filename](_media/example3.js ':include :fragment=something_else_not_code') - + [filename](_media/example4.js ':include :fragment=demo') - + # Text after `, }, @@ -303,4 +303,26 @@ Command | Description | Parameters expect(mainText).toContain('Something'); expect(mainText).toContain('this is include content'); }); + + test.each([ + { type: 'iframe', selector: 'iframe' }, + { type: 'video', selector: 'video' }, + { type: 'audio', selector: 'audio' }, + ])('embed %s escapes URL for XSS safety', async ({ type, selector }) => { + const dangerousUrl = 'https://example.com/?q=">'; + + await docsifyInit({ + markdown: { + homepage: `[media](${dangerousUrl} ':include :type=${type}')`, + }, + }); + + expect( + await waitForFunction(() => !!document.querySelector(selector)), + ).toBe(true); + + const mediaElm = document.querySelector(selector); + expect(mediaElm.getAttribute('src')).toBe(dangerousUrl); + expect(mediaElm.hasAttribute('onload')).toBe(false); + }); }); diff --git a/test/integration/render.test.js b/test/integration/render.test.js index 876321bb75..bb49c98e27 100644 --- a/test/integration/render.test.js +++ b/test/integration/render.test.js @@ -241,6 +241,16 @@ Text

" '"

alt text

"', ); }); + + test('escapes image alt and title to prevent attribute injection', async function () { + const output = window.marked( + '![alt" onerror="alert(1)](http://imageUrl \'title" onerror="alert(1)\')', + ); + + expect(output).not.toContain(' onerror="alert(1)"'); + expect(output).toContain('alt="alt" onerror="alert(1)"'); + expect(output).toContain('title="title" onerror="alert(1)"'); + }); }); // Headings @@ -377,6 +387,15 @@ Text

" `"

alt text

"`, ); }); + + test('escapes link title to prevent attribute injection', async function () { + const output = window.marked( + `[alt text](http://url 'title" onclick="alert(1)')`, + ); + + expect(output).not.toContain(' onclick="alert(1)"'); + expect(output).toContain('title="title" onclick="alert(1)"'); + }); }); // Skip Link