diff --git a/lib/macro-converter.js b/lib/macro-converter.js index ab4c977..c47f154 100644 --- a/lib/macro-converter.js +++ b/lib/macro-converter.js @@ -30,6 +30,19 @@ const PASSTHROUGH_BLOCK_RE = /<(svg|div)(?:\s[^>]*)?>[\s\S]*?<\/\1>/gi; // continuation that happens to align to four spaces. const INLINE_CODE_RE = /`[^`\n]+`/g; +// Escapes a restored passthrough tag for safe inclusion inside a double-quoted +// XML attribute value. Only used when a stash placeholder resolved into an +// attribute context (see `_renderMarkdownToHtml`); text-node placeholders are +// left raw. `&` is escaped first so the entity ampersands below are not +// double-escaped. +function escapeXmlAttr(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + class MacroConverter { constructor({ isCloud = false, webUrlPrefix = '', buildUrl = null, linkStyle = null } = {}) { this._isCloud = isCloud; @@ -126,9 +139,29 @@ class MacroConverter { src += stashHtml(markdown.slice(pos)); const html = this.markdown.render(src); + let contextOffset = 0; + let isInsideTag = false; return html.replace( new RegExp(`${STASH_DELIM}H(\\d+)${STASH_DELIM}`, 'g'), - (m, i) => htmlStash[+i] ?? m, + (m, i, offset) => { + for (; contextOffset < offset; contextOffset++) { + if (html[contextOffset] === '<') isInsideTag = true; + else if (html[contextOffset] === '>') isInsideTag = false; + } + contextOffset = offset + m.length; + + const raw = htmlStash[+i]; + if (raw == null) return m; + // A placeholder can render into an attribute value (e.g. a link/image + // `title` built from `[x](url "
")`). Restoring the raw tag there + // would inject unescaped `<>"&` into an attribute, producing invalid + // storage XML that Confluence rejects. Detect the attribute context by + // checking whether the nearest preceding angle bracket opens a tag + // (`<` after the last `>`), and XML-escape the restored value if so. + // Text-node placeholders keep their raw tag, so body passthrough is + // byte-identical. + return isInsideTag ? escapeXmlAttr(raw) : raw; + }, ); } diff --git a/tests/macro-converter.test.js b/tests/macro-converter.test.js index 527b9a8..93be57f 100644 --- a/tests/macro-converter.test.js +++ b/tests/macro-converter.test.js @@ -1143,6 +1143,76 @@ describe('MacroConverter
passthrough', () => { }); }); +describe('MacroConverter passthrough tags in link/image title attributes', () => { + // Whitelisted inline tags (br|u|sub|sup|mark|details|summary) are stashed as + // placeholders before rendering and restored across the whole HTML string. + // When a placeholder lands inside an attribute value (a link/image `title`), + // restoring the raw tag would inject unescaped `<>"&` and produce storage XML + // that Confluence rejects. The restore step must XML-escape those, while + // leaving body-text passthrough byte-identical (see the
passthrough suite + // above, which must stay green). + const converter = new MacroConverter({ isCloud: true }); + + // Focused well-formedness check for this defect class: every attribute value + // must be free of raw `<`/`>`. htmlparser2 (xmlMode) is too lenient to flag + // these, so we scan attribute values directly. + const assertAttrsWellFormed = (xml) => { + const attrRe = /\b[\w:-]+\s*=\s*"([^"]*)"/g; + let m; + while ((m = attrRe.exec(xml)) !== null) { + expect(m[1]).not.toMatch(/[<>]/); + } + }; + + test('link title with
is XML-escaped, not restored raw', () => { + const result = converter.markdownToStorage('[x](https://example.com "
")'); + expect(result).toContain('title="<br>"'); + expect(result).not.toContain('title="
"'); + assertAttrsWellFormed(result); + }); + + test('image title with ... is XML-escaped, not restored raw', () => { + const result = converter.markdownToStorage('![img](https://example.com/i.png "capx")'); + expect(result).toContain('title="cap<sub>x</sub>"'); + expect(result).not.toContain('title="cap'); + assertAttrsWellFormed(result); + }); + + test('same tag in body AND title: body stays raw XHTML, title gets escaped', () => { + const result = converter.markdownToStorage('Line a
b then [x](https://example.com "
")'); + // body passthrough unchanged (self-closed by markdown/storage pipeline) + expect(result).toContain('a
b'); + // attribute context escaped + expect(result).toContain('title="<br>"'); + assertAttrsWellFormed(result); + }); + + test('all seven whitelisted tags in a title attribute are escaped', () => { + const tags = ['br', 'u', 'sub', 'sup', 'mark', 'details', 'summary']; + for (const tag of tags) { + const result = converter.markdownToStorage(`[x](https://example.com "a<${tag}>b")`); + expect(result).toContain(`title="a<${tag}>b"`); + assertAttrsWellFormed(result); + } + }); + + test('output with escaped title parses as well-formed XML', () => { + const htmlparser2 = require('htmlparser2'); + const result = converter.markdownToStorage('![img](https://example.com/i.png "capx") and text a
b'); + let error = null; + const parser = new htmlparser2.Parser( + { onerror: (e) => { error = e; } }, + { xmlMode: true, recognizeSelfClosing: true } + ); + parser.write(`${result}`); + parser.end(); + expect(error).toBeNull(); + // xmlMode is lenient about attribute contents, so also assert no raw + // angle brackets survive inside any attribute value. + assertAttrsWellFormed(result); + }); +}); + describe('MacroConverter storageToMarkdown panel formatting', () => { const converter = new MacroConverter({ isCloud: true });