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:
parent
69da046ff3
commit
2b5f94da7d
11 changed files with 371 additions and 140 deletions
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
93
test/js/conversation-page-state-test.vala
Normal file
93
test/js/conversation-page-state-test.vala
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue