blob: 0053739794054eeb1b4290b03a2be09de4f800c9 [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::SVG - A MIME-part handler for C<image/svg+xml> parts
=head1 SYNOPSIS
loadhandler Mail::SpamAssassin::Handler::SVG
svgtext RULE_NAME /pattern/modifiers
body SVG_TEXT_HEAVY eval:check_svg_text_ratio('5')
=head1 DESCRIPTION
SVG is increasingly used as a phishing vector: an image file that is really a
text/link canvas, often carrying embedded JavaScript. This handler parses each
SVG part and exposes its B<text> content to C<svgtext> rules -- kept separate
from HTML body text so the same word (e.g. "docusign") can be scored differently
in an SVG than in ordinary HTML.
Links found in the SVG are added to the URI detail list (type C<svg>), and any
embedded JavaScript is surfaced for the JavaScript handler -- see L</RETURNS>.
An SVG whose ratio of text words to graphics elements is high -- a mostly-text
"image" -- is flagged via the C<check_svg_text_ratio()> eval rule (see
L</EVAL RULES>).
=head1 RETURNS
The handler returns any embedded JavaScript found in the SVG as a single
C<< { type => 'text/javascript', data => $bytes } >> sub-part, which the handler
framework dispatches to the JavaScript handler
(L<Mail::SpamAssassin::Handler::JavaScript>). The script bodies of C<< <script> >>
elements, C<javascript:> URIs, and C<on*> event-handler attributes are joined
into that one part. When the SVG contains no script, the handler returns an
empty list.
=head1 SVG TEXT RULES
svgtext RULENAME /regex/modifiers
score RULENAME 1.0
describe RULENAME SVG contains text matching /regex/
These rules behave like C<rawbody> rules and support the C<multiple> and
C<maxhits=N> tflags.
=head1 EVAL RULES
check_svg_text_ratio(MIN_RATIO)
Fires if any SVG part's ratio of text words to graphics elements
(image/path/rect/circle/ellipse/line/polyline/polygon/use) is greater than
or equal to MIN_RATIO. An SVG that contains text words but no graphics
element at all is treated as having an infinite ratio and always fires.
MIN_RATIO defaults to 1.
=cut
package Mail::SpamAssassin::Handler::SVG;
use strict;
use warnings;
use re 'taint';
use HTML::Parser ();
use Mail::SpamAssassin::Handler;
use Mail::SpamAssassin::Logger qw(dbg info would_log);
use Mail::SpamAssassin::Util qw(compile_regexp untaint_var);
our @ISA = qw(Mail::SpamAssassin::Handler);
sub log_dbg { Mail::SpamAssassin::Logger::dbg ("svg: @_"); }
# SVG graphics elements -- presence of any of these means the SVG actually
# renders something other than text/links.
my %graphics_tags = map { $_ => 1 } qw(
image path rect circle ellipse line polyline polygon use
);
sub new {
my ($class, $mailsaobject) = @_;
$class = ref($class) || $class;
my $self = $class->SUPER::new($mailsaobject);
bless ($self, $class);
$self->register_eval_rule("check_svg_text_ratio",
$Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
$self->register_handler('image/svg+xml', 'handle_svg');
$self->set_config($mailsaobject->{conf});
return $self;
}
sub set_config {
my ($self, $conf) = @_;
my @cmds;
push (@cmds, (
{
# svgtext RULENAME /pattern/modifiers (or svgtext RULENAME eval:func())
setting => 'svgtext',
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("svg: invalid svgtext regexp for $name '$pattern': $err");
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
}
$conf->{svgtext_rules}->{$name} = $re;
$self->{parser}->add_test($name, undef,
$Mail::SpamAssassin::Conf::TYPE_EMPTY_TESTS);
},
},
));
$conf->{parser}->register_commands(\@cmds);
}
# handle_svg($node, $pms): parse one SVG part. Collect text into
# $pms->{Handler}{SVG}{text}; add any http(s) links to the URI detail list (type
# 'svg'); accumulate the text-word and graphics-element counts across all parts
# (for check_svg_text_ratio); and return any embedded script as a text/javascript
# sub-part.
sub handle_svg {
my ($self, $node, $pms) = @_;
my $data = $node->decode();
return [] unless defined $data && length $data;
my ($text, $script, $uris, $graphics_count) = $self->_parse_svg($data);
push @{ $pms->{Handler}{SVG}{text} }, @$text if @$text;
# Render the SVG's text into the node so ordinary body rules can match it.
# Node::rendered() renders text/plain only; every other type, this one
# included, gets its body text from whatever its handler publishes here.
# Use the part's own (image) type rather than the default text/plain, as
# Handler::Image does: get_body_text_array_common drops text/* parts that
# arrived as attachments, which is the common case for an SVG.
$node->set_rendered(join("\n", @$text), $node->effective_type) if @$text;
# Add links found in the SVG to the URI detail list, tagged 'svg' (so rules can
# target links that hide inside an "image") plus the source element name (e.g.
# 'a' for a clickable link, 'image' for an external image reference).
for my $u ( @$uris ) {
my ($uri, $tag) = @$u;
log_dbg("found URI ($tag): $uri");
$pms->add_uri_detail_list($uri, { svg => 1, $tag => 1 }, 'SVG');
}
# Accumulate text-word and graphics-element counts across all SVG parts. A
# mostly-text "image" (many words, few or no graphics elements) is a likely
# text-phishing canvas -- see check_svg_text_ratio().
my $word_count = 0;
$word_count += () = $_ =~ /\S+/g for @$text;
$pms->{Handler}{SVG}{word_count} += $word_count;
$pms->{Handler}{SVG}{graphics_count} += $graphics_count;
log_dbg("SVG words=$word_count graphics=$graphics_count: ".($node->{name} || '?'));
return [] unless @$script;
my $js = join("\n", grep { defined && length } @$script);
return [] unless length $js;
return [ { type => 'text/javascript', data => $js } ];
}
# Run the SVG through HTML::Parser (lenient; never dies on malformed/hostile
# input, no XML entity-expansion attack surface) and pull out:
# - text nodes (outside <script>) -> @text
# - <script> text + javascript:/on* attrs -> @script
# - http(s) URIs from link/ref attributes -> @uris ([uri, source-tag] pairs)
# - number of graphics elements seen -> $graphics_count
sub _parse_svg {
my ($self, $data) = @_;
my @text;
my @script;
my @uris;
my $graphics_count = 0;
my $in_script = 0;
my $p = HTML::Parser->new(
api_version => 3,
start_h => [ sub {
my ($tag, $attr, $attrseq) = @_;
my $lc = lc $tag;
$in_script++ if $lc eq 'script';
$graphics_count++ if $graphics_tags{$lc};
for my $an (@$attrseq) {
my $av = $attr->{$an};
next unless defined $av;
if ($an =~ /^on/i) {
push @script, $av;
}
elsif ($av =~ /^javascript:(.*)/is) {
push @script, $1;
}
# Link/reference attributes -- href, xlink:href (SVG's namespaced link),
# src, action, data -- carrying an http(s) URI. A clickable link in an
# "image" is a strong phishing signal. Keep the source tag so callers
# can tell e.g. an <a> link from an image reference. Normalise SVG's
# <image> element to 'img' so rules match the HTML <img> tag name.
elsif ($an =~ /^(?:xlink:)?(?:href|src|action|data)$/i
&& $av =~ m{^\s*https?://}i) {
my $src_tag = $lc eq 'image' ? 'img' : $lc;
push @uris, [ $av, $src_tag ];
}
}
}, 'tagname, attr, attrseq' ],
text_h => [ sub {
my ($dtext) = @_;
return unless defined $dtext;
$dtext =~ s/^\s+|\s+$//g;
return if $dtext eq '';
if ($in_script > 0) {
push @script, $dtext;
} else {
push @text, $dtext;
}
}, 'dtext' ],
end_h => [ sub {
my ($tag) = @_;
$in_script-- if lc($tag) eq 'script' && $in_script > 0;
}, 'tagname' ],
);
eval {
local $SIG{__WARN__} = sub {
my $err = $_[0];
$err =~ s/\s+/ /gs; $err =~ s/(.*) at .*/$1/s;
info("svg: HTML::Parser warning: $err");
};
$p->parse($data);
# bug 7437: close any unclosed <script> so trailing text is not lost
$p->parse("</script>") while $in_script > 0;
$p->eof();
};
return (\@text, \@script, \@uris, $graphics_count);
}
# Compile svgtext rules into _run_svgtext_rules, mirroring the JS/PDF handlers.
sub finish_parsing_end {
my ($self, $opts) = @_;
my $conf = $opts->{conf};
return unless exists $conf->{svgtext_rules};
my $would_log = would_log('dbg');
my $eval = <<'EOF';
package Mail::SpamAssassin::Handler::SVG;
sub _run_svgtext_rules {
my ($self, $opts) = @_;
my $pms = $opts->{permsgstatus};
my ($test_qr, $hits);
my $svg_text = $self->_get_svg_text($pms);
return unless @$svg_text;
EOF
my $loopid = 0;
foreach my $name (keys %{$conf->{svgtext_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}->{svgtext_rules}->{$name};
$init_hits
rule_$loopid: foreach my \$line (\@\$svg_text) {
$ifwhile ( \$line =~ /\$test_qr/$modifiers ) {
my \$match = defined \${^MATCH} ? \${^MATCH} : '<negative match>';
$dbg_ran_rule
\$pms->got_hit('$name', 'SVG: ', 'ruletype' => 'rawbody');
$last
}
}
EOF
}
$eval .= "}\n";
no warnings 'redefine';
eval untaint_var($eval);
if ($@) {
die("svg: error compiling svgtext rules: $@");
}
}
# Real implementation is compiled in by finish_parsing_end; no-op otherwise.
sub _run_svgtext_rules { }
sub parsed_metadata {
my ($self, $opts) = @_;
$self->_run_svgtext_rules($opts);
}
sub _get_svg_text {
my ($self, $pms) = @_;
return ($pms->{Handler}{SVG} && $pms->{Handler}{SVG}{text}) || [];
}
# Eval rule: true if the ratio of SVG text words to graphics elements (summed
# across all SVG parts) is >= $min_ratio (default 1). An SVG with text words but
# no graphics element has an infinite ratio and always fires.
sub check_svg_text_ratio {
my ($self, $pms, $min_ratio) = @_;
my $svg = $pms->{Handler}{SVG};
return 0 unless $svg;
my $words = $svg->{word_count} || 0;
my $graphics = $svg->{graphics_count} || 0;
return 0 unless $words;
$min_ratio = 1 unless defined $min_ratio && $min_ratio ne '';
# No graphics element at all -> infinite ratio -> always fires (given words).
return 1 if $graphics == 0;
return ($words / $graphics >= $min_ratio) ? 1 : 0;
}
1;