From 174501199bdfb77e9c0d32ea5e6458aff56bf7b0 Mon Sep 17 00:00:00 2001
From: Andrew Ruthven <andrew@etc.gen.nz>
Date: Sat, 12 Apr 2025 23:49:14 +1200
Subject: Fix two security issues in RT.

* RT is vulnerable to Cross Site Scripting via injection of malicious
  parameters in a search URL. This vulnerability is assigned CVE-2025-30087.
* RT uses the default OpenSSL cipher, 3DES (des3), for encrypting SMIME email.
  This is an outdated cipher algorithm, so the default is changed to
  aes-128-cbc. In addition, we have made this option configurable so you can
  pick an alternate cipher now or in the future, or revert to des3 if needed
  for compatibility. This vulnerability is assigned CVE-2025-2545.

Patch-Name: upstream_4.4.6_cve:_patchset_2025-04-08.diff
Author: Best Practical <support@bestpractical.com>
Forwarded: not-needed
Applied: 4.4.8
---
 etc/RT_Config.pm                    | 24 ++++++++++++++
 lib/RT/Crypt/SMIME.pm               |  2 +-
 lib/RT/Interface/Web.pm             | 51 +++++++++++++++++++++++++++--
 share/html/Asset/Elements/TSVExport |  2 +-
 share/html/Elements/CollectionList  |  4 +--
 share/html/Elements/ScrubHTML       |  2 +-
 share/html/Elements/TSVExport       |  2 +-
 share/html/Search/Build.html        |  2 +-
 share/html/Search/Edit.html         |  2 +-
 9 files changed, 80 insertions(+), 11 deletions(-)

diff --git a/etc/RT_Config.pm b/etc/RT_Config.pm
index 9ae33228..6ff1681e 100644
--- a/etc/RT_Config.pm
+++ b/etc/RT_Config.pm
@@ -2612,6 +2612,26 @@ higher numbers denoting greater effort.
 
 Set($BcryptCost, 12);
 
+=item C<@RestrictLinkDomains>
+
+This sets a list of external domains that RT is allowed to link to. If this
+setting is empty, no external domains are allowed.
+
+Currently, this restriction only applies to links in Format parameter for
+search results. All external links whose domains are not in the list will
+be removed.
+
+E.g.
+
+    Set(@RestrictLinkDomains, ("example.com", "*.trusted.com"));
+
+    example.com    # Allow links to "example.com"
+    *.trusted.com  # Allow links to any one-level subdomain of "trusted.com"
+
+=cut
+
+Set(@RestrictLinkDomains, ());
+
 =back
 
 
@@ -3123,6 +3143,9 @@ Set C<CheckRevocationDownloadTimeout> to the timeout in seconds for
 downloading a CRL or an issuer certificate (the latter is used when
 checking against OCSP).  The default timeout is 30 seconds.
 
+Set C<Cipher> to the encryption algorithm to use. By default, it's
+C<aes-128-cbc>.
+
 See L<RT::Crypt::SMIME> for details.
 
 =back
@@ -3140,6 +3163,7 @@ Set( %SMIME,
     CheckCRL => 0,
     CheckOCSP => 0,
     CheckRevocationDownloadTimeout => 30,
+    Cipher => 'aes-128-cbc',
 );
 
 =head2 GnuPG configuration
diff --git a/lib/RT/Crypt/SMIME.pm b/lib/RT/Crypt/SMIME.pm
index 67764d55..c98cecc1 100644
--- a/lib/RT/Crypt/SMIME.pm
+++ b/lib/RT/Crypt/SMIME.pm
@@ -425,7 +425,7 @@ sub _SignEncrypt {
             $key = $key_file;
         }
         push @commands, [
-            $self->OpenSSLPath, qw(smime -encrypt -des3),
+            $self->OpenSSLPath, qw(smime -encrypt), '-' . ( $opts->{Cipher} || 'aes-128-cbc' ),
             map { $_->filename } @keys
         ];
     }
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 87102028..ea11a90d 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -4615,12 +4615,20 @@ Removes unsafe and undesired HTML from the passed content
 =cut
 
 my $SCRUBBER;
+my $RESTRICTIVE_SCRUBBER;
 sub ScrubHTML {
     my $Content = shift;
-    $SCRUBBER = _NewScrubber() unless $SCRUBBER;
+    my %args = @_;
 
     $Content = '' if !defined($Content);
-    return $SCRUBBER->scrub($Content);
+    if ( $args{Restrictive} ) {
+        $RESTRICTIVE_SCRUBBER = _NewScrubber(Restrictive => 1) unless $RESTRICTIVE_SCRUBBER;
+        return $RESTRICTIVE_SCRUBBER->scrub($Content);
+    }
+    else {
+        $SCRUBBER = _NewScrubber() unless $SCRUBBER;
+        return $SCRUBBER->scrub($Content);
+    }
 }
 
 =head2 _NewScrubber
