Skip to content

Commit e788e36

Browse files
seb-odooged-odoo
authored andcommitted
[IMP] runtime/utils: export htmlEscape and add tests
markup tag function requires markup awareness to determine whether a given parameter should be escaped or not. This implies that pre-escaped content should be properly marked'ed up to avoid double escaping. Having to manually wrap all calls to escape with markup is cumbersome and prone to issues (on top of having to be validated by the security team for no reason). This commit introduces a markup-aware escape function to resolve those issues.
1 parent 9d378b0 commit e788e36

File tree

3 files changed

+68
-7
lines changed

3 files changed

+68
-7
lines changed

src/runtime/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export { useComponent, useState } from "./component_node";
4141
export { status } from "./status";
4242
export { reactive, markRaw, toRaw } from "./reactivity";
4343
export { useEffect, useEnv, useExternalListener, useRef, useChildSubEnv, useSubEnv } from "./hooks";
44-
export { batched, EventBus, whenReady, loadFile, markup } from "./utils";
44+
export { batched, EventBus, htmlEscape, whenReady, loadFile, markup } from "./utils";
4545
export {
4646
onWillStart,
4747
onMounted,

src/runtime/utils.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,15 @@ export async function loadFile(url: string): Promise<string> {
111111
*/
112112
export class Markup extends String {}
113113

114-
function _escapeHtml(str: any): string | Markup {
114+
export function htmlEscape(str: any): Markup {
115115
if (str instanceof Markup) {
116116
return str;
117117
}
118118
if (str === undefined) {
119-
return "";
119+
return markup("");
120120
}
121121
if (typeof str === "number") {
122-
return String(str);
122+
return markup(String(str));
123123
}
124124
[
125125
["&", "&amp;"],
@@ -131,7 +131,7 @@ function _escapeHtml(str: any): string | Markup {
131131
].forEach((pairs) => {
132132
str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]);
133133
});
134-
return str;
134+
return markup(str);
135135
}
136136

137137
/*
@@ -153,7 +153,7 @@ export function markup(
153153
let acc = "";
154154
let i = 0;
155155
for (; i < placeholders.length; ++i) {
156-
acc += strings[i] + _escapeHtml(placeholders[i]);
156+
acc += strings[i] + htmlEscape(placeholders[i]);
157157
}
158158
acc += strings[i];
159159
return new Markup(acc);

tests/utils.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { batched, EventBus, markup } from "../src/runtime/utils";
1+
import { batched, EventBus, htmlEscape, markup } from "../src/runtime/utils";
22
import { nextMicroTick } from "./helpers";
33

44
describe("event bus behaviour", () => {
@@ -78,6 +78,61 @@ describe("markup", () => {
7878
const html = markup("<blink>Hello</blink>");
7979
expect(html).toBeInstanceOf(Markup);
8080
});
81+
describe("htmlEscape", () => {
82+
test("htmlEscape escapes text", () => {
83+
const res = htmlEscape("<p>test</p>");
84+
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
85+
expect(res).toBeInstanceOf(Markup);
86+
});
87+
test("htmlEscape keeps html markup", () => {
88+
const res = htmlEscape(markup("<p>test</p>"));
89+
expect(res.toString()).toBe("<p>test</p>");
90+
expect(res).toBeInstanceOf(Markup);
91+
});
92+
test("htmlEscape produces empty string on undefined", () => {
93+
const res = htmlEscape(undefined);
94+
expect(res.toString()).toBe("");
95+
expect(res).toBeInstanceOf(Markup);
96+
});
97+
test("htmlEscape produces string from number", () => {
98+
const res = htmlEscape(10);
99+
expect(res.toString()).toBe("10");
100+
expect(res).toBeInstanceOf(Markup);
101+
});
102+
test("htmlEscape produces string from boolean", () => {
103+
const res = htmlEscape(false);
104+
expect(res.toString()).toBe("false");
105+
expect(res).toBeInstanceOf(Markup);
106+
});
107+
test("htmlEscape correctly escapes various links", () => {
108+
expect(htmlEscape("<a>this is a link</a>").toString()).toBe(
109+
"&lt;a&gt;this is a link&lt;/a&gt;"
110+
);
111+
expect(htmlEscape(`<a href="https://www.odoo.com">odoo<a>`).toString()).toBe(
112+
`&lt;a href=&quot;https://www.odoo.com&quot;&gt;odoo&lt;a&gt;`
113+
);
114+
expect(htmlEscape(`<a href='https://www.odoo.com'>odoo<a>`).toString()).toBe(
115+
`&lt;a href=&#x27;https://www.odoo.com&#x27;&gt;odoo&lt;a&gt;`
116+
);
117+
expect(htmlEscape("<a href='https://www.odoo.com'>Odoo`s website<a>").toString()).toBe(
118+
`&lt;a href=&#x27;https://www.odoo.com&#x27;&gt;Odoo&#x60;s website&lt;a&gt;`
119+
);
120+
});
121+
test("htmlEscape doesn't escape already escaped content", () => {
122+
const res = htmlEscape("<p>test</p>");
123+
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
124+
expect(res).toBeInstanceOf(Markup);
125+
const res2 = htmlEscape(res);
126+
expect(res2.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
127+
expect(res2).toBeInstanceOf(Markup);
128+
expect(res2).toBe(res);
129+
});
130+
test("htmlEscape returns markup even for only-safe text", () => {
131+
const res = htmlEscape("safe");
132+
expect(res.toString()).toBe("safe");
133+
expect(res).toBeInstanceOf(Markup);
134+
});
135+
});
81136
describe("tag function", () => {
82137
test("interpolated values are escaped", () => {
83138
const maliciousInput = "<script>alert('💥💥')</script>";
@@ -99,5 +154,11 @@ describe("markup", () => {
99154
const html = markup`<img src="${imgUrl}">`;
100155
expect(html.toString()).toBe(`<img src="lol&quot; onerror=&quot;alert(&#x27;xss&#x27;)">`);
101156
});
157+
test("already escaped content is not escaped again", () => {
158+
const res = htmlEscape("<p>test</p>");
159+
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
160+
const html = markup`${res}`;
161+
expect(html.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
162+
});
102163
});
103164
});

0 commit comments

Comments
 (0)