Skip to content

Commit

Permalink
fix: escape HTML when katex fails to render (waylonflinn#26)
Browse files Browse the repository at this point in the history
fix XSS vulnerability when katex fails to render
  • Loading branch information
Swedish-li committed Aug 12, 2020
1 parent e027160 commit 182bf27
Show file tree
Hide file tree
Showing 4 changed files with 54 additions and 17 deletions.
5 changes: 5 additions & 0 deletions __tests__/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,8 @@ exports[`markdown katex render Text can immediately follow inline math 1`] = `
"<p><span class=\\"katex\\"><span class=\\"katex-mathml\\"><math xmlns=\\"http://www.w3.org/1998/Math/MathML\\"><semantics><mrow><mi>n</mi></mrow><annotation encoding=\\"application/x-tex\\">n</annotation></semantics></math></span><span class=\\"katex-html\\" aria-hidden=\\"true\\"><span class=\\"base\\"><span class=\\"strut\\" style=\\"height:0.43056em;vertical-align:0em;\\"></span><span class=\\"mord mathnormal\\">n</span></span></span></span>-th order</p>
"
`;
exports[`markdown katex render should escape html when katex render latex failed 1`] = `
"<p>\\\\unicode{&lt;img src=x onerror=alert(1)&gt;}</p>
"
`;
15 changes: 10 additions & 5 deletions __tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,12 @@ describe('markdown katex render', () => {
expect(output).toMatchSnapshot()
})

it('should call console.error with option throwOnError is true', () => {
it('should escape html when katex render latex failed', () => {
const output = md.render('$\\unicode{<img src=x onerror=alert(1)>}$')
expect(output).toMatchSnapshot()
})

it('should call console.error when option throwOnError is true', () => {
const logFn = jest.fn()
console.error = logFn

Expand All @@ -209,18 +214,18 @@ describe('markdown katex render', () => {
expect(logFn).toHaveBeenCalledTimes(2)
})

it('should not call console.error with option throwOnError is false', () => {
it('should not call console.error when option throwOnError is false', () => {
const logFn = jest.fn()
console.error = logFn

const mdShowErr = MarkdownIt('default', {
const mdNotShowErr = MarkdownIt('default', {
throwOnError: false,
} as MarkdownIt.Options).use(mdk)

mdShowErr.render('$\\atopwithdelims a b c d e$')
mdNotShowErr.render('$\\atopwithdelims a b c d e$')
expect(logFn).not.toBeCalled()

mdShowErr.render('$$\\atopwithdelims a b c \n ssbs$$')
mdNotShowErr.render('$$\\atopwithdelims a b c \n ssbs$$')
expect(logFn).not.toBeCalled()
})
})
27 changes: 15 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import katex from 'katex';
import katex from 'katex'
import MarkdownIt, { Options } from 'markdown-it'
import { RenderRule } from 'markdown-it/lib/renderer'
import { RuleInline } from 'markdown-it/lib/parser_inline'
import { RuleBlock } from 'markdown-it/lib/parser_block'
import StateInline from 'markdown-it/lib/rules_inline/state_inline'
import Token from 'markdown-it/lib/token'
import { escapeHtml } from './utils'

// Test if potential opening or closing delimieter
const handleError = (latex: string, error: Error, options: PluginOptions) => {
if (options.throwOnError) {
console.error(error)
}

return escapeHtml(latex)
}

// Test if potential opening or closing delimiter
// Assumes that there is a "$" at state.src[pos]

const isValidDelim = (state: StateInline, pos: number) => {
Expand Down Expand Up @@ -52,10 +61,7 @@ const inlineRenderer: RenderRule = (
try {
return katex.renderToString(latex, options)
} catch (error) {
if (options.throwOnError) {
console.error(error)
}
return latex
return handleError(latex, error, options)
}
}

Expand All @@ -71,10 +77,7 @@ const blockRenderer: RenderRule = (
try {
return `<p>${katex.renderToString(latex, opts)}</p>`
} catch (error) {
if (options.throwOnError) {
console.error(error)
}
return latex
return handleError(latex, error, options)
}
}

Expand All @@ -89,10 +92,10 @@ const mathInlineRule: RuleInline = (state, silent) => {
return true
}
let start: number, match: number, token: Token, pos: number
// First check for and bypass all properly escaped delimieters
// First check for and bypass all properly escaped delimiters
// This loop will assume that the first leading backtick can not
// be the first character in state.src, which is known since
// we have found an opening delimieter already.
// we have found an opening delimiter already.
start = state.pos + 1
match = start
while ((match = state.src.indexOf('$', match)) !== -1) {
Expand Down
24 changes: 24 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

// copy from Markdown-it
const HTML_ESCAPE_TEST_RE = /[&<>"]/
const HTML_ESCAPE_REPLACE_RE = /[&<>"]/g

type UnsafeChar = '&' | '<' | '>' | '"'

const HTML_REPLACEMENTS = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
}

function replaceUnsafeChar(ch: string) {
return HTML_REPLACEMENTS[ch as UnsafeChar]
}

export function escapeHtml(str: string) {
if (HTML_ESCAPE_TEST_RE.test(str)) {
return str.replace(HTML_ESCAPE_REPLACE_RE, replaceUnsafeChar)
}
return str
}

0 comments on commit 182bf27

Please sign in to comment.