[IMP] runtime: add markup tag function

Allows markup to be called as a tag function. The interpolated strings
are then safely escaped for injection in HTML code.

Example usage:
```js
const maliciousInput = "<script>alert('💥💥')</script>";
const value = markup`<b>${maliciousInput}</b>`;
// no problem, maliciousInput is properly escaped
```
This commit is contained in:
Louis Wicket (wil)
2025-02-28 15:57:11 +01:00
committed by Géry Debongnie
parent ac9ccb81ca
commit fd3c194525
3 changed files with 82 additions and 3 deletions
+9
View File
@@ -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, of `value1` will be escaped. However, since `value2` has been tagged as a markup,
this will be injected as html. 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>";
// <b>&lt;script&gt;alert(&#x27;💥💥&#x27;)&lt;/script&gt;</b>
const value = markup`<b>${maliciousInput}</b>`;
```
### Setting Variables ### 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, ... 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, ...
+42 -2
View File
@@ -81,10 +81,50 @@ export async function loadFile(url: string): Promise<string> {
*/ */
export class Markup extends String {} 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);
}
[
["&", "&amp;"],
["<", "&lt;"],
[">", "&gt;"],
["'", "&#x27;"],
['"', "&quot;"],
["`", "&#x60;"],
].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. * 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. * 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) { export function markup(strings: TemplateStringsArray, ...placeholders: unknown[]): Markup;
return new Markup(value); 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);
} }
+31 -1
View File
@@ -1,4 +1,4 @@
import { batched, EventBus } from "../src/runtime/utils"; import { batched, EventBus, markup } from "../src/runtime/utils";
import { nextMicroTick } from "./helpers"; import { nextMicroTick } from "./helpers";
describe("event bus behaviour", () => { describe("event bus behaviour", () => {
@@ -71,3 +71,33 @@ describe("batched", () => {
expect(n).toBe(2); expect(n).toBe(2);
}); });
}); });
const Markup = markup("").constructor;
describe("markup", () => {
test("string is flagged as safe", () => {
const html = markup("<blink>Hello</blink>");
expect(html).toBeInstanceOf(Markup);
});
describe("tag function", () => {
test("interpolated values are escaped", () => {
const maliciousInput = "<script>alert('💥💥')</script>";
const html = markup`<b>${maliciousInput}</b>`;
expect(html.toString()).toBe("<b>&lt;script&gt;alert(&#x27;💥💥&#x27;)&lt;/script&gt;</b>");
expect(html).toBeInstanceOf(Markup);
});
test("interpolated markups aren't escaped", () => {
const shouldBeEscaped = "<script>alert('should be escaped')</script>";
const shouldnt = markup("<b>this is safe</b>");
const html = markup`<div>${shouldBeEscaped} ${shouldnt}</div>`;
expect(html.toString()).toBe(
"<div>&lt;script&gt;alert(&#x27;should be escaped&#x27;)&lt;/script&gt; <b>this is safe</b></div>"
);
expect(html).toBeInstanceOf(Markup);
});
test("quotes in interpolated values are escaped", () => {
const imgUrl = `lol" onerror="alert('xss')`;
const html = markup`<img src="${imgUrl}">`;
expect(html.toString()).toBe(`<img src="lol&quot; onerror=&quot;alert(&#x27;xss&#x27;)">`);
});
});
});