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('');
+ 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(' 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 });