Group
Extension

Mail-Milter-Authentication/lib/Mail/Milter/Authentication/Handler/SPF.pm

package Mail::Milter::Authentication::Handler::SPF;
use 5.20.0;
use strict;
use warnings;
use Mail::Milter::Authentication::Pragmas;
# ABSTRACT: Handler class for SPF
our $VERSION = '4.20250811'; # VERSION
use base 'Mail::Milter::Authentication::Handler';
use Mail::SPF;

sub default_config {
    return {
        'hide_received-spf_header' => 0,
        'hide_none'                => 0,
        'best_guess'               => 0,
        'spfu_detection'           => 0,
    };
}

sub grafana_rows {
    my ( $self ) = @_;
    my @rows;
    push @rows, $self->get_json( 'SPF_metrics' );
    return \@rows;
}

sub setup_callback {
    my ( $self ) = @_;

    $self->set_object_maker( 'spf_server' , sub {
        my ( $self, $name ) = @_;
        my $thischild = $self->{'thischild'};
        $self->dbgout( 'Object created', $name, LOG_DEBUG );
        my $object;
        eval {
            my $resolver = $self->get_object('resolver');
            $object = Mail::SPF::Server->new(
                'hostname'     => $self->get_my_hostname(),
                'dns_resolver' => $resolver,
            );
        };
        if ( my $error = $@ ) {
            $self->handle_exception( $error );
            $self->log_error( 'SPF Object Setup Error ' . $error );
        }
        $thischild->{'object'}->{$name} = {
            'object'  => $object,
            'destroy' => 0,
        };
    });
}

sub register_metrics {
    return {
        'spf_total'      => 'The number of emails processed for SPF',
    };
}

sub wrap_header {
    my ( $self, $value ) = @_;
    $value =~ s/ /\n    /;
    $value =~ s/\) /\)\n    /;
    $value =~ s/; /;\n    /g;
    return $value;
}

sub helo_callback {
    my ( $self, $helo_host ) = @_;
    $self->{'failmode'} = 0;
    $self->{'helo_name'} = $helo_host;
}

sub envfrom_callback {

    # On MAILFROM
    #...
    my ( $self, $env_from ) = @_;
    my $config = $self->handler_config();
    return if ( $self->is_local_ip_address() );
    return if ( $self->is_trusted_ip_address() );
    return if ( $self->is_authenticated() );

    if ( $config->{'spfu_detection'} ) {
        $self->{'spfu_from_domain'} = '';
        $self->{'spfu_chain'}       = [];
    }
    delete $self->{'spf_header'};
    delete $self->{'spf_metric'};

    my $spf_server = $self->get_object('spf_server');
    if ( ! $spf_server ) {
        $self->log_error( 'SPF Setup Error' );
        $self->{ 'spf_metric' } = 'error';
        my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'spf' )->safe_set_value( 'temperror' );
        $self->{'spf_header'} = $header;
        return;
    }

    my $scope = 'mfrom';

    $env_from = q{} if $env_from eq '<>';

    my $identity;
    my $domain;
    if ( !$env_from ) {
        $identity = $self->{'helo_name'};
        $domain   = $identity;
        $scope    = 'helo';
    }
    else {
        $identity = $self->get_address_from($env_from);
        $domain   = $self->get_domain_from($identity);
    }

    if ( !$identity ) {
        $identity = $self->{'helo_name'};
        $domain   = $identity;
        $scope    = 'helo';
    }

    eval {
        my $spf_request = Mail::SPF::Request->new(
            'versions'      => [1],
            'scope'         => $scope,
            'identity'      => $identity,
            'ip_address'    => $self->ip_address(),
            'helo_identity' => $self->{'helo_name'},
        );

        my $spf_result = $spf_server->process($spf_request);
        my $spf_results = $self->get_object('spf_results');
        $spf_results = [] if ! $spf_results;
        push @$spf_results, $spf_result;
        $self->set_object('spf_results',$spf_results,1);

        my $result_code = $spf_result->code();

        # Best Guess SPF based on org domain
        # ToDo report this in both metrics and AR header
        my $auth_domain;
        if ( $result_code eq 'none' ) {
            if ( $config->{'best_guess'} ) {
                if ( $self->is_handler_loaded( 'DMARC' ) ) {
                    my $dmarc_handler = $self->get_handler('DMARC');
                    my $dmarc_object = $dmarc_handler->get_dmarc_object();
                    if ( $domain ) {
                        my $org_domain = eval{ $dmarc_object->get_organizational_domain( $domain ); };
                        $self->handle_exception( $@ );
                        if ( $org_domain ne $domain ) {
                            $auth_domain = $org_domain;
                            $spf_request = Mail::SPF::Request->new(
                                'versions'         => [1],
                                'scope'            => $scope,
                                'identity'         => $identity,
                                'authority_domain' => $org_domain,
                                'ip_address'       => $self->ip_address(),
                                'helo_identity'    => $self->{'helo_name'},
                            );
                            $spf_result = $spf_server->process($spf_request);
                            my $spf_results = $self->get_object('spf_results');
                            $spf_results = [] if ! $spf_results;
                            push @$spf_results, $spf_result;
                            $self->set_object('spf_results',$spf_results,1);
                            $result_code = $spf_result->code();
                        }
                    }
                }
            }
        }

        $self->{ 'spf_metric' } = $result_code;

        my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'spf' )->safe_set_value( $result_code );
        if ( $auth_domain ) {
            $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.authdomain' )->safe_set_value( $auth_domain ) );
        }
        $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.mailfrom' )->safe_set_value( $self->get_address_from( $env_from ) ) );
        $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.helo' )->safe_set_value( $self->{ 'helo_name' } ) );
        if ( !( $config->{'hide_none'} && $result_code eq 'none' ) ) {
            $self->{'spf_header'} = $header;
        }

        # Set for DMARC
        # Note, these are the SPF results to be processed
        # BY the DMARC handler, they are not DMARC results.
        $self->{'dmarc_domain'}         = $domain;
        $self->{'dmarc_scope'}          = $scope;
        $self->{'dmarc_result'}         = $result_code;
        $self->{'dmarc_spfu_downgrade'} = 0;

        $self->dbgout( 'SPFCode', $result_code, LOG_DEBUG );

        if ( !( $config->{'hide_received-spf_header'} ) ) {
            if ( !( $config->{'hide_none'} && $result_code eq 'none' ) ) {
                my $result_header = $spf_result->received_spf_header();
                my ( $header, $value ) = split( ': ', $result_header, 2 );
                $value = $self->wrap_header( $value );
                $self->prepend_header( $header, $value );
                $self->dbgout( 'SPFHeader', $result_header, LOG_DEBUG );
            }
        }
    };
    if ( my $error = $@ ) {
        $self->handle_exception( $error );
        $self->log_error( 'SPF Error ' . $error );
        $self->{'failmode'} = 1;
        my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'spf' )->safe_set_value( 'temperror' );
        $self->{'spf_header'} = $header;
        $self->{'spf_metric'} = 'error';
    }
}

