Skip to content

Commit 23649ff

Browse files
committed
[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 fd3c194 commit 23649ff

File tree

2 files changed

+67
-6
lines changed

2 files changed

+67
-6
lines changed

src/runtime/utils.ts

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

84-
function _escapeHtml(str: any): string | Markup {
84+
export function htmlEscape(str: any): Markup {
8585
if (str instanceof Markup) {
8686
return str;
8787
}
8888
if (str === undefined) {
89-
return "";
89+
return markup("");
9090
}
9191
if (typeof str === "number") {
92-
return String(str);
92+
return markup(String(str));
9393
}
9494
[
9595
["&", "&amp;"],
@@ -101,7 +101,7 @@ function _escapeHtml(str: any): string | Markup {
101101
].forEach((pairs) => {
102102
str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]);
103103
});
104-
return str;
104+
return markup(str);
105105
}
106106

107107
/*
@@ -123,7 +123,7 @@ export function markup(
123123
let acc = "";
124124
let i = 0;
125125
for (; i < placeholders.length; ++i) {
126-
acc += strings[i] + _escapeHtml(placeholders[i]);
126+
acc += strings[i] + htmlEscape(placeholders[i]);
127127
}
128128
acc += strings[i];
129129
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)