svn commit: r1936591 - in spamassassin/trunk: . lib/Mail/SpamAssassin t

[email protected] Sun, 26 Jul 2026 01:38:14 -0000
Newsgroups gmane.mail.spam.spamassassin.cvs
Message-ID <178502989487.2465993.1186772314561679588@svn03-he-fi>
Author: fkento
Date: Sun Jul 26 01:38:14 2026
New Revision: 1936591

Log:
Bug 8404: fix NBSP false positives in HTML obfuscation detection

 Add t/html_obfuscation_ratio.t to guard the obfuscation-ratio calculation
 against regressions. Writing it uncovered a new false positive: html_text()
 counts a text node's trailing character and the next node's leading
 character as "obfuscation" when both are word characters (a word split
 across a tag), but its character class treated NBSP as a word character.

 A non-breaking space renders as whitespace and, via the
 <br>-before-closing-block rewrite in html_tag(), is inserted by the parser
 itself, so NBSP-separated runs were wrongly counted as split words. Exclude
 \xa0 from the class, taking care not to confuse it with legitimate text:

   - a leading UTF-8 NBSP is skipped with (?!\xc2\xa0), without excluding
     the shared \xc2 lead byte (© ® ° ...);
   - a trailing \xa0 still counts when it is the tail byte of a multibyte
     letter (à, Š, CJK, ...) rather than an NBSP.

 The word-character class is factored into $wordchar with the leading and
 trailing variants built from it. The test exercises these cases directly
 against Mail::SpamAssassin::HTML in both character-semantics modes.

Added:
   spamassassin/trunk/t/html_obfuscation_ratio.t
Modified:
   spamassassin/trunk/MANIFEST
   spamassassin/trunk/lib/Mail/SpamAssassin/HTML.pm

Modified: spamassassin/trunk/MANIFEST
==============================================================================
--- spamassassin/trunk/MANIFEST	Sun Jul 26 00:00:16 2026	(r1936590)
+++ spamassassin/trunk/MANIFEST	Sun Jul 26 01:38:14 2026	(r1936591)
@@ -650,6 +650,7 @@ t/hashbl.t
 t/html_colors.t
 t/html_nested_anchors.t
 t/html_obfu.t