@@ -4702,7 +4710,44 @@ if (RT->Config->Get('ShowTransactionImages') or RT->Config->Get('ShowRemoteImage
     $SCRUBBER_RULES{'img'}->{'src'} = join "|", @src;
 }
 
+our %RESTRICTIVE_SCRUBBER_RULES = (
+    a => {
+        href => sub {
+            my ( $self, $tag, $attr, $href ) = @_;
+            return $href unless $href;
+
+            # Allow internal RT macros like __WebPath__, etc.
+            return $href if $href =~ qr{^(?:/|__Web(?:Path|HomePath|BaseURL|URL)__)}i;
+
+            my $uri = URI->new($href);
+            unless ( $uri->can("host") && $uri->host ) {
+                RT->Logger->warn("Unknown link: $href");
+                return '';
+            }
+
+            my $rt_host = RT::Interface::Web::_NormalizeHost( RT->Config->Get('WebBaseURL') )->host;
+            my $host    = lc $uri->host;
+            for my $allowed_domain ( $rt_host, @{ RT->Config->Get('RestrictLinkDomains') || [] } ) {
+                if ( $allowed_domain =~ /\*/ ) {
+
+                    # Turn a literal * into a domain component or partial component match.
+                    my $regex = join "[a-zA-Z0-9\-]*", map { quotemeta($_) }
+                        split /\*/, $allowed_domain;
+                    return $href if $host =~ /^$regex$/i;
+                }
+                else {
+                    return $href if $host eq lc($allowed_domain);
+                }
+            }
+
+            RT->Logger->warning("Blocked link: $href");
+            return '';
+        },
+    },
+);
+
 sub _NewScrubber {
+    my %args = @_;
     require HTML::Scrubber;
     my $scrubber = HTML::Scrubber->new();
 
@@ -4730,7 +4775,7 @@ sub _NewScrubber {
     );
     $scrubber->deny(qw[*]);
     $scrubber->allow(@SCRUBBER_ALLOWED_TAGS);
-    $scrubber->rules(%SCRUBBER_RULES);
+    $scrubber->rules( $args{Restrictive} ? %RESTRICTIVE_SCRUBBER_RULES : %SCRUBBER_RULES );
 
     # Scrubbing comments is vital since IE conditional comments can contain
     # arbitrary HTML and we'd pass it right on through.
diff --git a/share/html/Asset/Elements/TSVExport b/share/html/Asset/Elements/TSVExport
index 660a19eb..646adb1a 100644
--- a/share/html/Asset/Elements/TSVExport
+++ b/share/html/Asset/Elements/TSVExport
@@ -58,7 +58,7 @@ require HTML::Entities;
 
 $r->content_type('application/vnd.ms-excel');
 
-my $DisplayFormat = $m->comp('/Elements/ScrubHTML', Content => $Format);
+my $DisplayFormat = $m->comp('/Elements/ScrubHTML', Content => $Format, Restrictive => 1);
 
 my @Format = $m->comp('/Elements/CollectionAsTable/ParseFormat', Format => $DisplayFormat);
 
diff --git a/share/html/Elements/CollectionList b/share/html/Elements/CollectionList
index fff3c7d8..499442de 100644
--- a/share/html/Elements/CollectionList
+++ b/share/html/Elements/CollectionList
@@ -93,8 +93,8 @@ $Collection->GotoPage( $Page - 1 ); # SB uses page 0 as the first page
 $DisplayFormat ||= $Format;
 
 # Scrub the html of the format string to remove any potential nasties.
-$Format = $m->comp('/Elements/ScrubHTML', Content => $Format);
-$DisplayFormat = $m->comp('/Elements/ScrubHTML', Content => $DisplayFormat);
+$Format = $m->comp('/Elements/ScrubHTML', Content => $Format, Restrictive => 1);
+$DisplayFormat = $m->comp('/Elements/ScrubHTML', Content => $DisplayFormat, Restrictive => 1);
 
 my @Format = $m->comp('/Elements/CollectionAsTable/ParseFormat', Format => $DisplayFormat);
 
diff --git a/share/html/Elements/ScrubHTML b/share/html/Elements/ScrubHTML
index 2a1c7f60..84636070 100644
--- a/share/html/Elements/ScrubHTML
+++ b/share/html/Elements/ScrubHTML
@@ -46,7 +46,7 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%init>
-return ScrubHTML($Content);
+return ScrubHTML($Content, %ARGS);
 </%init>
 <%args>
 $Content => undef
diff --git a/share/html/Elements/TSVExport b/share/html/Elements/TSVExport
index 5f9427f9..5a13fecb 100644
--- a/share/html/Elements/TSVExport
+++ b/share/html/Elements/TSVExport
@@ -62,7 +62,7 @@ $Class ||= $Collection->ColumnMapClassName;
 $r->content_type('application/vnd.ms-excel');
 $r->header_out( 'Content-disposition' => "attachment; filename=$Filename" ) if $Filename;
 
-my $DisplayFormat = $m->comp('/Elements/ScrubHTML', Content => $Format);
+my $DisplayFormat = $m->comp('/Elements/ScrubHTML', Content => $Format, Restrictive => 1);
 
 my @Format = $m->comp('/Elements/CollectionAsTable/ParseFormat', Format => $DisplayFormat);
 
diff --git a/share/html/Search/Build.html b/share/html/Search/Build.html
index 7b134f57..f80459c5 100644
--- a/share/html/Search/Build.html
+++ b/share/html/Search/Build.html
@@ -159,7 +159,7 @@ if ( $NewQuery ) {
     }
     if ( $query{'Format'} ) {
         # Clean unwanted junk from the format
-        $query{'Format'} = $m->comp( '/Elements/ScrubHTML', Content => $query{'Format'} );
+        $query{'Format'} = $m->comp( '/Elements/ScrubHTML', Content => $query{'Format'}, Restrictive => 1 );
     }
 }
 
diff --git a/share/html/Search/Edit.html b/share/html/Search/Edit.html
index cdb7c1c4..f4e42e9d 100644
--- a/share/html/Search/Edit.html
+++ b/share/html/Search/Edit.html
@@ -64,7 +64,7 @@
 
 <%INIT>
 my $title = loc("Edit Query");
-$Format = $m->comp('/Elements/ScrubHTML', Content => $Format);
+$Format = $m->comp('/Elements/ScrubHTML', Content => $Format, Restrictive => 1);
 my $QueryString = $m->comp('/Elements/QueryString',
                            Query   => $Query,
                            Format  => $Format,
