From b77766c8d79a787f04db4943950ee1cc18505baf Mon Sep 17 00:00:00 2001 From: Robert Schroll Date: Tue, 28 May 2013 18:28:33 -0700 Subject: [PATCH] Malicious HTML link checker: Closes #6933 Pops up a small warning if the user clicks on a link that doesn't match URL-looking text. --- src/client/views/conversation-viewer.vala | 125 ++++++++++++++++++++ src/client/views/conversation-web-view.vala | 1 + theming/message-viewer.css | 29 ++++- theming/message-viewer.html | 3 + 4 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/client/views/conversation-viewer.vala b/src/client/views/conversation-viewer.vala index db65a07b..0a731e9d 100644 --- a/src/client/views/conversation-viewer.vala +++ b/src/client/views/conversation-viewer.vala @@ -247,6 +247,7 @@ public class ConversationViewer : Gtk.Box { bind_event(web_view, ".remote_images .show_images", "click", (Callback) on_show_images, this); bind_event(web_view, ".remote_images .show_from", "click", (Callback) on_show_images_from, this); bind_event(web_view, ".remote_images .close_show_images", "click", (Callback) on_close_show_images, this); + bind_event(web_view, ".body a", "click", (Callback) on_link_clicked, this); // Update the search results if (conversation_find_bar.visible) @@ -792,6 +793,130 @@ public class ConversationViewer : Gtk.Box { } } } + + private static void on_link_clicked(WebKit.DOM.Element element, WebKit.DOM.Event event, + ConversationViewer conversation_viewer) { + if (conversation_viewer.on_link_clicked_self(element)) + event.prevent_default(); + } + + private bool on_link_clicked_self(WebKit.DOM.Element element) { + if (!Geary.String.is_empty(element.get_attribute("warning"))) { + // A warning is open, so ignore clicks. + return true; + } + + string? href = element.get_attribute("href"); + if (Geary.String.is_empty(href)) + return false; + string text = ((WebKit.DOM.HTMLElement) element).get_inner_text(); + string href_short, text_short; + if (!deceptive_text(href, ref text, out href_short, out text_short)) + return false; + + WebKit.DOM.HTMLElement div = Util.DOM.clone_select(web_view.get_dom_document(), + "#link_warning_template"); + try { + div.set_inner_html("""%s %s %s %s + %s""".printf(div.get_inner_html(), + _("This link appears to go to"), text, text_short, + _("but actually goes to"), href, href_short)); + div.remove_attribute("id"); + element.parent_node.insert_before(div, element); + element.set_attribute("warning", "open"); + + long overhang = div.get_offset_left() + div.get_offset_width() - + web_view.get_dom_document().get_body().get_offset_width(); + if (overhang > 0) + div.set_attribute("style", @"margin-left: -$(overhang)px;"); + } catch (Error error) { + warning("Error showing link warning dialog: %s", error.message); + } + bind_event(web_view, ".link_warning .close_link_warning, .link_warning a", "click", + (Callback) on_close_link_warning, this); + return true; + } + + /* + * Test whether text looks like a URI that leads somewhere other than href. The text + * will have a scheme prepended if it doesn't already have one, and the short versions + * have the scheme skipped and long paths truncated. + */ + private bool deceptive_text(string href, ref string text, out string href_short, + out string text_short) { + href_short = ""; + text_short = ""; + // mailto URLs have a different form, and the worst they can do is pop up a composer, + // so we don't trigger on them. + if (href.has_prefix("mailto:")) + return false; + + // First, does text look like a URI? Right now, just test whether it has + // . in it. More sophisticated tests are possible. + GLib.MatchInfo text_match, href_match; + try { + GLib.Regex domain = new GLib.Regex( + "([a-z]*://)?" // Optional scheme + + "([^\\s:/]+\\.[^\\s:/\\.]+)" // Domain + + "(/[^\\s]*)?" // Optional path + ); + if (!domain.match(text, 0, out text_match)) + return false; + if (!domain.match(href, 0, out href_match)) { + // If href doesn't look like a URL, something is fishy, so warn the user + href_short = href + _(" (Invalid?)"); + text_short = text; + return true; + } + } catch (Error error) { + warning("Error in Regex text for deceptive urls: %s", error.message); + return false; + } + + // Second, do the top levels of the two domains match? We compare the top n levels, + // where n is the minimum of the number of levels of the two domains. + string[] href_parts = href_match.fetch_all(); + string[] text_parts = text_match.fetch_all(); + string[] text_domain = text_parts[2].reverse().split("."); + string[] href_domain = href_parts[2].reverse().split("."); + for (int i = 0; i < text_domain.length && i < href_domain.length; i++) { + if (text_domain[i] != href_domain[i]) { + if (href_parts[1] == "") + href_parts[1] = "http://"; + if (text_parts[1] == "") + text_parts[1] = href_parts[1]; + string temp; + assemble_uris(href_parts, out temp, out href_short); + assemble_uris(text_parts, out text, out text_short); + return true; + } + } + return false; + } + + private void assemble_uris(string[] parts, out string full, out string short_) { + full = parts[1] + parts[2]; + short_ = parts[2]; + if (parts.length == 4 && parts[3] != "/") { + full += parts[3]; + if (parts[3].length > 20) + short_ += parts[3].substring(0, 20) + "…"; + else + short_ += parts[3]; + } + } + + private static void on_close_link_warning(WebKit.DOM.Element element, WebKit.DOM.Event event, + ConversationViewer conversation_viewer) { + try { + WebKit.DOM.Element warning_div = closest_ancestor(element, ".link_warning"); + WebKit.DOM.Element link = (WebKit.DOM.Element) warning_div.get_next_sibling(); + link.remove_attribute("warning"); + warning_div.parent_node.remove_child(warning_div); + } catch (Error error) { + warning("Error removing link warning dialog: %s", error.message); + } + } private static void on_attachment_clicked(WebKit.DOM.Element element, WebKit.DOM.Event event, ConversationViewer conversation_viewer) { diff --git a/src/client/views/conversation-web-view.vala b/src/client/views/conversation-web-view.vala index 3909dcb8..defbc7e5 100644 --- a/src/client/views/conversation-web-view.vala +++ b/src/client/views/conversation-web-view.vala @@ -146,6 +146,7 @@ public class ConversationWebView : WebKit.WebView { set_icon_src("#email_template .unstarred .icon", "non-starred-grey"); set_icon_src("#email_template .attachment.icon", "mail-attachment"); set_icon_src("#email_template .close_show_images", "gtk-close"); + set_icon_src("#link_warning_template .close_link_warning", "gtk-close"); } private void load_user_style() { diff --git a/theming/message-viewer.css b/theming/message-viewer.css index dcbd6f0e..6da19de3 100644 --- a/theming/message-viewer.css +++ b/theming/message-viewer.css @@ -188,6 +188,32 @@ body:not(.nohide) .email.hide .header_container .avatar { margin-right: -0.67em; } +.email .link_warning { + display: inline-block; + position: absolute; + margin-top: -1em; + border: 1px solid #999; + padding: 1em; + background: #ffc; + box-shadow: 0 3px 11px rgba(0,0,0,0.21); + /* Reset styles */ + font-size: small; + font-family: sans; + color: black; +} +.email .link_warning a { + color: #08c; +} +.email .link_warning span { + display: block; + padding-left: 1em; +} +.email .link_warning .close_link_warning { + float: right; + margin-top: -0.67em; + margin-right: -0.67em; +} + @media screen { body { @@ -468,7 +494,8 @@ body:not(.nohide) .quote_container.controllable.show > .quote { padding: 15px; } #email_template, -#attachment_template { +#attachment_template, +#link_warning_template { display: none; } diff --git a/theming/message-viewer.html b/theming/message-viewer.html index 5ff3d163..5b3768cc 100644 --- a/theming/message-viewer.html +++ b/theming/message-viewer.html @@ -32,5 +32,8 @@ +