sub header_callback {
    my ( $self, $header, $value ) = @_;

    return unless exists $self->{'spfu_chain'};
    return unless $self->{'dmarc_result'} eq 'pass'; # Did we have an SPF pass for DMARC

    my $lc_header = lc $header;

    if ( $lc_header eq 'from') {
        my $spfu_from_domain = lc $self->get_address_from($value);
        $spfu_from_domain = $self->get_domain_from($spfu_from_domain) if $spfu_from_domain =~ /\@/;
        $self->{'spfu_from_domain'} = $spfu_from_domain;

        return;
    }

    if ( $lc_header eq 'received-spf' ||
         $lc_header eq 'x-ms-exchange-authentication-results' ||
         $lc_header eq 'arc-authentication-results' ||
         $lc_header =~ 'authentication-results$'
    ) {
        push $self->{'spfu_chain'}->@*, { header => $header, value => $value };
    }
}

sub eoh_callback {
    my ($self) = @_;
    if ( $self->{'spf_header'} ) {
        eval {
            $self->spfu_checks();
        };
        $self->handle_exception( $@ );
        $self->add_auth_header($self->{'spf_header'});
    }
    $self->metric_count( 'spf_total', { 'result' => $self->{'spf_metric'} } ) if $self->{'spf_metric'};
}

