blob: ea380f08ea37d8a6204bc8ace45897130ad0b38c [file]
# <@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:
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# 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
Mail::SpamAssassin::Handler::JavaScript - A MIME-part handler for C<text/javascript> parts
=head1 SYNOPSIS
loadhandler Mail::SpamAssassin::Handler::JavaScript
script RULE_NAME /pattern/modifiers
script RULE_NAME eval:check_script_contains_recip_addr()
=head1 DESCRIPTION
A MIME-part handler for C<text/javascript> parts. As it consumes each JavaScript
part: it collects the script text for C<script> rules (see L</SCRIPT RULES>) and
adds any navigation/redirect URLs it finds (e.g. C<< window.location = '...' >>)
to the URI detail list with type C<script>, so C<uri-detail> rules can target them.
=head1 RETURNS
This handler returns no sub-parts (always an empty list).
=head1 SCRIPT RULES
script RULENAME /regex/modifiers
score RULENAME 1.0
describe RULENAME message contains script matching /regex/
These rules behave like C<rawbody> rules and support the C<multiple> and
C<maxhits=N> tflags. By default a rule stops at its first match. A rule may
also reference an eval function:
script RULENAME eval:check_script_contains_recip_addr()
=head1 EVAL RULES
check_script_contains_recip_addr()
Fires if the recipient's address (To:addr) appears anywhere in the
script text -- a common phishing indicator.
=cut
package Mail::SpamAssassin::Handler::JavaScript;
use strict;
use warnings;
use re 'taint';
use Mail::SpamAssassin::Handler;
use Mail::SpamAssassin::Logger qw(dbg would_log);
use Mail::SpamAssassin::Util qw(compile_regexp untaint_var);
our @ISA = qw(Mail::SpamAssassin::Handler);
sub log_dbg { Mail::SpamAssassin::Logger::dbg ("javascript: @_"); }
sub new {
my ($class, $mailsaobject) = @_;
$class = ref($class) || $class;
my $self = $class->SUPER::new($mailsaobject);
bless ($self, $class);
$self->register_eval_rule("check_script_contains_recip_addr",
$Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
# Register as the handler for JavaScript parts. text/javascript is the
# canonical type; the application/javascript et al. aliases and the .js
# filename map to it via Message::Node::effective_type (see Node.pm).
$self->register_handler('text/javascript', 'handle_javascript');
$self->set_config($mailsaobject->{conf});
return $self;
}
sub set_config {
my ($self, $conf) = @_;
my @cmds;
push (@cmds, (
{
# script RULENAME /pattern/modifiers (or script RULENAME eval:func())
# Define a rule that matches against collected JavaScript. Regex rules are
# compiled and stored; the match loop is built in finish_parsing_end. An
# eval: form just registers the named eval rule.
setting => 'script',
is_priv => 1,
type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
code => sub {
my ($self, $key, $value, $line) = @_;
if ($value !~ /^(\S+)\s+(.+)$/) {
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
}
my ($name, $pattern) = ($1, $2);
if ( $pattern =~ /^eval:(.*)/ ) {
$self->{parser}->add_test($name, $1,
$Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
return;
}
my ($re, $err) = compile_regexp($pattern, 1);
if (!$re) {
dbg("javascript: invalid script regexp for $name '$pattern': $err");
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
}
$conf->{script_rules}->{$name} = $re;
$self->{parser}->add_test($name, undef,
$Mail::SpamAssassin::Conf::TYPE_EMPTY_TESTS);
},
},
));
$conf->{parser}->register_commands(\@cmds);
}
# handle_javascript($node, $pms): collect one part's JavaScript onto
# $pms->{Handler}{JavaScript}{script_text} (one entry per part, document order),
# and add any redirect URLs it navigates to (window.location = '...', etc.) to the
# URI detail list. Produces no child parts.
sub handle_javascript {
my ($self, $node, $pms) = @_;
my $js = $node->decode();
return [] unless defined $js && length $js;
push @{ $pms->{Handler}{JavaScript}{script_text} }, $js;
log_dbg("collected ".length($js)." bytes of script from ".($node->{name} || $node->{type} || '?'));
$self->_extract_uris($js, $pms);
return [];
}
# Pull navigation/redirect URLs out of script text and add them to the URI detail
# list (tagged 'script'). A URL assigned to the browser location -- e.g.
# window.location='https://...', location.href="https://...",
# location.replace('https://...') -- is the page the script sends the victim to,
# but it lives inside a JS string, so the HTML/SVG link-attribute scanners never
# see it. Surfacing it lets uri-detail rules target it:
#
# uri-detail RULENAME type =~ /script/ raw =~ /badsite\.example/
#
# We deliberately match only location-navigation forms (not every http(s) string
# in the script) to keep noise -- analytics beacons, commented URLs, library CDN
# references -- out of the URI list.
sub _extract_uris {
my ($self, $js, $pms) = @_;
my %seen;
# (window|top|self|parent|document)?.location followed by either an
# assignment (= 'url' / .href = 'url') or a navigation call
# (.replace('url') / .assign('url')), capturing the quoted http(s) URL.
while ( $js =~ m{
(?: \b (?: window | top | self | parent | document ) \s* \. \s* )?
location
\s*
(?:
(?: \. \s* href \s* )? = | # location = / location.href =
\. \s* (?: replace | assign ) \s* \( # location.replace( / .assign(
)
\s* (['"]) ( https? :// [^'"]+ ) \1
}gixs ) {
my $uri = $2;
next if $seen{$uri}++;
log_dbg("found redirect URI: $uri");
$pms->add_uri_detail_list($uri, { script => 1 }, 'JavaScript');
}
}
# After config is fully parsed, compile all script rules into a single sub
# (_run_script_rules), mirroring how the PDF/Image handlers build their custom
# rule types. Each default rule stops at its first hit; "multiple" rules scan
# with /g and honour maxhits=N.
sub finish_parsing_end {
my ($self, $opts) = @_;
my $conf = $opts->{conf};
return unless exists $conf->{script_rules};
my $would_log = would_log('dbg');
my $eval = <<'EOF';
package Mail::SpamAssassin::Handler::JavaScript;
sub _run_script_rules {
my ($self, $opts) = @_;
my $pms = $opts->{permsgstatus};
my ($test_qr, $hits);
my $script_text = $self->_get_script_text($pms);
return unless @$script_text;
EOF
my $loopid = 0;
foreach my $name (keys %{$conf->{script_rules}}) {
$loopid++;
my $tflags = $conf->{tflags}->{$name} || '';
my ($dbg_running_rule, $dbg_ran_rule) = ('', '');
if ($would_log) {
$dbg_running_rule = qq(dbg("running rule $name"););
$dbg_ran_rule = qq(dbg(qq(ran rule $name ======> got hit "\$match")););
}
my $ifwhile = 'if';
my $last = 'last;';
my $modifiers = 'p';
my $init_hits = '';
if ($tflags =~ /\bmultiple\b/) {
$ifwhile = 'while';
$modifiers .= 'g';
if ($tflags =~ /\bmaxhits=(\d+)\b/) {
$init_hits = "\$hits = 0;";
$last = "last rule_$loopid if ++\$hits >= $1;";
} else {
$last = '';
}
}
$eval .= <<"EOF";
$dbg_running_rule
\$test_qr = \$pms->{conf}->{script_rules}->{$name};
$init_hits
rule_$loopid: foreach my \$line (\@\$script_text) {
$ifwhile ( \$line =~ /\$test_qr/$modifiers ) {
my \$match = defined \${^MATCH} ? \${^MATCH} : '<negative match>';
$dbg_ran_rule
\$pms->got_hit('$name', 'SCRIPT: ', 'ruletype' => 'rawbody');
$last
}
}
EOF
}
$eval .= "}\n";
# The generated sub replaces the no-op _run_script_rules placeholder below.
no warnings 'redefine';
eval untaint_var($eval);
if ($@) {
die("javascript: error compiling script rules: $@");
}
}
# Real implementation is compiled in by finish_parsing_end; this is the no-op
# used when no script rules are configured.
sub _run_script_rules { }
sub parsed_metadata {
my ($self, $opts) = @_;
$self->_run_script_rules($opts);
}
# The text source for script rules and the eval rule: the per-part script text
# collected by handle_javascript (in document order).
sub _get_script_text {
my ($self, $pms) = @_;
return ($pms->{Handler}{JavaScript} && $pms->{Handler}{JavaScript}{script_text}) || [];
}
# Eval rule: true if the recipient address appears in any collected script text.
sub check_script_contains_recip_addr {
my ($self, $pms) = @_;
my $to = $pms->get('To:addr');
return 0 unless defined $to && length $to;
my $script_text = $self->_get_script_text($pms);
return scalar grep { /\Q$to\E/ } @$script_text;
}
# Characters that separate tokens in JavaScript: statement/expression
# punctuation, whitespace, quotes, and operators. A run of anything NOT in this
# set is one "token".
my $JS_SEPARATORS = qr/[;,(){}\[\]\s"'=:?+\-*\/<>&|!.~^%@`]+/;
1;