commit bbadacb4f4115d3cdcd524c7b88facc7f713c703
parent df9e3566f9e35100ffb6b6ae19aaf0618d6369a5
Author: Steven <steven@zhao.io>
Date: Mon, 19 Dec 2022 22:47:47 -0800
Use html template tags to escape user input
Diffstat:
4 files changed, 83 insertions(+), 44 deletions(-)
diff --git a/web/index.html b/web/index.html
@@ -8,6 +8,7 @@
<link rel="stylesheet" href="css/utils.css?v=2">
<link rel="stylesheet" href="css/styles.css?v=14">
<link rel="stylesheet" href="css/responsive.css?v=11">
+ <script defer src="js/ui/safe-html.js?v=1"></script>
<script defer src="js/purify.js?v=1"></script>
<script defer src="js/util.js?v=5"></script>
<script defer src="js/ui/util.js?v=10"></script>
diff --git a/web/js/ui/render.js b/web/js/ui/render.js
@@ -22,18 +22,18 @@ function render_events(damus, view) {
function render_reply_line_top(has_top_line) {
const classes = has_top_line ? "" : "invisible"
- return `<div class="line-top ${classes}"></div>`
+ return html`<div class="line-top ${classes}"></div>`
}
function render_reply_line_bot() {
- return `<div class="line-bot"></div>`
+ return html`<div class="line-bot"></div>`
}
function render_thread_collapsed(model, ev, opts)
{
if (opts.is_composing)
return ""
- return `<div onclick="expand_thread('${ev.id}')" class="thread-collapsed">
+ return html`<div onclick="expand_thread('${ev.id}')" class="thread-collapsed">
<div class="thread-summary clickable event-message">
Read More
<img class="icon svg small" src="icon/read-more.svg"/>
@@ -65,7 +65,7 @@ function render_replying_to_chat(damus, ev) {
const names = pks.map(pk => render_mentioned_name(pk, damus.profiles[pk])).join(", ")
const to_users = pks.length === 0 ? "" : ` to ${names}`
- return `<div class="replying-to">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>`
+ return html`<div class="replying-to">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>`
}
function render_replying_to(model, ev) {
@@ -79,16 +79,16 @@ function render_replying_to(model, ev) {
if (pubkeys.length === 0 && ev.refs.reply) {
const replying_to = model.all_events[ev.refs.reply]
if (!replying_to)
- return `<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>`
+ return html`<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>`
pubkeys = [replying_to.pubkey]
}
const names = ev.refs.pubkeys.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ")
- return `
+ return html`
<span class="replying-to small-txt">
- replying to ${names}
+ replying to $${names}
</span>
`
}
@@ -117,16 +117,16 @@ function render_comment_body(damus, ev, opts) {
const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(damus, ev, can_delete)
const show_media = !opts.is_composing
- return `
+ return html`
<div>
- ${render_replying_to(damus, ev)}
- ${render_shared_by(ev, opts)}
+ $${render_replying_to(damus, ev)}
+ $${render_shared_by(ev, opts)}
</div>
<p>
- ${format_content(ev, show_media)}
+ $${format_content(ev, show_media)}
</p>
- ${render_reactions(damus, ev)}
- ${bar}
+ $${render_reactions(damus, ev)}
+ $${bar}
`
}
@@ -135,23 +135,23 @@ function render_shared_by(ev, opts) {
if (!b) {
return ""
}
- return `
+ return html`
<div class="shared-by">
- Shared by ${render_name(b.pubkey, b.profile)}
+ Shared by $${render_name(b.pubkey, b.profile)}
</div>
`
}
function render_deleted_comment_body(ev, deleted) {
if (deleted.content) {
- return `
+ return html`
<div class="deleted-comment event-message">
This content was deleted with reason:
<div class="quote">${format_content(deleted, false)}</div>
</div>
`
}
- return `
+ return html`
<div class="deleted-comment event-message">
This content was deleted.
</div>
@@ -185,24 +185,24 @@ function render_event(damus, view, ev, opts={}) {
const has_top_line = replied_events !== ""
const border_bottom = opts.is_composing || has_bot_line ? "" : "bottom-border";
- return `
- ${replied_events}
+ return html`
+ $${replied_events}
<div id="ev${ev.id}" class="event ${border_bottom}">
<div class="userpic">
- ${render_reply_line_top(has_top_line)}
- ${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)}
- ${reply_line_bot}
+ $${render_reply_line_top(has_top_line)}
+ $${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)}
+ $${reply_line_bot}
</div>
<div class="event-content">
<div class="info">
- ${render_name(ev.pubkey, profile)}
+ $${render_name(ev.pubkey, profile)}
<span class="timestamp">${delta}</span>
<button class="icon" title="View Thread" role="view-event" data-eid="${ev.id}" onclick="click_event(this)">
<img class="icon svg small" src="icon/open-thread.svg"/>
</button>
</div>
<div class="comment">
- ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(damus, ev, opts)}
+ $${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(damus, ev, opts)}
</div>
</div>
</div>
@@ -212,9 +212,9 @@ function render_event(damus, view, ev, opts={}) {
function render_react_onclick(our_pubkey, reacting_to, emoji, reactions) {
const reaction = reactions[our_pubkey]
if (!reaction) {
- return `onclick="send_reply('${emoji}', '${reacting_to}')"`
+ return html`onclick="send_reply('${emoji}', '${reacting_to}')"`
} else {
- return `onclick="delete_post('${reaction.id}')"`
+ return html`onclick="delete_post('${reaction.id}')"`
}
}
@@ -223,12 +223,12 @@ function render_reaction_group(model, emoji, reactions, reacting_to) {
let onclick = render_react_onclick(model.pubkey, reacting_to.id, emoji, reactions)
- return `
- <span ${onclick} class="reaction-group clickable">
+ return html`
+ <span $${onclick} class="reaction-group clickable">
<span class="reaction-emoji">
${emoji}
</span>
- ${pfps.join("\n")}
+ $${pfps.join("\n")}
</span>
`
}
@@ -245,7 +245,7 @@ function render_reaction(model, reaction) {
function render_action_bar(damus, ev, can_delete) {
let delete_html = ""
if (can_delete)
- delete_html = `
+ delete_html = html`
<button class="icon" title="Delete" onclick="delete_post_confirm('${ev.id}')">
<img class="icon svg small" src="icon/event-delete.svg"/>
</button>`
@@ -254,16 +254,16 @@ function render_action_bar(damus, ev, can_delete) {
const like = "❤️"
const likes = groups[like] || {}
const react_onclick = render_react_onclick(damus.pubkey, ev.id, like, likes)
- return `
+ return html`
<div class="action-bar">
<button class="icon" title="Reply" onclick="reply_to('${ev.id}')">
<img class="icon svg small" src="icon/event-reply.svg"/>
</button>
- <button class="icon react heart" ${react_onclick} title="Like">
+ <button class="icon react heart" $${react_onclick} title="Like">
<img class="icon svg small" src="icon/event-like.svg"/>
</button>
<!--<button class="icon" title="Share" onclick=""><i class="fa fa-fw fa-link"></i></a>-->
- ${delete_html}
+ $${delete_html}
<!--<button class="icon" title="View raw Nostr event." onclick=""><i class="fa-solid fa-fw fa-code"></i></a>-->
</div>
`
@@ -277,9 +277,9 @@ function render_reactions(model, ev) {
str += render_reaction_group(model, emoji, groups[emoji], ev)
}
- return `
+ return html`
<div class="reactions">
- ${str}
+ $${str}
</div>
`
}
@@ -313,7 +313,7 @@ function render_mentioned_name(pk, profile) {
}
function render_name(pk, profile, prefix="") {
- return `
+ return html`
<span class="username clickable" onclick="show_profile('${pk}')"
data-pk="${pk}">${prefix}${render_name_plain(profile)}
</span>`
@@ -325,13 +325,13 @@ function render_deleted_name() {
function render_pfp(pk, profile) {
const name = render_name_plain(profile)
- return `<img class="pfp" title="${name}"
+ return html`<img class="pfp" title="${name}"
onerror="this.onerror=null;this.src='${robohash(pk)}';"
src="${get_picture(pk, profile)}">`
}
function render_deleted_pfp() {
- return `
+ return html`
<div class="pfp deleted">
<i class="fa-solid fa-fw fa-ghost"></i>
</div>`
@@ -339,7 +339,7 @@ function render_deleted_pfp() {
function render_loading_spinner()
{
- return `
+ return html`
<div class="loading-events">
<div class="loader" title="Loading...">
<img class="dark-invert" src="icon/loader-fragment.svg"/>
diff --git a/web/js/ui/safe-html.js b/web/js/ui/safe-html.js
@@ -0,0 +1,38 @@
+// https://github.com/AntonioVdlC/html-template-tag
+
+const chars = {
+ "&": "&",
+ ">": ">",
+ "<": "<",
+ '"': """,
+ "'": "'",
+ "`": "`",
+};
+
+// Dynamically create a RegExp from the `chars` object
+const re = new RegExp(Object.keys(chars).join("|"), "g");
+
+// Return the escaped string
+function escape(str) {
+ return String(str).replace(re, (match) => chars[match]);
+}
+
+function html(
+ literals,
+ ...substs
+) {
+ return literals.raw.reduce((acc, lit, i) => {
+ let subst = substs[i - 1];
+ if (Array.isArray(subst)) {
+ subst = subst.join("");
+ } else if (literals.raw[i - 1] && literals.raw[i - 1].endsWith("$")) {
+ // If the interpolation is preceded by a dollar sign,
+ // substitution is considered safe and will not be escaped
+ acc = acc.slice(0, -1);
+ } else {
+ subst = escape(subst);
+ }
+
+ return acc + subst + lit;
+ });
+}
diff --git a/web/js/util.js b/web/js/util.js
@@ -166,23 +166,23 @@ function linkify(text, show_media) {
return text.replace(URL_REGEX, function(match, p1, p2, p3) {
const url = p2+p3
const parsed = new URL(url)
- let html;
+ let markup;
if (show_media && is_img_url(parsed.pathname)) {
- html = `
+ markup = html`
<a target="_blank" href="${url}">
<img class="inline-img" src="${url}"/>
</a>
`;
} else if (show_media && is_video_url(parsed.pathname)) {
- html = `
+ markup = html`
<video controls class="inline-img" />
<source src="${url}">
</video>
`;
} else {
- html = `<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`;
+ markup = html`<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`;
}
- return p1+html;
+ return p1+markup;
})
}