mirror of https://github.com/zulip/zulip.git
util: Rename clean_user_content_links to postprocess_content.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
parent
b88b11b958
commit
edf34ada63
|
@ -139,7 +139,7 @@ js_rules = RuleList(
|
||||||
{"pattern": r"\+.*\$t\(.+\)", "description": "Do not concatenate i18n strings"},
|
{"pattern": r"\+.*\$t\(.+\)", "description": "Do not concatenate i18n strings"},
|
||||||
{
|
{
|
||||||
"pattern": "[.]html[(]",
|
"pattern": "[.]html[(]",
|
||||||
"exclude_pattern": r"""\.html\(("|'|render_|\w+_html|html|message\.content|util\.clean_user_content_links|rendered_|$|\)|error_html|widget_elem|\$error|\$\("<p>"\))""",
|
"exclude_pattern": r"""\.html\(("|'|render_|\w+_html|html|message\.content|postprocess_content|rendered_|$|\)|error_html|widget_elem|\$error|\$\("<p>"\))""",
|
||||||
"exclude": {
|
"exclude": {
|
||||||
"web/src/portico",
|
"web/src/portico",
|
||||||
"web/src/lightbox.ts",
|
"web/src/lightbox.ts",
|
||||||
|
|
|
@ -21,6 +21,7 @@ import * as markdown from "./markdown";
|
||||||
import * as message_events from "./message_events";
|
import * as message_events from "./message_events";
|
||||||
import * as onboarding_steps from "./onboarding_steps";
|
import * as onboarding_steps from "./onboarding_steps";
|
||||||
import * as people from "./people";
|
import * as people from "./people";
|
||||||
|
import {postprocess_content} from "./postprocess_content";
|
||||||
import * as rendered_markdown from "./rendered_markdown";
|
import * as rendered_markdown from "./rendered_markdown";
|
||||||
import * as scheduled_messages from "./scheduled_messages";
|
import * as scheduled_messages from "./scheduled_messages";
|
||||||
import * as sent_messages from "./sent_messages";
|
import * as sent_messages from "./sent_messages";
|
||||||
|
@ -350,7 +351,7 @@ export function render_and_show_preview($preview_spinner, $preview_content_box,
|
||||||
rendered_preview_html = rendered_content;
|
rendered_preview_html = rendered_content;
|
||||||
}
|
}
|
||||||
|
|
||||||
$preview_content_box.html(util.clean_user_content_links(rendered_preview_html));
|
$preview_content_box.html(postprocess_content(rendered_preview_html));
|
||||||
rendered_markdown.update_elements($preview_content_box);
|
rendered_markdown.update_elements($preview_content_box);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,11 +13,11 @@ import * as keydown_util from "./keydown_util";
|
||||||
import * as markdown from "./markdown";
|
import * as markdown from "./markdown";
|
||||||
import * as overlays from "./overlays";
|
import * as overlays from "./overlays";
|
||||||
import {page_params} from "./page_params";
|
import {page_params} from "./page_params";
|
||||||
|
import {postprocess_content} from "./postprocess_content";
|
||||||
import * as rendered_markdown from "./rendered_markdown";
|
import * as rendered_markdown from "./rendered_markdown";
|
||||||
import * as scroll_util from "./scroll_util";
|
import * as scroll_util from "./scroll_util";
|
||||||
import {current_user} from "./state_data";
|
import {current_user} from "./state_data";
|
||||||
import {user_settings} from "./user_settings";
|
import {user_settings} from "./user_settings";
|
||||||
import * as util from "./util";
|
|
||||||
|
|
||||||
// Make it explicit that our toggler is undefined until
|
// Make it explicit that our toggler is undefined until
|
||||||
// set_up_toggler is called.
|
// set_up_toggler is called.
|
||||||
|
@ -269,7 +269,7 @@ export function set_up_toggler(): void {
|
||||||
raw_content: row.markdown,
|
raw_content: row.markdown,
|
||||||
...markdown.render(row.markdown),
|
...markdown.render(row.markdown),
|
||||||
};
|
};
|
||||||
row.output_html = util.clean_user_content_links(message.content);
|
row.output_html = postprocess_content(message.content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
import {$t} from "./i18n";
|
||||||
|
|
||||||
|
let inertDocument: Document | undefined;
|
||||||
|
|
||||||
|
export function postprocess_content(html: string): string {
|
||||||
|
if (inertDocument === undefined) {
|
||||||
|
inertDocument = new DOMParser().parseFromString("", "text/html");
|
||||||
|
}
|
||||||
|
const template = inertDocument.createElement("template");
|
||||||
|
template.innerHTML = html;
|
||||||
|
|
||||||
|
for (const elt of template.content.querySelectorAll("a")) {
|
||||||
|
// Ensure that all external links have target="_blank"
|
||||||
|
// rel="opener noreferrer". This ensures that external links
|
||||||
|
// never replace the Zulip web app while also protecting
|
||||||
|
// against reverse tabnapping attacks, without relying on the
|
||||||
|
// correctness of how Zulip's Markdown processor generates links.
|
||||||
|
//
|
||||||
|
// Fragment links, which we intend to only open within the
|
||||||
|
// Zulip web app using our hashchange system, do not require
|
||||||
|
// these attributes.
|
||||||
|
const href = elt.getAttribute("href");
|
||||||
|
if (href === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(href, window.location.href);
|
||||||
|
} catch {
|
||||||
|
elt.removeAttribute("href");
|
||||||
|
elt.removeAttribute("title");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-script-url
|
||||||
|
if (["data:", "javascript:", "vbscript:"].includes(url.protocol)) {
|
||||||
|
// Remove unsafe links completely.
|
||||||
|
elt.removeAttribute("href");
|
||||||
|
elt.removeAttribute("title");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We detect URLs that are just fragments by comparing the URL
|
||||||
|
// against a new URL generated using only the hash.
|
||||||
|
if (url.hash === "" || url.href !== new URL(url.hash, window.location.href).href) {
|
||||||
|
elt.setAttribute("target", "_blank");
|
||||||
|
elt.setAttribute("rel", "noopener noreferrer");
|
||||||
|
} else {
|
||||||
|
elt.removeAttribute("target");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elt.parentElement?.classList.contains("message_inline_image")) {
|
||||||
|
// For inline images we want to handle the tooltips explicitly, and disable
|
||||||
|
// the browser's built in handling of the title attribute.
|
||||||
|
const title = elt.getAttribute("title");
|
||||||
|
if (title !== null) {
|
||||||
|
elt.setAttribute("aria-label", title);
|
||||||
|
elt.removeAttribute("title");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-image user uploads, the following block ensures that the title
|
||||||
|
// attribute always displays the filename as a security measure.
|
||||||
|
let title: string;
|
||||||
|
let legacy_title: string;
|
||||||
|
if (
|
||||||
|
url.origin === window.location.origin &&
|
||||||
|
url.pathname.startsWith("/user_uploads/")
|
||||||
|
) {
|
||||||
|
// We add the word "download" to make clear what will
|
||||||
|
// happen when clicking the file. This is particularly
|
||||||
|
// important in the desktop app, where hovering a URL does
|
||||||
|
// not display the URL like it does in the web app.
|
||||||
|
title = legacy_title = $t(
|
||||||
|
{defaultMessage: "Download {filename}"},
|
||||||
|
{filename: url.pathname.slice(url.pathname.lastIndexOf("/") + 1)},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
title = url.toString();
|
||||||
|
legacy_title = href;
|
||||||
|
}
|
||||||
|
elt.setAttribute(
|
||||||
|
"title",
|
||||||
|
["", legacy_title].includes(elt.title) ? title : `${title}\n${elt.title}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return template.innerHTML;
|
||||||
|
}
|
|
@ -20,6 +20,7 @@ import {$t, $t_html} from "./i18n";
|
||||||
import * as keydown_util from "./keydown_util";
|
import * as keydown_util from "./keydown_util";
|
||||||
import * as narrow_state from "./narrow_state";
|
import * as narrow_state from "./narrow_state";
|
||||||
import * as popovers from "./popovers";
|
import * as popovers from "./popovers";
|
||||||
|
import {postprocess_content} from "./postprocess_content";
|
||||||
import * as scroll_util from "./scroll_util";
|
import * as scroll_util from "./scroll_util";
|
||||||
import * as settings_components from "./settings_components";
|
import * as settings_components from "./settings_components";
|
||||||
import * as settings_config from "./settings_config";
|
import * as settings_config from "./settings_config";
|
||||||
|
@ -38,7 +39,6 @@ import * as sub_store from "./sub_store";
|
||||||
import * as ui_report from "./ui_report";
|
import * as ui_report from "./ui_report";
|
||||||
import * as user_groups from "./user_groups";
|
import * as user_groups from "./user_groups";
|
||||||
import {user_settings} from "./user_settings";
|
import {user_settings} from "./user_settings";
|
||||||
import * as util from "./util";
|
|
||||||
|
|
||||||
export function setup_subscriptions_tab_hash(tab_key_value) {
|
export function setup_subscriptions_tab_hash(tab_key_value) {
|
||||||
if ($("#subscription_overlay .right").hasClass("show")) {
|
if ($("#subscription_overlay .right").hasClass("show")) {
|
||||||
|
@ -132,7 +132,7 @@ export function update_stream_description(sub) {
|
||||||
const $edit_container = stream_settings_containers.get_edit_container(sub);
|
const $edit_container = stream_settings_containers.get_edit_container(sub);
|
||||||
$edit_container.find("input.description").val(sub.description);
|
$edit_container.find("input.description").val(sub.description);
|
||||||
const html = render_stream_description({
|
const html = render_stream_description({
|
||||||
rendered_description: util.clean_user_content_links(sub.rendered_description),
|
rendered_description: postprocess_content(sub.rendered_description),
|
||||||
});
|
});
|
||||||
$edit_container.find(".stream-description").html(html);
|
$edit_container.find(".stream-description").html(html);
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import * as message_live_update from "./message_live_update";
|
||||||
import * as message_view_header from "./message_view_header";
|
import * as message_view_header from "./message_view_header";
|
||||||
import * as narrow_state from "./narrow_state";
|
import * as narrow_state from "./narrow_state";
|
||||||
import * as overlays from "./overlays";
|
import * as overlays from "./overlays";
|
||||||
|
import {postprocess_content} from "./postprocess_content";
|
||||||
import * as resize from "./resize";
|
import * as resize from "./resize";
|
||||||
import * as scroll_util from "./scroll_util";
|
import * as scroll_util from "./scroll_util";
|
||||||
import * as search_util from "./search_util";
|
import * as search_util from "./search_util";
|
||||||
|
@ -39,7 +40,6 @@ import * as stream_settings_components from "./stream_settings_components";
|
||||||
import * as stream_settings_data from "./stream_settings_data";
|
import * as stream_settings_data from "./stream_settings_data";
|
||||||
import * as stream_ui_updates from "./stream_ui_updates";
|
import * as stream_ui_updates from "./stream_ui_updates";
|
||||||
import * as sub_store from "./sub_store";
|
import * as sub_store from "./sub_store";
|
||||||
import * as util from "./util";
|
|
||||||
|
|
||||||
export function is_sub_already_present(sub) {
|
export function is_sub_already_present(sub) {
|
||||||
return stream_ui_updates.row_for_stream_id(sub.stream_id).length > 0;
|
return stream_ui_updates.row_for_stream_id(sub.stream_id).length > 0;
|
||||||
|
@ -133,7 +133,7 @@ export function update_stream_description(sub, description, rendered_description
|
||||||
|
|
||||||
// Update stream row
|
// Update stream row
|
||||||
const $sub_row = stream_ui_updates.row_for_stream_id(sub.stream_id);
|
const $sub_row = stream_ui_updates.row_for_stream_id(sub.stream_id);
|
||||||
$sub_row.find(".description").html(util.clean_user_content_links(sub.rendered_description));
|
$sub_row.find(".description").html(postprocess_content(sub.rendered_description));
|
||||||
|
|
||||||
// Update stream settings
|
// Update stream settings
|
||||||
stream_edit.update_stream_description(sub);
|
stream_edit.update_stream_description(sub);
|
||||||
|
|
|
@ -2,7 +2,7 @@ import Handlebars from "handlebars/runtime";
|
||||||
|
|
||||||
import * as common from "./common";
|
import * as common from "./common";
|
||||||
import {default_html_elements, intl} from "./i18n";
|
import {default_html_elements, intl} from "./i18n";
|
||||||
import * as util from "./util";
|
import {postprocess_content} from "./postprocess_content";
|
||||||
|
|
||||||
// Below, we register Zulip-specific extensions to the Handlebars API.
|
// Below, we register Zulip-specific extensions to the Handlebars API.
|
||||||
//
|
//
|
||||||
|
@ -112,7 +112,7 @@ Handlebars.registerHelper("tr", function (options) {
|
||||||
|
|
||||||
Handlebars.registerHelper(
|
Handlebars.registerHelper(
|
||||||
"rendered_markdown",
|
"rendered_markdown",
|
||||||
(content) => new Handlebars.SafeString(util.clean_user_content_links(content)),
|
(content) => new Handlebars.SafeString(postprocess_content(content)),
|
||||||
);
|
);
|
||||||
|
|
||||||
Handlebars.registerHelper("numberFormat", (number) => number.toLocaleString());
|
Handlebars.registerHelper("numberFormat", (number) => number.toLocaleString());
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
|
|
||||||
import * as blueslip from "./blueslip";
|
import * as blueslip from "./blueslip";
|
||||||
import {$t} from "./i18n";
|
|
||||||
import type {MatchedMessage, Message, RawMessage} from "./message_store";
|
import type {MatchedMessage, Message, RawMessage} from "./message_store";
|
||||||
import type {UpdateMessageEvent} from "./types";
|
import type {UpdateMessageEvent} from "./types";
|
||||||
import {user_settings} from "./user_settings";
|
import {user_settings} from "./user_settings";
|
||||||
|
@ -292,93 +291,6 @@ export function canonicalize_stream_synonyms(text: string): string {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
let inertDocument: Document | undefined;
|
|
||||||
|
|
||||||
export function clean_user_content_links(html: string): string {
|
|
||||||
if (inertDocument === undefined) {
|
|
||||||
inertDocument = new DOMParser().parseFromString("", "text/html");
|
|
||||||
}
|
|
||||||
const template = inertDocument.createElement("template");
|
|
||||||
template.innerHTML = html;
|
|
||||||
|
|
||||||
for (const elt of template.content.querySelectorAll("a")) {
|
|
||||||
// Ensure that all external links have target="_blank"
|
|
||||||
// rel="opener noreferrer". This ensures that external links
|
|
||||||
// never replace the Zulip web app while also protecting
|
|
||||||
// against reverse tabnapping attacks, without relying on the
|
|
||||||
// correctness of how Zulip's Markdown processor generates links.
|
|
||||||
//
|
|
||||||
// Fragment links, which we intend to only open within the
|
|
||||||
// Zulip web app using our hashchange system, do not require
|
|
||||||
// these attributes.
|
|
||||||
const href = elt.getAttribute("href");
|
|
||||||
if (href === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let url;
|
|
||||||
try {
|
|
||||||
url = new URL(href, window.location.href);
|
|
||||||
} catch {
|
|
||||||
elt.removeAttribute("href");
|
|
||||||
elt.removeAttribute("title");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-script-url
|
|
||||||
if (["data:", "javascript:", "vbscript:"].includes(url.protocol)) {
|
|
||||||
// Remove unsafe links completely.
|
|
||||||
elt.removeAttribute("href");
|
|
||||||
elt.removeAttribute("title");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We detect URLs that are just fragments by comparing the URL
|
|
||||||
// against a new URL generated using only the hash.
|
|
||||||
if (url.hash === "" || url.href !== new URL(url.hash, window.location.href).href) {
|
|
||||||
elt.setAttribute("target", "_blank");
|
|
||||||
elt.setAttribute("rel", "noopener noreferrer");
|
|
||||||
} else {
|
|
||||||
elt.removeAttribute("target");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elt.parentElement?.classList.contains("message_inline_image")) {
|
|
||||||
// For inline images we want to handle the tooltips explicitly, and disable
|
|
||||||
// the browser's built in handling of the title attribute.
|
|
||||||
const title = elt.getAttribute("title");
|
|
||||||
if (title !== null) {
|
|
||||||
elt.setAttribute("aria-label", title);
|
|
||||||
elt.removeAttribute("title");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For non-image user uploads, the following block ensures that the title
|
|
||||||
// attribute always displays the filename as a security measure.
|
|
||||||
let title: string;
|
|
||||||
let legacy_title: string;
|
|
||||||
if (
|
|
||||||
url.origin === window.location.origin &&
|
|
||||||
url.pathname.startsWith("/user_uploads/")
|
|
||||||
) {
|
|
||||||
// We add the word "download" to make clear what will
|
|
||||||
// happen when clicking the file. This is particularly
|
|
||||||
// important in the desktop app, where hovering a URL does
|
|
||||||
// not display the URL like it does in the web app.
|
|
||||||
title = legacy_title = $t(
|
|
||||||
{defaultMessage: "Download {filename}"},
|
|
||||||
{filename: url.pathname.slice(url.pathname.lastIndexOf("/") + 1)},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
title = url.toString();
|
|
||||||
legacy_title = href;
|
|
||||||
}
|
|
||||||
elt.setAttribute(
|
|
||||||
"title",
|
|
||||||
["", legacy_title].includes(elt.title) ? title : `${title}\n${elt.title}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return template.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filter_by_word_prefix_match<T>(
|
export function filter_by_word_prefix_match<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
search_term: string,
|
search_term: string,
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const {strict: assert} = require("assert");
|
||||||
|
|
||||||
|
const {zrequire} = require("./lib/namespace");
|
||||||
|
const {run_test} = require("./lib/test");
|
||||||
|
|
||||||
|
const {postprocess_content} = zrequire("postprocess_content");
|
||||||
|
|
||||||
|
run_test("postprocess_content", () => {
|
||||||
|
assert.equal(
|
||||||
|
postprocess_content(
|
||||||
|
'<a href="http://example.com">good</a> ' +
|
||||||
|
'<a href="http://zulip.zulipdev.com/user_uploads/w/ha/tever/file.png">upload</a> ' +
|
||||||
|
'<a href="http://localhost:NNNN">invalid</a> ' +
|
||||||
|
'<a href="javascript:alert(1)">unsafe</a> ' +
|
||||||
|
'<a href="/#fragment" target="_blank">fragment</a>' +
|
||||||
|
'<div class="message_inline_image">' +
|
||||||
|
'<a href="http://zulip.zulipdev.com/user_uploads/w/ha/tever/inline.png" title="inline image">upload</a> ' +
|
||||||
|
'<a role="button">button</a> ' +
|
||||||
|
"</div>",
|
||||||
|
),
|
||||||
|
'<a href="http://example.com" target="_blank" rel="noopener noreferrer" title="http://example.com/">good</a> ' +
|
||||||
|
'<a href="http://zulip.zulipdev.com/user_uploads/w/ha/tever/file.png" target="_blank" rel="noopener noreferrer" title="translated: Download file.png">upload</a> ' +
|
||||||
|
"<a>invalid</a> " +
|
||||||
|
"<a>unsafe</a> " +
|
||||||
|
'<a href="/#fragment" title="http://zulip.zulipdev.com/#fragment">fragment</a>' +
|
||||||
|
'<div class="message_inline_image">' +
|
||||||
|
'<a href="http://zulip.zulipdev.com/user_uploads/w/ha/tever/inline.png" target="_blank" rel="noopener noreferrer" aria-label="inline image">upload</a> ' +
|
||||||
|
'<a role="button">button</a> ' +
|
||||||
|
"</div>",
|
||||||
|
);
|
||||||
|
});
|
|
@ -302,31 +302,6 @@ run_test("move_array_elements_to_front", () => {
|
||||||
assert.deepEqual(emails_actual, emails_expected);
|
assert.deepEqual(emails_actual, emails_expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
run_test("clean_user_content_links", () => {
|
|
||||||
assert.equal(
|
|
||||||
util.clean_user_content_links(
|
|
||||||
'<a href="http://example.com">good</a> ' +
|
|
||||||
'<a href="http://zulip.zulipdev.com/user_uploads/w/ha/tever/file.png">upload</a> ' +
|
|
||||||
'<a href="http://localhost:NNNN">invalid</a> ' +
|
|
||||||
'<a href="javascript:alert(1)">unsafe</a> ' +
|
|
||||||
'<a href="/#fragment" target="_blank">fragment</a>' +
|
|
||||||
'<div class="message_inline_image">' +
|
|
||||||
'<a href="http://zulip.zulipdev.com/user_uploads/w/ha/tever/inline.png" title="inline image">upload</a> ' +
|
|
||||||
'<a role="button">button</a> ' +
|
|
||||||
"</div>",
|
|
||||||
),
|
|
||||||
'<a href="http://example.com" target="_blank" rel="noopener noreferrer" title="http://example.com/">good</a> ' +
|
|
||||||
'<a href="http://zulip.zulipdev.com/user_uploads/w/ha/tever/file.png" target="_blank" rel="noopener noreferrer" title="translated: Download file.png">upload</a> ' +
|
|
||||||
"<a>invalid</a> " +
|
|
||||||
"<a>unsafe</a> " +
|
|
||||||
'<a href="/#fragment" title="http://zulip.zulipdev.com/#fragment">fragment</a>' +
|
|
||||||
'<div class="message_inline_image">' +
|
|
||||||
'<a href="http://zulip.zulipdev.com/user_uploads/w/ha/tever/inline.png" target="_blank" rel="noopener noreferrer" aria-label="inline image">upload</a> ' +
|
|
||||||
'<a role="button">button</a> ' +
|
|
||||||
"</div>",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
run_test("filter_by_word_prefix_match", () => {
|
run_test("filter_by_word_prefix_match", () => {
|
||||||
const strings = ["stream-hyphen_underscore/slash", "three word stream"];
|
const strings = ["stream-hyphen_underscore/slash", "three word stream"];
|
||||||
const values = [0, 1];
|
const values = [0, 1];
|
||||||
|
|
Loading…
Reference in New Issue