sub spfu_checks {
    my ($self) = @_;

    return unless exists $self->{'spfu_chain'};
    return unless exists $self->{'spfu_from_domain'};
    return unless $self->{'dmarc_result'} eq 'pass'; # Did we have an SPF pass for DMARC
    my $dmarc_object;
    if ( $self->is_handler_loaded( 'DMARC' ) ) {
        my $dmarc_handler = $self->get_handler('DMARC');
        $dmarc_object = $dmarc_handler->get_dmarc_object();
    }

    my $spfu_from_domain = $self->{'spfu_from_domain'};
    my $dmarc_domain = $self->{'dmarc_domain'};
    if ($dmarc_object) {
        # Work with org domain if possible
        $dmarc_domain = $dmarc_object->get_organizational_domain( $dmarc_domain );
        $spfu_from_domain = $dmarc_object->get_organizational_domain( $spfu_from_domain );
    }

    return unless lc $dmarc_domain eq $spfu_from_domain;

    ENTRY:
    for my $chain_entry ( reverse $self->{'spfu_chain'}->@* ) {
        last ENTRY if $self->{'spfu_detected'};
        my $header = lc $chain_entry->{'header'};
        my $value  =    $chain_entry->{'value'};

        # Check for a Received-SPF we can parse
        if ( $header eq 'received-spf' ) {
            # We can parse the domain from the comment in most cases
            # Received-SPF: Fail (protection.outlook.com: domain of ups.com does not designate 23.26.253.8 as permitted sender) receiver=protection.outlook.com; client-ip=23.26.253.8; helo=fa83.windbound.org.uk;
            my $lc_value = lc $value;
            next ENTRY unless $lc_value =~ /^fail /;
            my ($for_value) = $lc_value =~ /^.*: domain of (\S+) .*/;
            next ENTRY unless $for_value;
            my $failed_domain = lc $for_value;
            $failed_domain = $self->get_domain_from($failed_domain) if $failed_domain =~ /\@/;
            $failed_domain = $dmarc_object->get_organizational_domain( $failed_domain ) if $dmarc_object;
            if ( $failed_domain eq $spfu_from_domain ) {
                $self->{'spfu_detected'} = 1; # suspicious...
            }

            next ENTRY;
        }

        # Check for Authentication-Results style headers
        # NOTE, We look for ARC-Authentication-Results but do
        # not verify ARC here, this is used as a negative signal
        # so forgery will not be of benefit
        my $ar_object;
        if ( $header eq 'x-ms-exchange-authentication-results' ) {
            # We can parse this slightly nonstandard format into an object
            $ar_object = eval{ Mail::AuthenticationResults->parser()->parse( $value ) };
            $self->handle_exception( $@ );
            unless ( $ar_object ) {
                # Try again with a synthesized authserv id (this is often missing)
                $ar_object = eval{ Mail::AuthenticationResults->parser()->parse( "authserv.example.com; $value" ) };
                $self->handle_exception( $@ );
            }
        } elsif ( $header eq 'arc-authentication-results' ) {
            # We can parse this into an object, remove the instance
            my ($null, $arc_value) = split ';', $value, 2;
            $arc_value =~ s/^ +//;
            $ar_object = eval{ Mail::AuthenticationResults->parser()->parse( $arc_value ) };
            $self->handle_exception( $@ );
        } elsif ( $header =~ 'authentication-results$' ) {
            # We can parse this into an object, best effort with subtypes
            $ar_object = eval{ Mail::AuthenticationResults->parser()->parse( $value ) };
            $self->handle_exception( $@ );
        }
        next ENTRY unless $ar_object; # We didn't find one we could parse

        eval {
            my $spf_fail_entries = $ar_object->search({ 'isa' => 'entry', 'key' => 'spf', 'value' => 'fail' })->children();
            for my $spf_fail_entry ($spf_fail_entries->@*) {
                my $mailfrom_domain_entries = $spf_fail_entry->search({ 'isa' => 'subentry', 'key' => 'smtp.mailfrom'})->children();
                # should be only 1, but let's iterate anyway
                for my $mailfrom_domain_entry ($mailfrom_domain_entries->@*) {
                    my $mailfrom_domain = $mailfrom_domain_entry->value();
                    $mailfrom_domain = $dmarc_object->get_organizational_domain( $mailfrom_domain ) if $dmarc_object;
                    if ( lc $mailfrom_domain eq $spfu_from_domain ) {
                        $self->{'spfu_detected'} = 1; # suspicious...
                    }
                }
            }

        };
        $self->handle_exception( $@ );

    }

    if ( $self->{'spfu_detected'} ) {
        my $config = $self->handler_config();
        $self->{'spf_metric'} = 'spf_upgrade';
        if ( $config->{'spfu_detection'} == 1 ) {
            $self->{'dmarc_spfu_downgrade'} = 1;
            $self->{'spf_header'}->safe_set_value( 'fail' );
            $self->{'spf_header'}->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'spf pass downgraded due to suspicious path' ) );
        }
        else {
            $self->{'spf_header'}->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'warning: aligned spf fail in history' ) );
        }
    }
}

sub close_callback {
    my ( $self ) = @_;
    delete $self->{'spfu_from_domain'};
    delete $self->{'spfu_chain'};
    delete $self->{'spfu_detected'};
    delete $self->{'spf_header'};
    delete $self->{'spf_metric'};
    delete $self->{'dmarc_domain'};
    delete $self->{'dmarc_scope'};
    delete $self->{'dmarc_result'};
    delete $self->{'dmarc_spfu_downgrade'};
    delete $self->{'failmode'};
    delete $self->{'helo_name'};
    $self->destroy_object('spf_results');
}

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

Mail::Milter::Authentication::Handler::SPF - Handler class for SPF

=head1 VERSION

version 4.20250811

=head1 DESCRIPTION

Implements the SPF standard checks.

=head1 CONFIGURATION

        "SPF" : {                                       | Config for the SPF Module
            "hide_received-spf_header" : 0,             | Do not add the "Received-SPF" header
            "hide_none"                : 0,             | Hide auth line if the result is 'none'
                                                        | if not hidden at all
            "best_guess"               : 0,             | Fallback to Org domain for SPF checks
                                                        | if result is none.
            "spfu_detection"           : 0              | Add some mitigation for SPF upgrade attacks
                                                        | 0 = off (default)
                                                        | 1 = mitigate
                                                        | 2 = report only
        },

=head1 AUTHOR

Marc Bradshaw <marc@marcbradshaw.net>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2020 by Marc Bradshaw.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.