Commit b7eea857 authored by Michael Gratton's avatar Michael Gratton 🤞

Merge branch 'wip/791275-mailsploit-mitigation'. Fixes Bug 791275.

parents de0618e6 eb63505a
......@@ -1376,7 +1376,7 @@ namespace GMime {
[CCode (cheader_filename = "gmime/gmime.h", cname = "g_mime_utils_structured_header_fold")]
public static string utils_structured_header_fold (string header);
[CCode (cheader_filename = "gmime/gmime.h", cname = "g_mime_utils_text_is_8bit")]
public static bool utils_text_is_8bit (uint text, size_t len);
public static bool utils_text_is_8bit (string text, size_t len);
[CCode (cheader_filename = "gmime/gmime.h", cname = "g_mime_utils_unquote_string")]
public static void utils_unquote_string (string str);
[CCode (cheader_filename = "gmime/gmime.h", cname = "g_mime_utils_unstructured_header_fold")]
......
......@@ -11,7 +11,7 @@ public class AccountDialogEditAlternateEmailsPane : AccountDialogPane {
public ListItem(Geary.RFC822.MailboxAddress mailbox) {
this.mailbox = mailbox;
label = "<b>%s</b>".printf(Geary.HTML.escape_markup(mailbox.get_full_address()));
label = "<b>%s</b>".printf(Geary.HTML.escape_markup(mailbox.to_full_display()));
use_markup = true;
ellipsize = Pango.EllipsizeMode.END;
set_halign(Gtk.Align.START);
......
......@@ -1676,16 +1676,16 @@ public class ComposerWidget : Gtk.EventBox {
StringBuilder tooltip = new StringBuilder();
if (to_entry.addresses != null)
foreach(Geary.RFC822.MailboxAddress addr in this.to_entry.addresses)
tooltip.append(_("To: ") + addr.get_full_address() + "\n");
tooltip.append(_("To: ") + addr.to_full_display() + "\n");
if (cc_entry.addresses != null)
foreach(Geary.RFC822.MailboxAddress addr in this.cc_entry.addresses)
tooltip.append(_("Cc: ") + addr.get_full_address() + "\n");
tooltip.append(_("Cc: ") + addr.to_full_display() + "\n");
if (bcc_entry.addresses != null)
foreach(Geary.RFC822.MailboxAddress addr in this.bcc_entry.addresses)
tooltip.append(_("Bcc: ") + addr.get_full_address() + "\n");
tooltip.append(_("Bcc: ") + addr.to_full_display() + "\n");
if (reply_to_entry.addresses != null)
foreach(Geary.RFC822.MailboxAddress addr in this.reply_to_entry.addresses)
tooltip.append(_("Reply-To: ") + addr.get_full_address() + "\n");
tooltip.append(_("Reply-To: ") + addr.to_full_display() + "\n");
this.header.set_recipients(label, tooltip.str.slice(0, -1)); // Remove trailing \n
}
......
......@@ -27,17 +27,21 @@ public class FormattedConversationData : Geary.BaseObject {
this.address = address;
this.is_unread = is_unread;
}
public string get_full_markup(Gee.List<Geary.RFC822.MailboxAddress> account_mailboxes) {
return get_as_markup((address in account_mailboxes) ? ME : address.get_short_address());
return get_as_markup((address in account_mailboxes) ? ME : address.to_short_display());
}
public string get_short_markup(Gee.List<Geary.RFC822.MailboxAddress> account_mailboxes) {
if (address in account_mailboxes)
return get_as_markup(ME);
string short_address = address.get_short_address().strip();
if (address.is_spoofed()) {
return get_full_markup(account_mailboxes);
}
string short_address = Markup.escape_text(address.to_short_display());
if (", " in short_address) {
// assume address is in Last, First format
string[] tokens = short_address.split(", ", 2);
......@@ -57,12 +61,21 @@ public class FormattedConversationData : Geary.BaseObject {
return get_as_markup(first_name);
}
private string get_as_markup(string participant) {
return "%s%s%s".printf(
is_unread ? "<b>" : "", Geary.HTML.escape_markup(participant), is_unread ? "</b>" : "");
string markup = Geary.HTML.escape_markup(participant);
if (is_unread) {
markup = "<b>%s</b>".printf(markup);
}
if (this.address.is_spoofed()) {
markup = "<s>%s</s>".printf(markup);
}
return markup;
}
public bool equal_to(ParticipantDisplay other) {
return address.equal_to(other.address);
}
......
/*
* Copyright 2016 Software Freedom Conservancy Inc.
* Copyright 2016 Michael Gratton <mike@vee.net>
* Copyright 2016-2018 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.
......@@ -26,15 +26,6 @@ public class ConversationMessage : Gtk.Grid {
private const int MAX_PREVIEW_BYTES = Geary.Email.MAX_PREVIEW_BYTES;
internal static inline bool has_distinct_name(
Geary.RFC822.MailboxAddress address) {
return (
!Geary.String.is_empty(address.name) &&
address.name != address.address
);
}
// Widget used to display sender/recipient email addresses in
// message header Gtk.FlowBox instances.
private class AddressFlowBoxChild : Gtk.FlowBoxChild {
......@@ -50,7 +41,7 @@ public class ConversationMessage : Gtk.Grid {
public AddressFlowBoxChild(Geary.RFC822.MailboxAddress address,
Type type = Type.OTHER) {
this.address = address;
this.search_value = address.address.casefold();
this.search_value = address.to_searchable_string().casefold();
// We use two label instances here when address has
// distinct parts so we can dim the secondary part, if
......@@ -60,6 +51,17 @@ public class ConversationMessage : Gtk.Grid {
Gtk.Grid address_parts = new Gtk.Grid();
bool is_spoofed = address.is_spoofed();
if (is_spoofed) {
Gtk.Image spoof_img = new Gtk.Image.from_icon_name(
"dialog-warning-symbolic", Gtk.IconSize.SMALL_TOOLBAR
);
this.set_tooltip_text(
_("This email address may have been forged")
);
address_parts.add(spoof_img);
}
Gtk.Label primary = new Gtk.Label(null);
primary.ellipsize = Pango.EllipsizeMode.END;
primary.set_halign(Gtk.Align.START);
......@@ -69,19 +71,21 @@ public class ConversationMessage : Gtk.Grid {
}
address_parts.add(primary);
if (has_distinct_name(address)) {
primary.set_text(address.name);
string display_address = address.to_address_display("", "");
// Don't display the name if it looks spoofed, to reduce
// chance of the user of being tricked by malware.
if (address.has_distinct_name() && !is_spoofed) {
primary.set_text(address.to_short_display());
Gtk.Label secondary = new Gtk.Label(null);
secondary.ellipsize = Pango.EllipsizeMode.END;
secondary.set_halign(Gtk.Align.START);
secondary.get_style_context().add_class(Gtk.STYLE_CLASS_DIM_LABEL);
secondary.set_text(address.address);
secondary.set_text(display_address);
address_parts.add(secondary);
this.search_value = address.name.casefold() + this.search_value;
} else {
primary.set_text(address.address);
primary.set_text(display_address);
}
// Update prelight state when mouse-overed.
......@@ -571,7 +575,7 @@ public class ConversationMessage : Gtk.Grid {
Gee.List<Geary.RFC822.MailboxAddress> list =
this.message.from.get_all();
foreach (Geary.RFC822.MailboxAddress addr in list) {
text += has_distinct_name(addr) ? addr.name : addr.address;
text += addr.to_short_display();
if (++i < list.size)
// Translators: This separates multiple 'from'
......@@ -765,7 +769,7 @@ public class ConversationMessage : Gtk.Grid {
Gee.Map<string,string> values = new Gee.HashMap<string,string>();
values[ACTION_OPEN_LINK] =
Geary.ComposedEmail.MAILTO_SCHEME + address.address;
values[ACTION_COPY_EMAIL] = address.get_full_address();
values[ACTION_COPY_EMAIL] = address.to_full_display();
values[ACTION_SEARCH_FROM] = address.address;
Menu model = new Menu();
......
......@@ -130,10 +130,10 @@ public class Libnotify : Geary.BaseObject {
ins = null;
}
issue_current_notification(primary.get_short_address(), body, avatar);
issue_current_notification(primary.to_short_display(), body, avatar);
}
private void issue_current_notification(string summary, string body, Gdk.Pixbuf? icon) {
// only one outstanding notification at a time
if (current_notification != null) {
......
/* Copyright 2016 Software Freedom Conservancy Inc.
/*
* Copyright 2016 Software Freedom Conservancy Inc.
* Copyright 2018 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.
*/
/**
* An immutable object containing a representation of an Internet email address.
* An immutable representation of an RFC 822 mailbox address.
*
* See [[https://tools.ietf.org/html/rfc2822#section-3.4]]
* The properties of this class such as {@link name} and {@link
* address} are stores decoded UTF-8, thus they must be re-encoded
* using methods such as {@link to_rfc822_string} before being re-used
* in a message envelope.
*
* See [[https://tools.ietf.org/html/rfc5322#section-3.4]]
*/
public class Geary.RFC822.MailboxAddress :
Geary.MessageData.SearchableMessageData,
Gee.Hashable<MailboxAddress>,
BaseObject {
/** Determines if a string contains a valid RFC822 mailbox address. */
public static bool is_valid_address(string address) {
try {
// http://www.regular-expressions.info/email.html
// matches john@dep.aol.museum not john@aol...com
Regex email_regex =
new Regex("[A-Z0-9._%+-]+@((?:[A-Z0-9-]+\\.)+[A-Z]{2}|localhost)",
RegexCompileFlags.CASELESS);
return email_regex.match(address);
} catch (RegexError e) {
debug("Regex error validating email address: %s", e.message);
return false;
}
}
private static string decode_name(string name) {
return GMime.utils_header_decode_phrase(prepare_header_text_part(name));
}
private static string decode_address_part(string mailbox) {
return GMime.utils_header_decode_text(prepare_header_text_part(mailbox));
}
private static string prepare_header_text_part(string part) {
// Borrowed liberally from GMime's internal
// _internet_address_decode_name() function.
// see if a broken mailer has sent raw 8-bit information
string text = GMime.utils_text_is_8bit(part, part.length)
? part : GMime.utils_decode_8bit(part, part.length);
// unquote the string then decode the text
GMime.utils_unquote_string(text);
// Sometimes quoted printables contain unencoded spaces which trips up GMime, so we want to
// encode them all here.
int offset = 0;
int start;
while ((start = text.index_of("=?", offset)) != -1) {
// Find the closing marker.
int end = text.index_of("?=", start + 2) + 2;
if (end == -1) {
end = text.length;
}
// Replace any spaces inside the encoded string.
string encoded = text.substring(start, end - start);
if (encoded.contains("\x20")) {
text = text.replace(encoded, encoded.replace("\x20", "_"));
}
offset = end;
}
return text;
}
public class Geary.RFC822.MailboxAddress : Geary.MessageData.SearchableMessageData,
Gee.Hashable<MailboxAddress>, BaseObject {
internal delegate string ListToStringDelegate(MailboxAddress address);
/**
* The optional user-friendly name associated with the {@link MailboxAddress}.
* The optional human-readable part of the mailbox address.
*
* For "Dirk Gently <dirk@example.com>", this would be "Dirk Gently".
*
* The returned value has been decoded into UTF-8.
*/
public string? name { get; private set; }
/**
* The routing of the message (optional, obsolete).
*
* The returned value has been decoded into UTF-8.
*/
public string? source_route { get; private set; }
/**
* The mailbox (local-part) portion of the {@link MailboxAddress}.
* The mailbox (local-part) portion of the mailbox's address.
*
* For "Dirk Gently <dirk@example.com>", this would be "dirk".
*
* The returned value has been decoded into UTF-8.
*/
public string mailbox { get; private set; }
/**
* The domain portion of the {@link MailboxAddress}.
* The domain portion of the mailbox's address.
*
* For "Dirk Gently <dirk@example.com>", this would be "example.com".
*
* The returned value has been decoded into UTF-8.
*/
public string domain { get; private set; }
/**
* The address specification of the {@link MailboxAddress}.
* The complete address part of the mailbox address.
*
* For "Dirk Gently <dirk@example.com>", this would be "dirk@example.com".
*
* The returned value has been decoded into UTF-8.
*/
public string address { get; private set; }
public MailboxAddress(string? name, string address) {
this.name = name;
this.source_route = null;
this.address = address;
source_route = null;
int atsign = address.index_of_char('@');
int atsign = address.last_index_of_char('@');
if (atsign > 0) {
mailbox = address.slice(0, atsign);
domain = address.slice(atsign + 1, address.length);
this.mailbox = address[0:atsign];
this.domain = address[atsign + 1:address.length];
} else {
mailbox = "";
domain = "";
this.mailbox = "";
this.domain = "";
}
}
public MailboxAddress.imap(string? name, string? source_route, string mailbox, string domain) {
this.name = (name != null) ? decode_name(name) : null;
this.source_route = source_route;
this.mailbox = mailbox;
this.mailbox = decode_address_part(mailbox);
this.domain = domain;
address = "%s@%s".printf(mailbox, domain);
this.address = "%s@%s".printf(mailbox, domain);
}
public MailboxAddress.from_rfc822_string(string rfc822) throws RFC822Error {
......@@ -84,67 +157,106 @@ public class Geary.RFC822.MailboxAddress : Geary.MessageData.SearchableMessageDa
// TODO: Handle group lists
InternetAddressMailbox? mbox_addr = addr as InternetAddressMailbox;
if (mbox_addr != null) {
this(mbox_addr.get_name(), mbox_addr.get_addr());
this.gmime(mbox_addr);
return;
}
}
throw new RFC822Error.INVALID("Could not parse RFC822 address: %s", rfc822);
}
// Borrowed liberally from GMime's internal _internet_address_decode_name() function.
private static string decode_name(string name) {
// see if a broken mailer has sent raw 8-bit information
string text = name.validate() ? name : GMime.utils_decode_8bit(name, name.length);
// unquote the string and decode the text
GMime.utils_unquote_string(text);
// Sometimes quoted printables contain unencoded spaces which trips up GMime, so we want to
// encode them all here.
int offset = 0;
int start;
while ((start = text.index_of("=?", offset)) != -1) {
// Find the closing marker.
int end = text.index_of("?=", start + 2) + 2;
if (end == -1) {
end = text.length;
}
public MailboxAddress.gmime(InternetAddressMailbox mailbox) {
// GMime strips source route for us, so the address part
// should only ever contain a single '@'
string? name = mailbox.get_name();
if (name != null) {
this.name = decode_name(name);
}
// Replace any spaces inside the encoded string.
string encoded = text.substring(start, end - start);
if (encoded.contains("\x20")) {
text = text.replace(encoded, encoded.replace("\x20", "_"));
}
offset = end;
string address = mailbox.get_addr();
int atsign = address.last_index_of_char('@');
if (atsign == -1) {
// No @ detected, try decoding in case a mailer (wrongly)
// encoded the whole thing and re-try
address = decode_address_part(address);
atsign = address.last_index_of_char('@');
}
return GMime.utils_header_decode_text(text);
if (atsign >= 0) {
this.mailbox = decode_address_part(address[0:atsign]);
this.domain = address[atsign + 1:address.length];
this.address = "%s@%s".printf(this.mailbox, this.domain);
} else {
this.mailbox = "";
this.domain = "";
this.address = address;
}
}
/**
* Returns a human-readable formatted address, showing the name (if available) and the email
* address in angled brackets. No RFC822 quoting is performed.
* Returns a full human-readable version of the mailbox address.
*
* @see to_rfc822_string
* This returns a formatted version of the address including
* {@link name} (if present, not a spoof, and distinct from the
* address) and {@link address} parts, suitable for display to
* people. The string will have white space reduced and
* non-printable characters removed, and the address will be
* surrounded by angle brackets if a name is present.
*
* If you need a form suitable for sending a message, see {@link
* to_rfc822_string} instead.
*
* @see has_distinct_name
* @see is_spoofed
* @param open optional string to use as the opening bracket for
* the address part, defaults to //<//
* @param close optional string to use as the closing bracket for
* the address part, defaults to //>//
* @return the cleaned //name// part if present, not spoofed and
* distinct from //address//, followed by a space then the cleaned
* //address// part, cleaned and enclosed within the specified
* brackets.
*/
public string get_full_address() {
return String.is_empty(name) ? address : "%s <%s>".printf(name, address);
public string to_full_display(string open = "<", string close = ">") {
string clean_name = Geary.String.reduce_whitespace(this.name);
string clean_address = Geary.String.reduce_whitespace(this.address);
return (!has_distinct_name() || is_spoofed())
? clean_address
: "%s %s%s%s".printf(clean_name, open, clean_address, close);
}
/**
* Returns a simple address, that is, no human-readable name and the email address in angled
* Returns a short human-readable version of the mailbox address.
*
* This returns a shortened version of the address suitable for
* display to people: Either the {@link name} (if present and not
* a spoof) or the {@link address} part otherwise. The string will
* have white space reduced and non-printable characters removed.
*
* @see is_spoofed
* @return the cleaned //name// part if present and not spoofed,
* or else the cleaned //address// part, cleaned but without
* brackets.
*/
public string get_simple_address() {
return "<%s>".printf(address);
public string to_short_display() {
string clean_name = Geary.String.reduce_whitespace(this.name);
string clean_address = Geary.String.reduce_whitespace(this.address);
return String.is_empty(clean_name) || is_spoofed()
? clean_address
: clean_name;
}
/**
* Returns a human-readable pretty address, showing only the name, but if unavailable, the
* mailbox name (that is, the account name without the domain).
* Returns a human-readable version of the address part.
*
* @param open optional string to use as the opening bracket,
* defaults to //<//
* @param close optional string to use as the closing bracket,
* defaults to //>//
* @return the {@link address} part, cleaned and enclosed within the
* specified brackets.
*/
public string get_short_address() {
return name ?? mailbox;
public string to_address_display(string open = "<", string close = ">") {
return open + Geary.String.reduce_whitespace(this.address) + close;
}
/**
......@@ -153,47 +265,134 @@ public class Geary.RFC822.MailboxAddress : Geary.MessageData.SearchableMessageDa
public bool is_valid() {
return is_valid_address(address);
}
/**
* Returns true if the email syntax is valid.
* Determines if the mailbox address appears to have been spoofed.
*
* Using recipient and sender mailbox addresses where the name
* part is also actually a valid RFC822 address
* (e.g. "you@example.com <jerk@spammer.com>") is a common tactic
* used by spammers and malware authors to exploit MUAs that will
* display the name part only if present. It also enables more
* sophisticated attacks such as
* [[https://www.mailsploit.com/|Mailsploit]], which uses
* Quoted-Printable or Base64 encoded nulls, new lines, @'s and
* other characters to further trick MUAs into displaying a bogus
* address.
*
* This method attempts to detect such attacks by examining the
* {@link name} for non-printing characters and determining if it
* is by itself also a valid RFC822 address.
*
* @return //true// if the complete decoded address contains any
* non-printing characters, if the name part is also a valid
* RFC822 address, or if the address part is not a valid RFC822
* address.
*/
public static bool is_valid_address(string address) {
try {
// http://www.regular-expressions.info/email.html
// matches john@dep.aol.museum not john@aol...com
Regex email_regex =
new Regex("[A-Z0-9._%+-]+@((?:[A-Z0-9-]+\\.)+[A-Z]{2}|localhost)",
RegexCompileFlags.CASELESS);
return email_regex.match(address);
} catch (RegexError e) {
debug("Regex error validating email address: %s", e.message);
return false;
public bool is_spoofed() {
// Empty test and regexes must apply to the raw values, not
// clean ones, otherwise any control chars present will have
// been lost
const string CONTROLS = "[[:cntrl:]]+";
bool is_spoof = false;
// 1. Check the name part contains no controls and doesn't
// look like an email address
if (!Geary.String.is_empty(this.name)) {
if (Regex.match_simple(CONTROLS, this.name)) {
is_spoof = true;
} else {
// Clean up the name as usual, but remove all
// whitespace so an attack can't get away with a name
// like "potus @ whitehouse . gov"
string clean_name = Geary.String.reduce_whitespace(this.name);
clean_name = clean_name.replace(" ", "");
if (is_valid_address(clean_name)) {
is_spoof = true;
}
}
}
// 2. Check the mailbox part of the address doesn't contain an
// @. Is actually legal if quoted, but rarely (never?) found
// in the wild and better be safe than sorry.
if (!is_spoof && this.mailbox.contains("@")) {
is_spoof = true;
}
// 3. Check the address doesn't contain any spaces or
// controls. Again, space in the mailbox is allowed if quoted,
// but in practice should rarely be used.
if (!is_spoof && Regex.match_simple(Geary.String.WS_OR_NP, this.address)) {
is_spoof = true;
}
return is_spoof;
}
/**
* Returns the address suitable for insertion into an RFC822 message. RFC822 quoting is
* performed if required.
* Determines if the name part is different to the address part.
*
* @return //true// if {@link name} is not empty, and the cleaned
* versions of the name part and {@link address} are not equal.
*/
public bool has_distinct_name() {
string clean_name = Geary.String.reduce_whitespace(this.name);
return (
!Geary.String.is_empty(clean_name) &&
clean_name != Geary.String.reduce_whitespace(this.address)
);
}
/**
* Returns the complete mailbox address, armoured for RFC 822 use.
*
* This method is similar to {@link to_full_display}, but only
* checks for a distinct address (per Postel's Law) and not for
* any spoofing, and does not strip extra white space or
* non-printing characters.
*
* @see get_full_address
* @return the RFC822 encoded form of the full address.
*/
public string to_rfc822_string() {
return String.is_empty(name)
? address
: "%s <%s>".printf(GMime.utils_quote_string(name), address);
return has_distinct_name()
? "%s <%s>".printf(
GMime.utils_header_encode_phrase(this.name),
to_rfc822_address()
)
: to_rfc822_address();
}
/**
* Returns the address part only, armoured for RFC 822 use.
*
* @return the RFC822 encoded form of the address, without angle
* brackets.
*/
public string to_rfc822_address() {
return "%s@%s".printf(
// XXX utils_quote_string won't quote if spaces or quotes
// present, so need to do that manually
GMime.utils_quote_string(GMime.utils_header_encode_text(this.mailbox)),
// XXX Need to punycode international domains.
this.domain
);
}
/**
* See Geary.MessageData.SearchableMessageData.
*/
public string to_searchable_string() {
return get_full_address();
return has_distinct_name()
? "%s <%s>".printf(this.name, this.address)
: this.address;
}
public uint hash() {
return String.stri_hash(address);
}
/**
* Equality is defined as a case-insensitive comparison of the {@link address}.
*/
......@@ -205,30 +404,13 @@ public class Geary.RFC822.MailboxAddress : Geary.MessageData.SearchableMessageDa
return this.address.normalize().casefold() == address.normalize().casefold();
}
/**
* Returns the RFC822 formatted version of the address.
*
* @see to_rfc822_string
*/
public string to_string() {
return get_full_address();
}
internal static string list_to_string(Gee.List<MailboxAddress> addrs,
string empty, ListToStringDelegate to_s) {
switch (addrs.size) {
case 0:
return empty;