diff --git a/doc/reference/templates.md b/doc/reference/templates.md index 551ac531..51d828f3 100644 --- a/doc/reference/templates.md +++ b/doc/reference/templates.md @@ -193,6 +193,15 @@ The first `t-out` will act as a `t-esc` directive, which means that the content of `value1` will be escaped. However, since `value2` has been tagged as a markup, this will be injected as html. +`markup` can also be used as a tag function, allowing the interpolated values to +be safely escaped: + +```js +const maliciousInput = ""; +// <script>alert('💥💥')</script> +const value = markup`${maliciousInput}`; +``` + ### Setting Variables QWeb allows creating variables from within the template, to memoize a computation (to use it multiple times), give a piece of data a clearer name, ... diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 7aa9b112..3644bbc6 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -81,10 +81,50 @@ export async function loadFile(url: string): Promise { */ export class Markup extends String {} +function _escapeHtml(str: any): string | Markup { + if (str instanceof Markup) { + return str; + } + if (str === undefined) { + return ""; + } + if (typeof str === "number") { + return String(str); + } + [ + ["&", "&"], + ["<", "<"], + [">", ">"], + ["'", "'"], + ['"', """], + ["`", "`"], + ].forEach((pairs) => { + str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]); + }); + return str; +} + /* * Marks a value as safe, that is, a value that can be injected as HTML directly. * It should be used to wrap the value passed to a t-out directive to allow a raw rendering. + * + * If called as a tag function, the interpolated strings are escaped. */ -export function markup(value: any) { - return new Markup(value); +export function markup(strings: TemplateStringsArray, ...placeholders: unknown[]): Markup; +export function markup(value: string): Markup; +export function markup( + valueOrStrings: string | TemplateStringsArray, + ...placeholders: unknown[] +): Markup { + if (!Array.isArray(valueOrStrings)) { + return new Markup(valueOrStrings); + } + const strings = valueOrStrings; + let acc = ""; + let i = 0; + for (; i < placeholders.length; ++i) { + acc += strings[i] + _escapeHtml(placeholders[i]); + } + acc += strings[i]; + return new Markup(acc); } diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 29a109fa..bc9097b2 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,4 +1,4 @@ -import { batched, EventBus } from "../src/runtime/utils"; +import { batched, EventBus, markup } from "../src/runtime/utils"; import { nextMicroTick } from "./helpers"; describe("event bus behaviour", () => { @@ -71,3 +71,33 @@ describe("batched", () => { expect(n).toBe(2); }); }); + +const Markup = markup("").constructor; +describe("markup", () => { + test("string is flagged as safe", () => { + const html = markup("Hello"); + expect(html).toBeInstanceOf(Markup); + }); + describe("tag function", () => { + test("interpolated values are escaped", () => { + const maliciousInput = ""; + const html = markup`${maliciousInput}`; + expect(html.toString()).toBe("<script>alert('💥💥')</script>"); + expect(html).toBeInstanceOf(Markup); + }); + test("interpolated markups aren't escaped", () => { + const shouldBeEscaped = ""; + const shouldnt = markup("this is safe"); + const html = markup`
${shouldBeEscaped} ${shouldnt}
`; + expect(html.toString()).toBe( + "
<script>alert('should be escaped')</script> this is safe
" + ); + expect(html).toBeInstanceOf(Markup); + }); + test("quotes in interpolated values are escaped", () => { + const imgUrl = `lol" onerror="alert('xss')`; + const html = markup``; + expect(html.toString()).toBe(``); + }); + }); +});