[geary/bug/728002-webkit2: 105/140] Reenable basic deceptive link highlighting.
- From: Michael Gratton <mjog src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [geary/bug/728002-webkit2: 105/140] Reenable basic deceptive link highlighting.
- Date: Tue, 31 Jan 2017 23:07:22 +0000 (UTC)
commit 2b5f94da7db23c6d3ed677665cbd1f77464a626d
Author: Michael James Gratton <mike vee net>
Date: Tue Jan 24 00:05:44 2017 +1100
Reenable basic deceptive link highlighting.
* bindings/vapi/javascriptcore-4.0.vapi (Object::get_property): Fix
return type.
* src/client/conversation-viewer/conversation-message.vala (GtkTemplate):
Hook up to new deceptive_link_clicked signal, remove old DOM-based
implementation.
* src/client/conversation-viewer/conversation-web-view.vala
(ConversationWebView): Add new deceptive_link_clicked signal and
DeceptiveText enum, listen for deceptiveLinkClicked JS message and fire
signal when received.
* src/client/util/util-webkit.vala (WebKitUtil): Add to_object util function.
* src/engine/util/util-js.vala (Geary.JS): Add to_object and get_property
util functions.
* ui/conversation-web-view.js (ConversationPageState) Listen for link
clicks, check for deceptive text and send message if found. Add unit
tests for deceptive text check.
* test/js/composer-page-state-test.vala: Move ::run_javascript to parent
class so new ConversationPageStateTest class can use it, adapt call
sites to different parent signature.
bindings/vapi/javascriptcore-4.0.vapi | 6 +-
.../conversation-viewer/conversation-message.vala | 144 +++++---------------
.../conversation-viewer/conversation-web-view.vala | 68 +++++++++-
src/client/util/util-webkit.vala | 12 ++
src/engine/util/util-js.vala | 41 ++++++
test/CMakeLists.txt | 1 +
.../components/client-web-view-test-case.vala | 9 ++
test/js/composer-page-state-test.vala | 48 +++----
test/js/conversation-page-state-test.vala | 93 +++++++++++++
test/main.vala | 1 +
ui/conversation-web-view.js | 92 +++++++++++++
11 files changed, 373 insertions(+), 142 deletions(-)
---
diff --git a/bindings/vapi/javascriptcore-4.0.vapi b/bindings/vapi/javascriptcore-4.0.vapi
index f31478e..d152ce2 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 f432a60..1209161 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
- // // <string>.<string> 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;
-
- // 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("<a href=\"%s\">%s</a>", text, text_short)
- // );
- // bad_link_label.set_markup(
- // Markup.printf_escaped("<a href=\"%s\">%s</a>", href, href_short)
- // );
-
- // 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;
- // }
+ // 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);
+
+ string anchor_href = href;
+ if (Uri.parse_scheme(anchor_href) == null) {
+ anchor_href = "http://" + anchor_href;
+ }
+ string anchor_label = Soup.URI.decode(anchor_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("<a href=\"%s\">%s</a>", text_href, text_label)
+ );
+ bad_link_label.set_markup(
+ Markup.printf_escaped("<a href=\"%s\">%s</a>", 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 03e7f46..78889cd 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 <mike vee net>
+ * Copyright 2017 Michael Gratton <mike vee net>
*
* 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 79bdd3b..375c47b 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 a25e330..af2fb26 100644
--- a/src/engine/util/util-js.vala
+++ b/src/engine/util/util-js.vala
@@ -76,6 +76,26 @@ namespace Geary.JS {
}
/**
+ * 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}.
*/
public inline string to_string_released(global::JS.String js) {
@@ -87,6 +107,27 @@ namespace Geary.JS {
}
/**
+ * 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.
*
* This method will raise a {@link Geary.JS.Error} if the given
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 363fa52..c050287 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 ddbe9a8..9854a6a 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<V> : 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 68b7279..42439c3 100644
--- a/test/js/composer-page-state-test.vala
+++ b/test/js/composer-page-state-test.vala
@@ -25,7 +25,7 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
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<ComposerWebView> {
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<ComposerWebView> {
string html = "<p>para</p>";
load_body_fixture(html);
try {
- assert(run_javascript(@"window.geary.getHtml();") == html + "<br><br>");
+ assert(WebKitUtil.to_string(run_javascript(@"window.geary.getHtml();")) ==
+ html + "<br><br>");
} catch (Geary.JS.Error err) {
print("Geary.JS.Error: %s\n", err.message);
assert_not_reached();
@@ -69,7 +70,8 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
public void get_text() {
load_body_fixture("<p>para</p>");
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<ComposerWebView> {
unichar q_marker = Geary.RFC822.Utils.QUOTE_MARKER;
load_body_fixture("<p>pre</p> <blockquote><p>quote</p></blockquote> <p>post</p>");
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<ComposerWebView> {
unichar q_marker = Geary.RFC822.Utils.QUOTE_MARKER;
load_body_fixture("<p>pre</p> <blockquote><p>quote1</p>
<blockquote><p>quote2</p></blockquote></blockquote> <p>post</p>");
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<ComposerWebView> {
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<ComposerWebView> {
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<ComposerWebView> {
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<ComposerWebView> {
}
}
- 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 0000000..48a1016
--- /dev/null
+++ b/test/js/conversation-page-state-test.vala
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2017 Michael Gratton <mike vee net>
+ *
+ * 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<ConversationWebView> {
+
+ 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("<p>my hovercraft is full of eels</p>");
+ assert(exec_is_deceptive_text("ohhai!", "http://example.com") ==
+ ConversationWebView.DeceptiveText.NOT_DECEPTIVE);
+ }
+
+ public void is_deceptive_text_identical_text() {
+ load_body_fixture("<p>my hovercraft is full of eels</p>");
+ 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("<p>my hovercraft is full of eels</p>");
+ 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("<p>my hovercraft is full of eels</p>");
+ 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("<p>my hovercraft is full of eels</p>");
+ 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("<p>my hovercraft is full of eels</p>");
+ assert(exec_is_deceptive_text("www.example.com", "ohhai!") ==
+ ConversationWebView.DeceptiveText.DECEPTIVE_HREF);
+ }
+
+ public void is_deceptive_text_non_matching_subdomain() {
+ load_body_fixture("<p>my hovercraft is full of eels</p>");
+ assert(exec_is_deceptive_text("www.example.com", "phishing.com") ==
+ ConversationWebView.DeceptiveText.DECEPTIVE_DOMAIN);
+ }
+
+ public void is_deceptive_text_different_domain() {
+ load_body_fixture("<p>my hovercraft is full of eels</p>");
+ 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 fe7f6e5..d141516 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 cf1dcb7..3fd6fc7 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 <string>.<string> 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) {
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]