svn commit: r1935671 - in spamassassin/trunk: lib/Mail/SpamAssassin/Plugin t

[email protected] Sat, 27 Jun 2026 01:14:05 -0000
Newsgroups gmane.mail.spam.spamassassin.cvs
Message-ID <178252284593.1749228.5445881495984410259@svn03-he-fi>
Author: fkento
Date: Sat Jun 27 01:14:05 2026
New Revision: 1935671

Log:
Redirectors: only extract embedded URIs that actually look like URLs

_extract_embedded_uri turned any url_redirector_params value into an
embedded URI, prepending http:// to bare tokens. Referral codes and
labels such as r=to8ex or redirect=app-store-no-desktop became bogus
http://to8ex / http://app-store-no-desktop URIs that were added to the
URI detail list and subjected to URIBL/HASHBL lookups, causing false
positives.

Require the captured value to look like a URL (explicit/encoded scheme,
scheme-relative //host, or bare host.tld/path) before accepting it. Also
guard against a user-supplied param regex with no capture group, which
left $1 undefined and fabricated a bare "http://".

Add regression tests covering both.

Modified:
   spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/Redirectors.pm
   spamassassin/trunk/t/redirectors_match.t

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/Redirectors.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/Redirectors.pm	Sat Jun 27 00:45:04 2026	(r1935670)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/Redirectors.pm	Sat Jun 27 01:14:05 2026	(r1935671)
@@ -1041,6 +1041,18 @@ sub _extract_embedded_uri {
   local($1);
   if (($rest =~ /(?:\?|\&)$rreg/gis) || ($rest =~ /(?:\/|\_|\=)((?:https?:)?\/\/.*)/)) {
     my $newuri = $1;
+    # A user-supplied url_redirector_params with no capture group leaves $1
+    # undefined here; without this guard we would fabricate a bare "http://".
+    return unless defined $newuri;
+    # The param value is only an embedded URI if it actually looks like one:
+    # an explicit/encoded scheme, a scheme-relative //host, or a bare
+    # host.tld/path. A bare token (referral code, label, etc.) is not a URI
+    # and must not be turned into a fabricated http://<token>.
+    unless ($newuri =~ m{^https?(?::|%3a)}i
+         || $newuri =~ m{^//}
+         || $newuri =~ m{^[^/?#\s]+\.[^/?#\s]+/}) {
+      return;
+    }
     dbg("Found embedded uri $newuri in $uri");
     $newuri = 'http://' . $newuri if $newuri !~ /^http/;
     return $newuri;

Modified: spamassassin/trunk/t/redirectors_match.t
==============================================================================
--- spamassassin/trunk/t/redirectors_match.t	Sat Jun 27 00:45:04 2026	(r1935670)
+++ spamassassin/trunk/t/redirectors_match.t	Sat Jun 27 01:14:05 2026	(r1935671)
@@ -63,8 +63,28 @@ my @cases = (
   [ 'https://app.spa.example/foo',   'selenium', 'selenium method on leading-dot subdomain' ],
 );
 
-# clear_url_redirector tests add 4 more checks
-plan tests => scalar(@cases) + 4;
+# A param value is only an embedded URI when it actually looks like one. A bare
+# token (referral code, label) must NOT be turned into a fabricated http://<token>.
+# Use a param list that includes the short params (r, redirect) that produced the
+# original Substack false positives, not just url/u.
+my $embedded_conf =
+  { url_redirector_params => qr/(?:url|u|r|redir|redirect)=(.*)/i };
+my @embedded = (
+  # [ uri, expected_extraction (undef = no embedded uri), desc ]
+  [ 'https://safe.example/?url=https://evil.com/path', 'https://evil.com/path',
+    'explicit scheme is extracted' ],
+  [ 'https://safe.example/?url=evil.com/landing', 'http://evil.com/landing',
+    'bare host.tld/path is extracted' ],
+  [ 'https://safe.example/?url=//evil.com/path', 'http:////evil.com/path',
+    'scheme-relative //host is extracted' ],
+  [ 'https://substack.com/signup?foo=bar&r=to8ex', undef,
+    'bare referral token (r=) is NOT an embedded uri' ],
+  [ 'https://open.substack.com/notes?utm_campaign=open-in-app&redirect=app-store-no-desktop', undef,
+    'bare hyphenated label (redirect=) is NOT an embedded uri' ],
+);
+
+# +1 no-capture-group check, +4 clear_url_redirector checks
+plan tests => scalar(@cases) + scalar(@embedded) + 1 + 4;
 
 for my $c (@cases) {
   my ($uri, $expect_method, $desc) = @$c;
@@ -77,6 +97,25 @@ for my $c (@cases) {
   }
 }
 
+for my $c (@embedded) {
+  my ($uri, $expect, $desc) = @$c;
+  my $r = Mail::SpamAssassin::Plugin::Redirectors::_extract_embedded_uri($uri, $embedded_conf);
+  if (defined $expect) {
+    is($r, $expect, $desc);
+  } else {
+    ok(!defined $r, $desc);
+  }
+}
+
+# A url_redirector_params regex with no capture group leaves $1 undefined; the
+# extractor must bail rather than fabricate a bare "http://".
+{
+  my $nocap_conf = { url_redirector_params => qr/(?:r|redirect)=[^&]+/i };
+  my $r = Mail::SpamAssassin::Plugin::Redirectors::_extract_embedded_uri(
+    'https://substack.com/signup?foo=bar&r=to8ex', $nocap_conf);
+  ok(!defined $r, 'no-capture-group param regex yields no embedded uri');
+}
+
 # clear_url_redirector behavior
 {
   my $conf = { url_redirector_params => qr/(?:url|u)=(.*)/i };