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.
This commit is contained in:
Michael James Gratton 2017-01-24 00:05:44 +11:00
parent 69da046ff3
commit 2b5f94da7d
11 changed files with 371 additions and 140 deletions

View file

@ -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);
}

View file

@ -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;
// 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("<a href=\"%s\">%s</a>", text, text_short)
// );
// bad_link_label.set_markup(
// Markup.printf_escaped("<a href=\"%s\">%s</a>", 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("<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() {

View file

@ -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);
}
}
}

View file

@ -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());
}
}

View file

@ -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.
*

View file

@ -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

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}
}

View file

@ -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

View file

@ -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) {