From 1791f28a7be8ad6261651914c3fbb3ec80d88559 Mon Sep 17 00:00:00 2001 From: muditagrawal2007 Date: Sat, 17 Oct 2026 23:58:07 +0530 Subject: [PATCH] fix(#173): do not run Handlebars escapeExpression on template variables Templates render into markdown notes, not HTML. With no options, Handlebars.compile runs every {{ var }} through escapeExpression and encodes ' " & < > into HTML entities. That mangles: * the note body (template_title / template_tags users hit it everywhere) * template_title (so the title in the note list carries the entities) * template_tags Joplin's markdown viewer renders the entities correctly so the bug is silent. It only surfaces when the user edits the note, searches it, exports it, or syncs it elsewhere. Pass { noEscape: true } to both compile calls (parser.ts:137 and parser.ts:286). Under noEscape, {{ var }} behaves like {{{ var }}}, so existing templates that already use the triple-stache workaround keep working unchanged. Three regression tests added in tests/parser.spec.ts: * apostrophes, quotes, ampersands, in text/number/dropdown * template_title and template_tags round-trip * {{{ var }}} still works identically under noEscape --- src/parser.ts | 11 ++++- tests/parser.spec.ts | 114 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index 36bd554..3c0aeaf 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -134,7 +134,14 @@ export class Parser { throw new Error(`${variable} should be a string, found ${typeof specialVariables[variable]}.`); } - const compiledText = Handlebars.compile(specialVariables[variable]); + // Pass `{ noEscape: true }` so Handlebars does not run every + // `{{ var }}` through `escapeExpression`. Templates render into + // markdown notes, not HTML — escaping `' " & < >` to entities + // mangles `template_title`/`template_tags` for any value that + // contains one of those characters. Triple-stache + // (`{{{ var }}}`) still works the same under `noEscape`, so + // existing templates are unaffected. + const compiledText = Handlebars.compile(specialVariables[variable], { noEscape: true }); res[variable] = compiledText(context); } @@ -283,7 +290,7 @@ export class Parser { }; const templateBody = processedTemplate.body; - const compiledTemplate = Handlebars.compile(templateBody); + const compiledTemplate = Handlebars.compile(templateBody, { noEscape: true }); // note_id is intentionally excluded from the shared context above // because it is only meaningful in the note body (it resolves to a diff --git a/tests/parser.spec.ts b/tests/parser.spec.ts index b7584bf..870bdab 100644 --- a/tests/parser.spec.ts +++ b/tests/parser.spec.ts @@ -1093,4 +1093,118 @@ describe("Template parser", () => { expect(parsedTemplate).toBeNull(); expect(errorMessagesShown).toEqual(1); }); + + // Regression tests for #173: HTML escaping leaks into markdown notes. + // Templates render into markdown, not HTML, so `{{ var }}` must NOT + // pass the value through `escapeExpression` and turn characters like + // `'`, `"`, `&`, `<`, `>` into entities. + test("should not HTML-escape text, number and dropdown variables in the body (#173)", async () => { + const template = { + id: "note-id", + title: "Some Template", + body: dedent` + --- + text_var: text + num_var: number + choice_var: dropdown(alpha, beta) + + --- + + text: {{ text_var }} + number: {{ num_var }} + choice: {{ choice_var }} + ` + }; + testVariableTypes({ + text_var: TextCustomVariable, + num_var: NumberCustomVariable, + choice_var: EnumCustomVariable, + }); + handleVariableDialog("ok", { + text_var: "it's a \"test\" with & ", + num_var: "42", + choice_var: "alpha", + }); + + const parsedTemplate = await parser.parseTemplate(template); + + assert(parsedTemplate); + expect(parsedTemplate.body).toEqual(dedent` + text: it's a "test" with & + number: 42 + choice: alpha + `); + // Make absolutely sure no HTML entities leaked in. + expect(parsedTemplate.body).not.toContain("'"); + expect(parsedTemplate.body).not.toContain("""); + expect(parsedTemplate.body).not.toContain("<"); + expect(parsedTemplate.body).not.toContain(">"); + }); + + test("should not HTML-escape template_title and template_tags (#173)", async () => { + const template = { + id: "note-id", + title: "Fallback Title", + body: dedent` + --- + topic: text + template_title: Today's "notes" — {{ topic }} + template_tags: alpha, {{ topic }} + + --- + + body: {{ topic }} + ` + }; + testVariableTypes({ + topic: TextCustomVariable, + }); + handleVariableDialog("ok", { + topic: "it's ", + }); + + const parsedTemplate = await parser.parseTemplate(template); + + assert(parsedTemplate); + // Title and tag list keep the user's input verbatim. + expect(parsedTemplate.title).toEqual("Today's \"notes\" — it's "); + expect(parsedTemplate.tags).toStrictEqual(["alpha", "it's "]); + // Body is markdown, so `` and the apostrophe must stay as plain characters. + expect(parsedTemplate.body.trim()).toEqual("body: it's "); + // No HTML entities should leak in anywhere. + expect(parsedTemplate.title).not.toMatch(/&(amp|quot|#x27|lt|gt);/); + expect(parsedTemplate.tags.join(",")).not.toMatch(/&(amp|quot|#x27|lt|gt);/); + expect(parsedTemplate.body).not.toMatch(/&(amp|quot|#x27|lt|gt);/); + }); + + test("should still support the {{{ var }}} triple-stache under noEscape (#173)", async () => { + const template = { + id: "note-id", + title: "Some Template", + body: dedent` + --- + text_var: text + + --- + + {{ text_var }} + {{{ text_var }}} + ` + }; + testVariableTypes({ + text_var: TextCustomVariable, + }); + handleVariableDialog("ok", { + text_var: "it's a \"test\"", + }); + + const parsedTemplate = await parser.parseTemplate(template); + + assert(parsedTemplate); + // Double stache and triple stache produce the same value under noEscape. + expect(parsedTemplate.body).toEqual(dedent` + it's a "test" + it's a "test" + `); + }); });