+t/html_obfuscation_ratio.t
 t/html_utf8.t
 t/html_visibility.t
 t/html_whitespace.t

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/HTML.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/HTML.pm	Sun Jul 26 00:00:16 2026	(r1936590)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/HTML.pm	Sun Jul 26 01:38:14 2026	(r1936591)
@@ -1147,18 +1147,38 @@ sub html_text {
     # 1. using \w or [A-Za-z] instead of \S or non-punctuation
     # 2. exempting certain tags
     # no re "strict";  # since perl 5.21.8: Ranges of ASCII printables...
-    # Bug 8404: \x00 is the paragraph-break marker pushed by html_whitespace() for block
-    # tags (div, p, ...).  It is not in \s, so it is included here explicitly;
-    if ($text =~ /^[^\s\x00\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]/s &&
-	$self->{text}->[-1] =~ /[^\s\x00\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]\z/s)
+    # Bug 8404: $wordchar excludes \s and ASCII punctuation but otherwise treats
+    # everything (incl. non-ASCII bytes) as a "word" character, so the synthetic
+    # marker bytes the parser inserts to represent whitespace/breaks would
+    # wrongly read as word content and make an ordinary block break look like a
+    # word split across a tag.  Exclude them from the class:
+    #   \x00   paragraph-break marker (html_whitespace, div/p/...)
+    #   \xa0   NBSP -- lone \x{a0} (char-semantics) or the trailing byte of
+    #          UTF-8 \xc2\xa0 (octet mode); NBSP renders as whitespace, and
+    #          html_tag() rewrites a <br> before a closing block to NBSP.
+    my $wordchar = qr/[^\s\x00\xa0\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]/;
+
+    # \xa0 is also a legitimate byte in UTF-8 text -- it is the trailing byte of
+    # ~2000 letters (à=\xc3\xa0, Š=\xc5\xa0, 1400+ CJK) -- so excluding it from
+    # $wordchar would stop counting a word split right after one of those.  The
+    # anchors below add that back where it matters:
+    #   - leading (^): a run never *starts* with a bare \xa0 for a real letter
+    #     (that would be its lead byte, \xc2..\xf4), so just skip a leading UTF-8
+    #     NBSP with (?!\xc2\xa0).
+    #   - trailing (\z): a trailing \xa0 is ambiguous, so also accept an \xa0
+    #     that is the tail of a multibyte char (preceded by a UTF-8 continuation
+    #     or non-\xc2 lead byte); the NBSP forms (\xc2\xa0 / lone \x{a0}) are not.
+    my $lead  = qr/(?!\xc2\xa0)$wordchar/;
+    my $trail = qr/(?:$wordchar|(?<=[\x80-\xbf\xc3-\xf4])\xa0)/;
+
+    if ($text =~ /^$lead/s && $self->{text}->[-1] =~ /$trail\z/s)
     {
       $self->{obfuscation}++;
     }
-    if ($self->{text}->[-1] =~
-	/\b([^\s\x00\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]{1,7})\z/s)
+    if ($self->{text}->[-1] =~ /\b($trail{1,7})\z/s)
     {
       my $start = length($1);
-      if ($text =~ /^([^\s\x00\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]{1,7})\b/s) {
+      if ($text =~ /^($lead{1,7})\b/s) {
 	$self->{backhair}->{$start . "_" . length($1)}++;
       }
     }

Added: spamassassin/trunk/t/html_obfuscation_ratio.t
==============================================================================
--- /dev/null	00:00:00 1970	(empty, because file is newly added)
+++ spamassassin/trunk/t/html_obfuscation_ratio.t	Sun Jul 26 01:38:14 2026	(r1936591)
@@ -0,0 +1,80 @@
+#!/usr/bin/perl -T
+use strict;
+use warnings;
+use lib '.'; use lib 't';
+use lib 'lib';
+use Mail::SpamAssassin::HTML;
+use Test::More;
+
+# Bug 8404: the obfuscation counter (HTML.pm html_text) increments when the
+# tail of one text node and the head of the next are both "word" characters,
+# meaning a word looks split across a tag (V<b></b>iagra).  The synthetic
+# marker bytes the parser inserts for whitespace/breaks -- \x00 (block break)
+# and \xa0 (NBSP, incl. the <br>-before-closing-block rewrite) -- are not \s,
+# so they used to read as word chars and made ordinary block breaks and NBSP
+# separated runs look obfuscated (HTML_OBFUSCATE_* false positives).
+
+# triples of (label, HTML, expected obfuscation count)
+my @tests = (
+
+    '<br> counts as whitespace -- no obfuscation',
+    'Foo<br>bar',
+    0,
+
+    'Block elements count as whitespace -- no obfuscation',
+    '<div dir="auto">Bonjour Salomon</div><div dir="auto">Je reviens vers vous</div><div dir="auto">La vente</div>',
+    0,
+
+    '<br> immediately before a closing block -- no obfuscation',
+    '<div>alpha<br></div><div>bravo<br></div><div>charlie</div>',
+    0,
+
+    'Bare NBSP (\xa0) at the end of a text block -- no obfuscation',
+    "foo\xa0<b>bar</b>",
+    0,
+
+    'Bare NBSP (\xa0) at the beginning of a text block -- no obfuscation',
+    "foo<b>\xa0bar</b>",
+    0,
+
+    'A run starting with UTF-8 NBSP (\xc2\xa0) -- no obfuscation',
+    "word<b>\xc2\xa0next</b>",
+    0,
+
+    'A run ending with UTF-8 NBSP (\xc2\xa0) -- no obfuscation',
+    "word\xc2\xa0<b>next</b>",
+    0,
+
+    'Genuine mid-word split -- obfuscation',
+    "Vi<b>a</b>gra",
+    2,
+
+    # \xa0 is also the trailing byte of many UTF-8 letters; a genuine split
+    # right after one must still count, i.e. the NBSP exclusion must not swallow
+    # these letter tails.  Two shapes: a 2-byte char whose lead byte precedes
+    # the \xa0 (à=\xc3\xa0), and a 3-byte char whose continuation byte does
+    # (CJK U+4E20=\xe4\xb8\xa0).
+    'Split after 2-byte UTF-8 letter ending in \xa0 (à) -- obfuscation',
+    "voil\xc3\xa0<b>x</b>",
+    1,
+
+    'Split after 3-byte CJK char ending in \xa0 -- obfuscation',
+    "\xe4\xb8\xa0<b>x</b>",
+    1,
+
+);
+
+plan tests => scalar(@tests) / 3 * 2;   # (@tests/3) cases x 2 semantics modes
+
+while (@tests) {
+    my $label = shift @tests;
+    my $html     = shift @tests;
+    my $expected = shift @tests;
+
+    for my $character_semantics (0,1) {
+        my $obj = Mail::SpamAssassin::HTML->new($character_semantics, 1);
+        $obj->parse($html);
+        is($obj->{obfuscation} || 0, $expected,
+           "[char_semantics=$character_semantics] $label");
+    }
+}