diff --git a/bindings/vapi/javascriptcore-4.0.vapi b/bindings/vapi/javascriptcore-4.0.vapi index f31478ed..d152ce2a 100644 --- a/bindings/vapi/javascriptcore-4.0.vapi +++ b/bindings/vapi/javascriptcore-4.0.vapi @@ -89,9 +89,9 @@ namespace JS { public bool has_property(Context ctx, String property_name); [CCode (cname = "JSObjectGetProperty", instance_pos = 1.1)] - public String get_property(Context ctx, - String property_name, - out Value? exception); + public Value get_property(Context ctx, + String property_name, + out Value? exception); } diff --git a/src/client/conversation-viewer/conversation-message.vala b/src/client/conversation-viewer/conversation-message.vala index f432a60b..12091619 100644 --- a/src/client/conversation-viewer/conversation-message.vala +++ b/src/client/conversation-viewer/conversation-message.vala @@ -210,10 +210,10 @@ public class ConversationMessage : Gtk.Grid { [GtkChild] private Gtk.Popover link_popover; - //[GtkChild] - //private Gtk.Label good_link_label; - //[GtkChild] - //private Gtk.Label bad_link_label; + [GtkChild] + private Gtk.Label good_link_label; + [GtkChild] + private Gtk.Label bad_link_label; [GtkChild] private Gtk.InfoBar remote_images_infobar; @@ -382,6 +382,7 @@ public class ConversationMessage : Gtk.Grid { this.web_view.allow_remote_image_loading(); } this.web_view.context_menu.connect(on_context_menu); + this.web_view.deceptive_link_clicked.connect(on_deceptive_link_clicked); this.web_view.link_activated.connect((link) => { link_activated(link); }); @@ -725,75 +726,6 @@ public class ConversationMessage : Gtk.Grid { } } - /* - * 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].down().reverse().split("."); - // string[] href_domain = href_parts[2].down().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 inline void set_revealer(Gtk.Revealer revealer, bool expand, bool use_transition) { @@ -936,42 +868,36 @@ public class ConversationMessage : Gtk.Grid { this.body_container.trigger_tooltip_query(); } - // // Check for possible phishing links, displays a popover if found. - // // If not, lets it go through to the default handler. - // private bool on_link_clicked() { - // 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; + // Check for possible phishing links, displays a popover if found. + // If not, lets it go through to the default handler. + private void on_deceptive_link_clicked(ConversationWebView.DeceptiveText reason, + string text, + string href, + Gdk.Rectangle location) { + string text_href = text; + if (Uri.parse_scheme(text_href) == null) { + text_href = "http://" + text_href; + } + string text_label = Soup.URI.decode(text_href); - // Escape text and especially URLs since we got them from the - // HREF, and Gtk.Label.set_markup is a strict parser. - // good_link_label.set_markup( - // Markup.printf_escaped("%s", text, text_short) - // ); - // bad_link_label.set_markup( - // Markup.printf_escaped("%s", href, href_short) - // ); + string anchor_href = href; + if (Uri.parse_scheme(anchor_href) == null) { + anchor_href = "http://" + anchor_href; + } + string anchor_label = Soup.URI.decode(anchor_href); - // Work out the link's position, update the popover. - // Gdk.Rectangle link_rect = Gdk.Rectangle(); - // web_view.get_allocation(out link_rect); - // WebKit.DOM.Element? offset_parent = element; - // while (offset_parent != null) { - // link_rect.x += (int) offset_parent.offset_left; - // link_rect.y += (int) offset_parent.offset_top; - // offset_parent = offset_parent.offset_parent; - // } - // link_rect.width = (int) element.offset_width; - // link_rect.height = (int) element.offset_height; - // link_popover.set_pointing_to(link_rect); - - // link_popover.show(); - // return true; - // } + // Escape text and especially URLs since we got them from the + // HREF, and Gtk.Label.set_markup is a strict parser. + good_link_label.set_markup( + Markup.printf_escaped("%s", text_href, text_label) + ); + bad_link_label.set_markup( + Markup.printf_escaped("%s", anchor_href, anchor_label) + ); + link_popover.set_relative_to(this.web_view); + link_popover.set_pointing_to(location); + link_popover.show(); + } [GtkCallback] private bool on_link_popover_activated() { diff --git a/src/client/conversation-viewer/conversation-web-view.vala b/src/client/conversation-viewer/conversation-web-view.vala index 03e7f46b..78889cd3 100644 --- a/src/client/conversation-viewer/conversation-web-view.vala +++ b/src/client/conversation-viewer/conversation-web-view.vala @@ -1,6 +1,6 @@ -/* +/* * Copyright 2016 Software Freedom Conservancy Inc. - * Copyright 2016 Michael Gratton + * Copyright 2017 Michael Gratton * * This software is licensed under the GNU Lesser General Public License * (version 2.1 or later). See the COPYING file in this distribution. @@ -10,6 +10,19 @@ public class ConversationWebView : ClientWebView { private const string USER_CSS = "user-message.css"; + private const string DECEPTIVE_LINK_CLICKED = "deceptiveLinkClicked"; + + /** Specifies the type of deceptive link text when clicked. */ + public enum DeceptiveText { + // Keep this in sync with JS ConversationPageState + /** No deceptive text found. */ + NOT_DECEPTIVE = 0, + /** The link had an invalid HREF value. */ + DECEPTIVE_HREF = 1, + /** The domain of the link's text did not match the HREF. */ + DECEPTIVE_DOMAIN = 2; + } + private static WebKit.UserStyleSheet? user_stylesheet = null; private static WebKit.UserStyleSheet? app_stylesheet = null; private static WebKit.UserScript? app_script = null; @@ -28,6 +41,12 @@ public class ConversationWebView : ClientWebView { } + /** Emitted when the user clicks on a link with deceptive text. */ + public signal void deceptive_link_clicked( + DeceptiveText reason, string text, string href, Gdk.Rectangle location + ); + + public ConversationWebView(Configuration config) { base(config); this.user_content_manager.add_script(ConversationWebView.app_script); @@ -35,6 +54,10 @@ public class ConversationWebView : ClientWebView { if (ConversationWebView.user_stylesheet != null) { this.user_content_manager.add_style_sheet(ConversationWebView.user_stylesheet); } + + register_message_handler( + DECEPTIVE_LINK_CLICKED, on_deceptive_link_clicked + ); } /** @@ -57,4 +80,45 @@ public class ConversationWebView : ClientWebView { return WebKitUtil.to_string(result); } + private void on_deceptive_link_clicked(WebKit.JavascriptResult result) { + try { + JS.GlobalContext context = result.get_global_context(); + JS.Object details = WebKitUtil.to_object(result); + + uint reason = (uint) Geary.JS.to_number( + context, + Geary.JS.get_property(context, details, "reason")); + + string href = Geary.JS.to_string( + context, + Geary.JS.get_property(context, details, "href")); + + string text = Geary.JS.to_string( + context, + Geary.JS.get_property(context, details, "text")); + + JS.Object js_location = Geary.JS.to_object( + context, + Geary.JS.get_property(context, details, "location")); + + Gdk.Rectangle location = new Gdk.Rectangle(); + location.x = (int) Geary.JS.to_number( + context, + Geary.JS.get_property(context, js_location, "x")); + location.y = (int) Geary.JS.to_number( + context, + Geary.JS.get_property(context, js_location, "y")); + location.width = (int) Geary.JS.to_number( + context, + Geary.JS.get_property(context, js_location, "width")); + location.height = (int) Geary.JS.to_number( + context, + Geary.JS.get_property(context, js_location, "height")); + + deceptive_link_clicked((DeceptiveText) reason, text, href, location); + } catch (Geary.JS.Error err) { + debug("Could not get deceptive link param: %s", err.message); + } + } + } diff --git a/src/client/util/util-webkit.vala b/src/client/util/util-webkit.vala index 79bdd3ba..375c47b8 100644 --- a/src/client/util/util-webkit.vala +++ b/src/client/util/util-webkit.vala @@ -67,4 +67,16 @@ namespace WebKitUtil { return Geary.JS.to_string_released(js_str); } + /** + * Returns a WebKit {@link WebKit.JavascriptResult} as an Object. + * + * This will raise a {@link Geary.JS.Error.TYPE} error if the + * result is not a JavaScript `Object`. + */ + public JS.Object to_object(WebKit.JavascriptResult result) + throws Geary.JS.Error { + return Geary.JS.to_object(result.get_global_context(), + result.get_value()); + } + } diff --git a/src/engine/util/util-js.vala b/src/engine/util/util-js.vala index a25e3308..af2fb26c 100644 --- a/src/engine/util/util-js.vala +++ b/src/engine/util/util-js.vala @@ -75,6 +75,26 @@ namespace Geary.JS { return Geary.JS.to_string_released(js_str); } + /** + * Returns a JSC Value as an object. + * + * This will raise a {@link Geary.JS.Error.TYPE} error if the + * value is not a JavaScript `Object`. + */ + public global::JS.Object to_object(global::JS.Context context, + global::JS.Value value) + throws Geary.JS.Error { + if (!value.is_object(context)) { + throw new Geary.JS.Error.TYPE("Value is not a JS Object"); + } + + global::JS.Value? err = null; + global::JS.Object js_obj = value.to_object(context, out err); + Geary.JS.check_exception(context, err); + + return js_obj; + } + /** * Returns a JSC {@link JS.String} as a Vala {@link string}. */ @@ -86,6 +106,27 @@ namespace Geary.JS { return str; } + /** + * Returns the value of an object's property. + * + * This will raise a {@link Geary.JS.Error.TYPE} error if the + * object does not contain the named property. + */ + public inline global::JS.Value get_property(global::JS.Context context, + global::JS.Object object, + string name) + throws Geary.JS.Error { + global::JS.String js_name = new global::JS.String.create_with_utf8_cstring(name); + global::JS.Value? err = null; + global::JS.Value prop = object.get_property(context, js_name, out err); + try { + Geary.JS.check_exception(context, err); + } finally { + js_name.release(); + } + return prop; + } + /** * Checks an JS exception returned from a JSC call. * diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 363fa52a..c0502872 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -19,6 +19,7 @@ set(TEST_SRC client/composer/composer-web-view-test.vala js/composer-page-state-test.vala + js/conversation-page-state-test.vala ) # Vala diff --git a/test/client/components/client-web-view-test-case.vala b/test/client/components/client-web-view-test-case.vala index ddbe9a8d..9854a6aa 100644 --- a/test/client/components/client-web-view-test-case.vala +++ b/test/client/components/client-web-view-test-case.vala @@ -42,4 +42,13 @@ public abstract class ClientWebViewTestCase : Gee.TestCase { } } + protected WebKit.JavascriptResult run_javascript(string command) throws Error { + ClientWebView view = (ClientWebView) this.test_view; + view.run_javascript.begin( + command, null, (obj, res) => { async_complete(res); } + ); + + return view.run_javascript.end(async_result()); + } + } diff --git a/test/js/composer-page-state-test.vala b/test/js/composer-page-state-test.vala index 68b72791..42439c35 100644 --- a/test/js/composer-page-state-test.vala +++ b/test/js/composer-page-state-test.vala @@ -25,7 +25,7 @@ class ComposerPageStateTest : ClientWebViewTestCase { load_body_fixture(html); try { - assert(run_javascript(@"new EditContext(document.getElementById('test')).encode()") + assert(WebKitUtil.to_string(run_javascript(@"new EditContext(document.getElementById('test')).encode()")) .has_prefix("1,url,")); } catch (Geary.JS.Error err) { print("Geary.JS.Error: %s\n", err.message); @@ -41,8 +41,8 @@ class ComposerPageStateTest : ClientWebViewTestCase { load_body_fixture(html); try { - assert(run_javascript(@"new EditContext(document.getElementById('test')).encode()") - == ("0,,Comic Sans,144")); + assert(WebKitUtil.to_string(run_javascript(@"new EditContext(document.getElementById('test')).encode()")) == + "0,,Comic Sans,144"); } catch (Geary.JS.Error err) { print("Geary.JS.Error: %s\n", err.message); assert_not_reached(); @@ -56,7 +56,8 @@ class ComposerPageStateTest : ClientWebViewTestCase { string html = "

para

"; load_body_fixture(html); try { - assert(run_javascript(@"window.geary.getHtml();") == html + "

"); + assert(WebKitUtil.to_string(run_javascript(@"window.geary.getHtml();")) == + html + "

"); } catch (Geary.JS.Error err) { print("Geary.JS.Error: %s\n", err.message); assert_not_reached(); @@ -69,7 +70,8 @@ class ComposerPageStateTest : ClientWebViewTestCase { public void get_text() { load_body_fixture("

para

"); try { - assert(run_javascript(@"window.geary.getText();") == "para\n\n\n\n"); + assert(WebKitUtil.to_string(run_javascript(@"window.geary.getText();")) == + "para\n\n\n\n"); } catch (Geary.JS.Error err) { print("Geary.JS.Error: %s\n", err.message); assert_not_reached(); @@ -83,7 +85,7 @@ class ComposerPageStateTest : ClientWebViewTestCase { unichar q_marker = Geary.RFC822.Utils.QUOTE_MARKER; load_body_fixture("

pre

quote

post

"); try { - assert(run_javascript(@"window.geary.getText();") == + assert(WebKitUtil.to_string(run_javascript(@"window.geary.getText();")) == @"pre\n\n$(q_marker)quote\n$(q_marker)\npost\n\n\n\n"); } catch (Geary.JS.Error err) { print("Geary.JS.Error: %s", err.message); @@ -98,7 +100,7 @@ class ComposerPageStateTest : ClientWebViewTestCase { unichar q_marker = Geary.RFC822.Utils.QUOTE_MARKER; load_body_fixture("

pre

quote1

quote2

post

"); try { - assert(run_javascript(@"window.geary.getText();") == + assert(WebKitUtil.to_string(run_javascript(@"window.geary.getText();")) == @"pre\n\n$(q_marker)quote1\n$(q_marker)\n$(q_marker)$(q_marker)quote2\n$(q_marker)$(q_marker)\npost\n\n\n\n"); } catch (Geary.JS.Error err) { print("Geary.JS.Error: %s\n", err.message); @@ -122,17 +124,17 @@ class ComposerPageStateTest : ClientWebViewTestCase { string js_cosy_quote2 = @"foo$(q_start)0$(q_end)$(q_start)1$(q_end)bar"; string js_values = "['quote1','quote2']"; try { - assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_no_quote)', $(js_values));") == + assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_no_quote)', $(js_values));")) == @"foo"); - assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_spaced_quote)', $(js_values));") == + assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_spaced_quote)', $(js_values));")) == @"foo \n$(q_marker)quote1\n bar"); - assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_leading_quote)', $(js_values));") == + assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_leading_quote)', $(js_values));")) == @"$(q_marker)quote1\n bar"); - assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_hanging_quote)', $(js_values));") == + assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_hanging_quote)', $(js_values));")) == @"foo \n$(q_marker)quote1"); - assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_cosy_quote1)', $(js_values));") == + assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_cosy_quote1)', $(js_values));")) == @"foo\n$(q_marker)quote1\nbar"); - assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_cosy_quote2)', $(js_values));") == + assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_cosy_quote2)', $(js_values));")) == @"foo\n$(q_marker)quote1\n$(q_marker)quote2\nbar"); } catch (Geary.JS.Error err) { print("Geary.JS.Error: %s\n", err.message); @@ -147,11 +149,11 @@ class ComposerPageStateTest : ClientWebViewTestCase { load_body_fixture(); unichar q_marker = Geary.RFC822.Utils.QUOTE_MARKER; try { - assert(run_javascript("ComposerPageState.quoteLines('');") == + assert(WebKitUtil.to_string(run_javascript("ComposerPageState.quoteLines('');")) == @"$(q_marker)"); - assert(run_javascript("ComposerPageState.quoteLines('line1');") == + assert(WebKitUtil.to_string(run_javascript("ComposerPageState.quoteLines('line1');")) == @"$(q_marker)line1"); - assert(run_javascript("ComposerPageState.quoteLines('line1\\nline2');") == + assert(WebKitUtil.to_string(run_javascript("ComposerPageState.quoteLines('line1\\nline2');")) == @"$(q_marker)line1\n$(q_marker)line2"); } catch (Geary.JS.Error err) { print("Geary.JS.Error: %s\n", err.message); @@ -167,9 +169,9 @@ class ComposerPageStateTest : ClientWebViewTestCase { string single_nbsp = "a b"; string multiple_nbsp = "a b c"; try { - assert(run_javascript(@"ComposerPageState.replaceNonBreakingSpace('$(single_nbsp)');") == + assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.replaceNonBreakingSpace('$(single_nbsp)');")) == "a b"); - assert(run_javascript(@"ComposerPageState.replaceNonBreakingSpace('$(multiple_nbsp)');") == + assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.replaceNonBreakingSpace('$(multiple_nbsp)');")) == "a b c"); } catch (Geary.JS.Error err) { print("Geary.JS.Error: %s\n", err.message); @@ -196,14 +198,4 @@ class ComposerPageStateTest : ClientWebViewTestCase { } } - protected string run_javascript(string command) throws Error { - this.test_view.run_javascript.begin( - command, null, (obj, res) => { async_complete(res); } - ); - - WebKit.JavascriptResult result = - this.test_view.run_javascript.end(async_result()); - return WebKitUtil.to_string(result); - } - } diff --git a/test/js/conversation-page-state-test.vala b/test/js/conversation-page-state-test.vala new file mode 100644 index 00000000..48a1016a --- /dev/null +++ b/test/js/conversation-page-state-test.vala @@ -0,0 +1,93 @@ +/* + * Copyright 2017 Michael Gratton + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +class ConversationPageStateTest : ClientWebViewTestCase { + + public ConversationPageStateTest() { + base("ConversationPageStateTest"); + add_test("is_deceptive_text_not_url", is_deceptive_text_not_url); + add_test("is_deceptive_text_identical_text", is_deceptive_text_identical_text); + add_test("is_deceptive_text_matching_url", is_deceptive_text_matching_url); + add_test("is_deceptive_text_common_href_subdomain", is_deceptive_text_common_href_subdomain); + add_test("is_deceptive_text_common_text_subdomain", is_deceptive_text_common_text_subdomain); + add_test("is_deceptive_text_deceptive_href", is_deceptive_text_deceptive_href); + add_test("is_deceptive_text_non_matching_subdomain", is_deceptive_text_non_matching_subdomain); + add_test("is_deceptive_text_different_domain", is_deceptive_text_different_domain); + } + + public void is_deceptive_text_not_url() { + load_body_fixture("

my hovercraft is full of eels

"); + assert(exec_is_deceptive_text("ohhai!", "http://example.com") == + ConversationWebView.DeceptiveText.NOT_DECEPTIVE); + } + + public void is_deceptive_text_identical_text() { + load_body_fixture("

my hovercraft is full of eels

"); + assert(exec_is_deceptive_text("http://example.com", "http://example.com") == + ConversationWebView.DeceptiveText.NOT_DECEPTIVE); + } + + public void is_deceptive_text_matching_url() { + load_body_fixture("

my hovercraft is full of eels

"); + assert(exec_is_deceptive_text("example.com", "http://example.com") == + ConversationWebView.DeceptiveText.NOT_DECEPTIVE); + } + + public void is_deceptive_text_common_href_subdomain() { + load_body_fixture("

my hovercraft is full of eels

"); + assert(exec_is_deceptive_text("example.com", "http://foo.example.com") == + ConversationWebView.DeceptiveText.NOT_DECEPTIVE); + } + + public void is_deceptive_text_common_text_subdomain() { + load_body_fixture("

my hovercraft is full of eels

"); + assert(exec_is_deceptive_text("www.example.com", "http://example.com") == + ConversationWebView.DeceptiveText.NOT_DECEPTIVE); + } + + public void is_deceptive_text_deceptive_href() { + load_body_fixture("

my hovercraft is full of eels

"); + assert(exec_is_deceptive_text("www.example.com", "ohhai!") == + ConversationWebView.DeceptiveText.DECEPTIVE_HREF); + } + + public void is_deceptive_text_non_matching_subdomain() { + load_body_fixture("

my hovercraft is full of eels

"); + assert(exec_is_deceptive_text("www.example.com", "phishing.com") == + ConversationWebView.DeceptiveText.DECEPTIVE_DOMAIN); + } + + public void is_deceptive_text_different_domain() { + load_body_fixture("

my hovercraft is full of eels

"); + assert(exec_is_deceptive_text("www.example.com", "phishing.net") == + ConversationWebView.DeceptiveText.DECEPTIVE_DOMAIN); + } + + protected override ConversationWebView set_up_test_view() { + try { + ConversationWebView.load_resources(File.new_for_path("")); + } catch (Error err) { + assert_not_reached(); + } + return new ConversationWebView(this.config); + } + + private uint exec_is_deceptive_text(string text, string href) { + try { + return (uint) WebKitUtil.to_number( + run_javascript(@"ConversationPageState.isDeceptiveText(\"$text\", \"$href\")") + ); + } catch (Geary.JS.Error err) { + print("Geary.JS.Error: %s\n", err.message); + assert_not_reached(); + } catch (Error err) { + print("WKError: %s\n", err.message); + assert_not_reached(); + } + } + +} diff --git a/test/main.vala b/test/main.vala index fe7f6e5e..d1415161 100644 --- a/test/main.vala +++ b/test/main.vala @@ -53,6 +53,7 @@ int main(string[] args) { TestSuite js = new TestSuite("js"); js.add_suite(new ComposerPageStateTest().get_suite()); + js.add_suite(new ConversationPageStateTest().get_suite()); /* * Run the tests diff --git a/ui/conversation-web-view.js b/ui/conversation-web-view.js index cf1dcb72..3fd6fc77 100644 --- a/ui/conversation-web-view.js +++ b/ui/conversation-web-view.js @@ -16,10 +16,24 @@ let ConversationPageState = function() { ConversationPageState.QUOTE_CONTAINER_CLASS = "geary-quote-container"; ConversationPageState.QUOTE_HIDE_CLASS = "geary-hide"; +// Keep these in sync with ConversationWebView +ConversationPageState.NOT_DECEPTIVE = 0; +ConversationPageState.DECEPTIVE_HREF = 1; +ConversationPageState.DECEPTIVE_DOMAIN = 2; + ConversationPageState.prototype = { __proto__: PageState.prototype, init: function() { PageState.prototype.init.apply(this, []); + + let state = this; + document.addEventListener("click", function(e) { + if (e.target.tagName == "A" && + state.linkClicked(e.target)) { + e.preventDefault(); + } + }, true); + }, loaded: function() { this.updateDirection(); @@ -209,9 +223,87 @@ ConversationPageState.prototype = { } } return value; + }, + linkClicked: function(link) { + let cancelClick = false; + let href = link.href; + if (!href.startsWith("mailto:")) { + let text = link.innerText; + let reason = ConversationPageState.isDeceptiveText(text, href); + if (reason != ConversationPageState.NOT_DECEPTIVE) { + cancelClick = true; + window.webkit.messageHandlers.deceptiveLinkClicked.postMessage({ + reason: reason, + text: text, + href: href, + location: ConversationPageState.getNodeBounds(link) + }); + } + } + + return cancelClick; } }; +/** + * Returns an [x, y, width, height] array of a node's bounds. + */ +ConversationPageState.getNodeBounds = function(node) { + let x = 0; + let y = 0; + let parent = node; + while (parent != null) { + x += parent.offsetLeft; + y += parent.offsetTop; + parent = parent.offsetParent; + } + return { + x: x, + y: y, + width: node.offsetWidth, + height: node.offsetHeight + }; +}; + +/** + * Test for URL-like `text` that leads somewhere other than `href`. + */ +ConversationPageState.isDeceptiveText = function(text, href) { + // First, does text look like a URI? Right now, just test whether + // it has . in it. More sophisticated tests are + // possible. + let domain = new RegExp("([a-z]*://)?" // Optional scheme + + "([^\\s:/]+\\.[^\\s:/\\.]+)" // Domain + + "(/[^\\s]*)?"); // Optional path + let textParts = text.match(domain); + if (textParts == null) { + return ConversationPageState.NOT_DECEPTIVE; + } + let hrefParts = href.match(domain); + if (hrefParts == null) { + // If href doesn't look like a URL, something is fishy, so + // warn the user + return ConversationPageState.DECEPTIVE_HREF; + } + + // 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. + let textDomain = textParts[2].toLowerCase().split(".").reverse(); + let hrefDomain = hrefParts[2].toLowerCase().split(".").reverse(); + let segmentCount = Math.min(textDomain.length, hrefDomain.length); + if (segmentCount == 0) { + return ConversationPageState.DECEPTIVE_DOMAIN; + } + for (let i = 0; i < segmentCount; i++) { + if (textDomain[i] != hrefDomain[i]) { + return ConversationPageState.DECEPTIVE_DOMAIN; + } + } + + return ConversationPageState.NOT_DECEPTIVE; +}; + ConversationPageState.isDescendantOf = function(node, ancestorTag) { let ancestor = node.parentNode; while (ancestor != null) {