From Code to Community: Sponsoring The Perl and Raku Conference 2025 Learn more

# <@LICENSE>
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to you under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at:
#
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# </@LICENSE>
=head1 NAME
FromNameSpoof - perform various tests to detect spoof attempts using the
From header name section
=head1 SYNOPSIS
loadplugin Mail::SpamAssassin::Plugin::FromNameSpoof
# From:name and From:addr do not match, matching depends on C<fns_check> setting
header __PLUGIN_FROMNAME_SPOOF eval:check_fromname_spoof()
# From:name and From:addr do not match (same as above rule and C<fns_check 0>)
header __PLUGIN_FROMNAME_DIFFERENT eval:check_fromname_different()
# From:name and From:addr domains differ
header __PLUGIN_FROMNAME_DOMAIN_DIFFER eval:check_fromname_domain_differ()
# From:name looks like it contains an email address (not same as From:addr)
header __PLUGIN_FROMNAME_EMAIL eval:check_fromname_contains_email()
# From:name matches any To:addr
header __PLUGIN_FROMNAME_EQUALS_TO eval:check_fromname_equals_to()
# From:name and From:addr owners differ
header __PLUGIN_FROMNAME_OWNERS_DIFFER eval:check_fromname_owners_differ()
# From:name matches Reply-To:addr
header __PLUGIN_FROMNAME_EQUALS_REPLYTO eval:check_fromname_equals_replyto()
=head1 DESCRIPTION
Perform various tests against From:name header to detect spoofing. Steps in place to
ensure minimal FPs.
=head1 CONFIGURATION
The plugin allows you to skip emails that have been DKIM signed by specific senders:
fns_ignore_dkim googlegroups.com
FromNameSpoof allows for a configurable closeness when matching the From:addr and From:name,
the closeness can be adjusted with:
fns_extrachars 50
B<Note> that FromNameSpoof detects the "owner" of a domain by the following search:
<owner>.<tld>
By default FromNameSpoof will ignore the TLD when comparing addresses:
fns_check 1
Check levels:
0 - Strict checking of From:name != From:addr
1 - Allow for different TLDs
2 - Allow for different aliases but same domain
"Owner" info can also be mapped as aliases with C<fns_add_addrlist>. For
example, to consider "googlemail.com" as "gmail":
fns_add_addrlist (gmail) *@googlemail.com
=head1 TAGS
The following tags are added to the set if a spoof is detected. They are available for
use in reports, header fields, other plugins, etc.:
_FNSFNAMEADDR_
Detected spoof address from From:name header
_FNSFNAMEDOMAIN_
Detected spoof domain from From:name header
_FNSFNAMEOWNER_
Detected spoof owner from From:name header
_FNSFADDRADDR_
Actual From:addr address
_FNSFADDRDOMAIN_
Actual From:addr domain
_FNSFADDROWNER_
Actual From:addr owner
=head1 EXAMPLE
header __PLUGIN_FROMNAME_SPOOF eval:check_fromname_spoof()
header __PLUGIN_FROMNAME_EQUALS_TO eval:check_fromname_equals_to()
meta FROMNAME_SPOOF_EQUALS_TO (__PLUGIN_FROMNAME_SPOOF && __PLUGIN_FROMNAME_EQUALS_TO)
describe FROMNAME_SPOOF_EQUALS_TO From:name is spoof to look like To: address
score FROMNAME_SPOOF_EQUALS_TO 1.2
=cut
use strict;
use re 'taint';
use vars qw(@ISA);
@ISA = qw(Mail::SpamAssassin::Plugin);
my $VERSION = 1.0;
sub dbg { my $msg = shift; Mail::SpamAssassin::Plugin::dbg("FromNameSpoof: $msg", @_); }
# constructor: register the eval rule
sub new {
my $class = shift;
my $mailsaobject = shift;
# some boilerplate...
$class = ref($class) || $class;
my $self = $class->SUPER::new($mailsaobject);
bless ($self, $class);
$self->set_config($mailsaobject->{conf});
# the important bit!
$self->register_eval_rule("check_fromname_spoof", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
$self->register_eval_rule("check_fromname_different", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
$self->register_eval_rule("check_fromname_domain_differ", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
$self->register_eval_rule("check_fromname_contains_email", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
$self->register_eval_rule("check_fromname_equals_to", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
$self->register_eval_rule("check_fromname_owners_differ", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
$self->register_eval_rule("check_fromname_equals_replyto", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
return $self;
}
sub set_config {
my ($self, $conf) = @_;
my @cmds = ();
push (@cmds, {
setting => 'fns_add_addrlist',
type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST,
code => sub {
my ($self, $key, $value, $line) = @_;
local($1,$2);
if ($value !~ /^ \( (.+?) \) \s+ (.+) \z/sx) {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
my $listname = "FNS_".lc($1);
$self->{parser}->add_to_addrlist($listname, split(/\s+/, lc $2));
$self->{fns_addrlists}{$listname} = 1;
}
});
push (@cmds, {
setting => 'fns_remove_addrlist',
type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST,
code => sub {
my ($self, $key, $value, $line) = @_;
local($1,$2);
if ($value !~ /^ \( (.+?) \) \s+ (.+) \z/sx) {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
my $listname = "FNS_".lc($1);
$self->{parser}->remove_from_addrlist($listname, split (/\s+/, lc $2));
}
});
push(@cmds, {
setting => 'fns_extrachars',
default => 50,
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
});
push (@cmds, {
setting => 'fns_ignore_dkim',
default => {},
type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
code => sub {
my ($self, $key, $value, $line) = @_;
if ($value eq '') {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
$self->{fns_ignore_dkim}->{$_} = 1 foreach (split(/\s+/, lc $value));
}
});
push (@cmds, {
setting => 'fns_ignore_headers',
default => {},
type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
code => sub {
my ($self, $key, $value, $line) = @_;
if ($value eq '') {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
$self->{fns_ignore_header}->{$_} = 1 foreach (split(/\s+/, $value));
}
});
push(@cmds, {
setting => 'fns_check',
default => 1,
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
code => sub {
my ($self, $key, $value, $line) = @_;
if ($value eq '') {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
if ($value !~ /^[012]$/) {
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
}
$self->{fns_check} = $value;
}
});
$conf->{parser}->register_commands(\@cmds);
}
sub parsed_metadata {
my ($self, $opts) = @_;
my $pms = $opts->{permsgstatus};
# If fns_ignore_dkim used, force wait for DKIM results
if (%{$pms->{conf}->{fns_ignore_dkim}}) {
if ($self->{main}->{local_tests_only}) {
dbg("local tests only, ignoring fns_ignore_dkim setting");
}
# Check that DKIM module is loaded (a bit kludgy check)
elsif (exists $pms->{conf}->{dkim_timeout}) {
# Initialize async queue, any eval calls will queue their checks
$pms->{fromname_async_queue} = [];
# Process and finish queue as soon as DKIM is ready
$pms->action_depends_on_tags('DKIMDOMAIN', sub {
$self->_check_async_queue($pms);
});
} else {
dbg("DKIM plugin not loaded, ignoring fns_ignore_dkim setting");
}
}
}
sub _check_eval {
my ($self, $pms, $result) = @_;
if (exists $pms->{fromname_async_queue}) {
my $rulename = $pms->get_current_eval_rule_name();
push @{$pms->{fromname_async_queue}}, sub {
if ($result->()) {
$pms->got_hit($rulename, '', ruletype => 'header');
} else {
$pms->rule_ready($rulename);
}
};
return; # return undef for async status
}
$self->_check_fromnamespoof($pms);
# make sure not to return undef, as this is not async anymore
return $result->() || 0;
}
sub check_fromname_spoof {
my ($self, $pms, $check_lvl) = @_;
# Some deprecated eval parameter, was not documented?
if (!defined $check_lvl || $check_lvl !~ /^[012]$/) {
$check_lvl = $pms->{conf}->{fns_check};
}
my $result = sub {
my @array = (
($pms->{fromname_address_different}),
($pms->{fromname_address_different} && $pms->{fromname_owner_different}),
($pms->{fromname_address_different} && $pms->{fromname_domain_different})
);
$array[$check_lvl];
};
return $self->_check_eval($pms, $result);
}
sub check_fromname_different {
my ($self, $pms) = @_;
my $result = sub {
$pms->{fromname_address_different};
};
return $self->_check_eval($pms, $result);
}
sub check_fromname_domain_differ {
my ($self, $pms) = @_;
my $result = sub {
$pms->{fromname_domain_different};
};
return $self->_check_eval($pms, $result);
}
sub check_fromname_contains_email {
my ($self, $pms) = @_;
my $result = sub {
$pms->{fromname_contains_email};
};
return $self->_check_eval($pms, $result);
}
sub check_fromname_equals_to {
my ($self, $pms) = @_;
my $result = sub {
$pms->{fromname_equals_to_addr};
};
return $self->_check_eval($pms, $result);
}
sub check_fromname_owners_differ {
my ($self, $pms) = @_;
my $result = sub {
$pms->{fromname_owner_different};
};
return $self->_check_eval($pms, $result);
}
sub check_fromname_equals_replyto {
my ($self, $pms) = @_;
my $result = sub {
$pms->{fromname_equals_replyto};
};
return $self->_check_eval($pms, $result);
}
sub check_cleanup {
my ($self, $opts) = @_;
$self->_check_async_queue($opts->{permsgstatus});
}
# Shall only be called when DKIMDOMAIN is ready, or from check_cleanup() to
# make sure _check_fromnamespoof is called if DKIMDOMAIN was never set
sub _check_async_queue {
my ($self, $pms) = @_;
if (exists $pms->{fromname_async_queue}) {
$self->_check_fromnamespoof($pms);
$_->() foreach (@{$pms->{fromname_async_queue}});
# No more async queueing needed. If any evals are called later, they
# will act on the results directly.
delete $pms->{fromname_async_queue};
}
}
sub _check_fromnamespoof {
my ($self, $pms) = @_;
return if $pms->{fromname_checked};
$pms->{fromname_checked} = 1;
my $conf = $pms->{conf};
foreach my $addr (split(/\s+/, $pms->get_tag('DKIMDOMAIN')||'')) {
if ($conf->{fns_ignore_dkim}->{lc $addr}) {
dbg("ignoring, DKIM signed: $addr");
return;
}
}
foreach my $iheader (keys %{$conf->{fns_ignore_header}}) {
if ($pms->get($iheader)) {
dbg("ignoring, header $iheader found");
return;
}
}
# Parse From addr
my $from_addr = lc $pms->get('From:addr');
my $from_domain = $self->{main}->{registryboundaries}->uri_to_domain("mailto:$from_addr");
return unless defined $from_domain;
# Parse From name
my $fromname = lc $pms->get('From:name');
# Very common to have From address cloned into name, ignore?
#if ($fromname eq $from_addr) {
# dbg("ignoring, From-name is exactly same as From addr: $fromname");
# return;
#}
my ($fromname_addr, $fromname_domain);
if ($fromname =~ /\b([\w\.\!\#\$\%\&\'\*\+\/\=\?\^\_\`\{\|\}\~-]+\@\w[\w-]*\.\w[\w.-]++)\b/i) {
$fromname_addr = $1;
$fromname_domain = $self->{main}->{registryboundaries}->uri_to_domain("mailto:$fromname_addr");
# No valid domain/TLD found? Any reason to keep testing a possibly obfuscated one?
if (!defined $fromname_domain) {
dbg("no From-name addr found");
return;
}
$pms->{fromname_contains_email} = 1; # check_fromname_contains_email hit
# Calculate "closeness" (this really needs documentation, as it's hard to understand)
my $nochar = ($fromname =~ y/a-z0-9//c);
$nochar -= ($fromname_addr =~ y/a-z0-9//c);
my $len = length($fromname) + $nochar - length($fromname_addr);
unless ($len <= $conf->{fns_extrachars}) {
dbg("not enough closeness for From-name/addr: $fromname <=> $fromname_addr ($len <= $conf->{fns_extrachars})");
return;
}
} else {
# No point continuing if email was not found inside name
dbg("no From-name addr found");
return;
}
# Parse owners
my $list_refs = {};
if ($conf->{fns_addrlists}) {
my @lists = keys %{$conf->{fns_addrlists}};
foreach my $list (@lists) {
$list_refs->{$list} = $conf->{$list};
}
dbg("using addrlists for owner aliases: ".join(', ', map { s/^FNS_//r; } @lists));
}
my $fromname_owner = $self->_find_address_owner($fromname_addr, $fromname_domain, $list_refs);
my $from_owner = $self->_find_address_owner($from_addr, $from_domain, $list_refs);
dbg("Parsed From-name addr/domain/owner: $fromname_addr/$fromname_domain/$fromname_owner");
dbg("Parsed From-addr addr/domain/owner: $from_addr/$from_domain/$from_owner");
if ($fromname_addr ne $from_addr) {
dbg("From-name addr differs from From addr: $fromname_addr != $from_addr");
$pms->{fromname_address_different} = 1;
}
if ($fromname_domain ne $from_domain) {
dbg("From-name domain differs from From domain: $fromname_domain != $from_domain");
$pms->{fromname_domain_different} = 1;
}
if ($fromname_owner ne $from_owner) {
dbg("From-name owner differs from From owner: $fromname_owner != $from_owner");
$pms->{fromname_owner_different} = 1;
}
# Check Reply-To related
my $replyto_addr = lc $pms->get('Reply-To:addr');
if ($fromname_addr eq $replyto_addr) {
dbg("From-name addr is same as Reply-To addr: $fromname_addr");
$pms->{fromname_equals_replyto} = 1;
}
# Check To related
foreach my $to_addr ($pms->all_to_addrs()) {
if ($fromname_addr eq $to_addr) {
dbg("From-name addr is same as To addr: $fromname_addr");
$pms->{fromname_equals_to_addr} = 1;
last;
}
}
# Set tags
if ($pms->{fromname_address_different} || $pms->{fromname_owner_different}) {
$pms->set_tag("FNSFNAMEADDR", $fromname_addr);
$pms->set_tag("FNSFNAMEDOMAIN", $fromname_domain);
$pms->set_tag("FNSFNAMEOWNER", $fromname_owner);
$pms->set_tag("FNSFADDRADDR", $from_addr);
$pms->set_tag("FNSFADDRDOMAIN", $from_domain);
$pms->set_tag("FNSFADDROWNER", $from_owner);
}
}
sub _find_address_owner {
my ($self, $addr, $addr_domain, $list_refs) = @_;
# Check fns addrlist first for user defined mapping
foreach my $owner (keys %{$list_refs}) {
foreach my $listaddr (keys %{$list_refs->{$owner}}) {
if ($addr =~ $list_refs->{$owner}{$listaddr}) {
$owner =~ s/^FNS_//;
return lc $owner;
}
}
}
# If we have subdomain addr foo.bar@sub.domain.com,
# this will try to recheck foo.bar@domain.com from addrlist
local($1,$2);
if ($addr =~ /^([^\@]+)\@(.+)$/) {
if ($2 ne $addr_domain) {
return $self->_find_address_owner("$1\@$addr_domain", $addr_domain, $list_refs);
}
}
# Grab the first component of TLD
if ($addr_domain =~ /^([^.]+)\./) {
return $1;
} else {
return $addr_domain;
}
}
1;