VYPR
Moderate severityNVD Advisory· Published Aug 5, 2025· Updated Aug 6, 2025

Concrete CMS 9 through 9.4.2 and below 8.5.21 is vulnerable to Reflected Cross-Site Scripting (XSS) in Conversation Messages Dashboard Page

CVE-2025-8571

Description

Concrete CMS 9 to 9.4.2 and versions below 8.5.21 are vulnerable to Reflected Cross-Site Scripting (XSS) in the Conversation Messages Dashboard Page. Unsanitized input could cause theft of session cookies or tokens, defacement of web content, redirection to malicious sites, and (if victim is an admin), the execution of unauthorized actions. The Concrete CMS security team gave this vulnerability a CVSS v.4.0 score of 4.8 with vector CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N. Thanks Fortbridge https://fortbridge.co.uk/  for performing a penetration test and vulnerability assessment on Concrete CMS and reporting this issue.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Concrete CMS versions 9 to 9.4.2 and below 8.5.21 have a reflected XSS in the Conversation Messages Dashboard page, risking session theft and admin actions.

Vulnerability

Detail

Concrete CMS versions 9 to 9.4.2 and all versions below 8.5.21 are vulnerable to reflected cross-site scripting (XSS) on the Conversation Messages Dashboard Page. The root cause is unsanitized user input that allows an attacker to inject malicious scripts into the response. [1] This flaw was reported by Fortbridge and assigned a CVSS v4.0 score of 4.8. [1]

Exploitation

Conditions

The attack vector is network-based and requires low complexity, but the attacker must have high privileges (PR:H) on the target and the victim must click a crafted link (UI:P). [1] The reflected XSS is triggered when the victim interacts with the malicious URL, executing the injected script in the context of the affected page.

Impact

Successful exploitation can lead to theft of session cookies or tokens, defacement of web content, redirection to malicious sites, and if the victim is an admin, execution of unauthorized actions. [1]

Mitigation

The Concrete CMS security team has released patches in versions 9.4.3 and 8.5.21 to address this vulnerability. [2][4] Users are strongly advised to update to the latest version. The fix involves proper sanitization of input on the Conversation Messages page. [2]

AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
concrete5/concrete5Packagist
< 8.5.218.5.21
concrete5/concrete5Packagist
>= 9.0.0RC1, < 9.4.39.4.3

Affected products

2
  • Range: <8.5.21 or 9 to 9.4.2
  • Concrete CMS/Concrete CMSv5
    Range: 9.0.0

Patches

2
4b39dcc17c30

Merge pull request #12646 from concretecms/fix-potential-xss-item-list

https://github.com/concretecms/concretecmsAndrew EmblerAug 5, 2025via ghsa
6 files changed · +293 92
  • build/tasks/build-release/download.js+1 1 modified
    @@ -3,7 +3,7 @@
     const download = require('download');
     
     module.exports = function(grunt, config, parameters, done) {
    -	var zipUrl = parameters.releaseSourceZip || 'https://github.com/concretecms/concretecms/archive/refs/tags/8.5.20.zip';
    +	var zipUrl = parameters.releaseSourceZip || 'https://github.com/concretecms/concretecms/archive/refs/tags/8.5.21.zip';
     	var workFolder = parameters.releaseWorkFolder || './release';
     	function endForError(e) {
     		process.stderr.write(e.message || e);
    
  • composer.lock+90 85 modified
    @@ -35,7 +35,7 @@
                         "hash": "e2a5dd43ca137fd4803f9f35257a7ada4d1e1cdd",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "anahkiasen/html-object/php8.1-compatibility.patch",
                                 "description": "Add PHP 8.1 compatibility"
                             }
    @@ -364,6 +364,70 @@
                 ],
                 "time": "2022-07-07T22:35:21+00:00"
             },
    +        {
    +            "name": "concretecms/monolog-cascade",
    +            "version": "0.5.3",
    +            "source": {
    +                "type": "git",
    +                "url": "https://github.com/concretecms/monolog-cascade.git",
    +                "reference": "034f018e9814c7a057ad2f64242f25e07aaad174"
    +            },
    +            "dist": {
    +                "type": "zip",
    +                "url": "https://api.github.com/repos/concretecms/monolog-cascade/zipball/034f018e9814c7a057ad2f64242f25e07aaad174",
    +                "reference": "034f018e9814c7a057ad2f64242f25e07aaad174",
    +                "shasum": ""
    +            },
    +            "require": {
    +                "monolog/monolog": "^1.13",
    +                "php": "^5.3.9 || ^7.0",
    +                "symfony/config": "^2.7 || ^3.0 || ^4.0 || ^5.0",
    +                "symfony/options-resolver": "^2.7 || ^3.0 || ^4.0 || ^5.0",
    +                "symfony/serializer": "^2.7 || ^3.0 || ^4.0 || ^5.0",
    +                "symfony/yaml": "^2.7 || ^3.0 || ^4.0 || ^5.0"
    +            },
    +            "require-dev": {
    +                "mikey179/vfsstream": "^1.6",
    +                "php-coveralls/php-coveralls": "^1.0",
    +                "phpunit/phpcov": "^2.0",
    +                "phpunit/phpunit": "^4.8",
    +                "squizlabs/php_codesniffer": "^2.5"
    +            },
    +            "type": "library",
    +            "extra": {
    +                "branch-alias": {
    +                    "dev-master": "0.5.x-dev"
    +                }
    +            },
    +            "autoload": {
    +                "psr-4": {
    +                    "Cascade\\": "src/"
    +                }
    +            },
    +            "notification-url": "https://packagist.org/downloads/",
    +            "license": [
    +                "MIT"
    +            ],
    +            "authors": [
    +                {
    +                    "name": "Raphaël Antonmattei",
    +                    "email": "rantonmattei@theorchard.com",
    +                    "homepage": "https://github.com/rantonmattei"
    +                }
    +            ],
    +            "description": "Monolog extension to configure multiple loggers in the blink of an eye and access them from anywhere",
    +            "homepage": "https://github.com/theorchard/monolog-cascade",
    +            "keywords": [
    +                "cascade",
    +                "log",
    +                "logger",
    +                "monolog",
    +                "monolog extension",
    +                "monolog plugin",
    +                "monolog utils"
    +            ],
    +            "time": "2020-09-12T14:44:09+00:00"
    +        },
             {
                 "name": "container-interop/container-interop",
                 "version": "1.2.0",
    @@ -540,7 +604,7 @@
                         "hash": "82e8d7bd5d0530366e7da28f81a2cef0f28bab55",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "doctrine/annotations/access-array-offset-on-null.patch",
                                 "description": "Fix access array offset on value of type null"
                             }
    @@ -1166,12 +1230,12 @@
                         "hash": "cc001a5b63de854ea8b786e0c6cb3d79460c36d2",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "doctrine/orm/UnitOfWork-createEntity-continue.patch",
                                 "description": "Fix UnitOfWork::createEntity()"
                             },
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "doctrine/orm/access-array-offset-on-null.patch",
                                 "description": "Fix access array offset on value of type null"
                             }
    @@ -1403,7 +1467,7 @@
                         "hash": "fc20e1dcce554bb50ef5a2583fae08dd3a7b8e93",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "gettext/gettext/php8-compatibility.patch",
                                 "description": "Fix PHP 8 compatibility"
                             }
    @@ -2742,7 +2806,7 @@
                         "hash": "cf09d5571fb17829c242450582ed83792df24c88",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "league/url/php8.1-compatibility.patch",
                                 "description": "Add PHP 8.1 compatibility"
                             }
    @@ -3271,12 +3335,12 @@
                 "version": "1.39.1",
                 "source": {
                     "type": "git",
    -                "url": "https://github.com/briannesbitt/Carbon.git",
    +                "url": "https://github.com/CarbonPHP/carbon.git",
                     "reference": "4be0c005164249208ce1b5ca633cd57bdd42ff33"
                 },
                 "dist": {
                     "type": "zip",
    -                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/4be0c005164249208ce1b5ca633cd57bdd42ff33",
    +                "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/4be0c005164249208ce1b5ca633cd57bdd42ff33",
                     "reference": "4be0c005164249208ce1b5ca633cd57bdd42ff33",
                     "shasum": ""
                 },
    @@ -3721,7 +3785,7 @@
                         "hash": "d22f2364a834b1917b3b4fa5d3e7227b29575733",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "primal/color/php8-compatibility.patch",
                                 "description": "Fix PHP 8 compatibility"
                             }
    @@ -4090,12 +4154,12 @@
                         "hash": "3285e37f0b32ba746ab47014a697ea90a981abfb",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "sunra/php-simple-html-dom-parser/minus-in-regular-expressions.patch",
                                 "description": "Fix minus in regular expressions"
                             },
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "sunra/php-simple-html-dom-parser/php8.1-compatibility.patch",
                                 "description": "Add PHP 8.1 compatibility"
                             }
    @@ -5834,12 +5898,12 @@
                         "hash": "c8ec7f9d85bbbe7d89dd1fe73ea17f64a508e3f9",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "tedivm/jshrink/fix-minifier-loop.patch",
                                 "description": "Fix continue switch in Minifier"
                             },
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "tedivm/jshrink/update-upstream-1.3.2.patch",
                                 "description": "Update to upstream version 1.3.2"
                             }
    @@ -5929,70 +5993,6 @@
                 ],
                 "time": "2017-04-23T17:16:57+00:00"
             },
    -        {
    -            "name": "theorchard/monolog-cascade",
    -            "version": "0.5.3",
    -            "source": {
    -                "type": "git",
    -                "url": "https://github.com/theorchard/monolog-cascade.git",
    -                "reference": "034f018e9814c7a057ad2f64242f25e07aaad174"
    -            },
    -            "dist": {
    -                "type": "zip",
    -                "url": "https://api.github.com/repos/theorchard/monolog-cascade/zipball/034f018e9814c7a057ad2f64242f25e07aaad174",
    -                "reference": "034f018e9814c7a057ad2f64242f25e07aaad174",
    -                "shasum": ""
    -            },
    -            "require": {
    -                "monolog/monolog": "^1.13",
    -                "php": "^5.3.9 || ^7.0",
    -                "symfony/config": "^2.7 || ^3.0 || ^4.0 || ^5.0",
    -                "symfony/options-resolver": "^2.7 || ^3.0 || ^4.0 || ^5.0",
    -                "symfony/serializer": "^2.7 || ^3.0 || ^4.0 || ^5.0",
    -                "symfony/yaml": "^2.7 || ^3.0 || ^4.0 || ^5.0"
    -            },
    -            "require-dev": {
    -                "mikey179/vfsstream": "^1.6",
    -                "php-coveralls/php-coveralls": "^1.0",
    -                "phpunit/phpcov": "^2.0",
    -                "phpunit/phpunit": "^4.8",
    -                "squizlabs/php_codesniffer": "^2.5"
    -            },
    -            "type": "library",
    -            "extra": {
    -                "branch-alias": {
    -                    "dev-master": "0.5.x-dev"
    -                }
    -            },
    -            "autoload": {
    -                "psr-4": {
    -                    "Cascade\\": "src/"
    -                }
    -            },
    -            "notification-url": "https://packagist.org/downloads/",
    -            "license": [
    -                "MIT"
    -            ],
    -            "authors": [
    -                {
    -                    "name": "Raphaël Antonmattei",
    -                    "email": "rantonmattei@theorchard.com",
    -                    "homepage": "https://github.com/rantonmattei"
    -                }
    -            ],
    -            "description": "Monolog extension to configure multiple loggers in the blink of an eye and access them from anywhere",
    -            "homepage": "https://github.com/theorchard/monolog-cascade",
    -            "keywords": [
    -                "cascade",
    -                "log",
    -                "logger",
    -                "monolog",
    -                "monolog extension",
    -                "monolog plugin",
    -                "monolog utils"
    -            ],
    -            "time": "2020-09-12T14:44:09+00:00"
    -        },
             {
                 "name": "true/punycode",
                 "version": "v2.1.1",
    @@ -6386,7 +6386,7 @@
                         "hash": "43aec57a6422c48c002031806c8a8ca01b6208cf",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "zendframework/zend-code/switch-continue.patch",
                                 "description": "Fix continue switch in FileGenerator and MethodReflection"
                             }
    @@ -6609,7 +6609,7 @@
                         "hash": "b1172d4359d8b116dcf51b148427b53e41d1ae2a",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "zendframework/zend-http/no-x-original-url-x-rewrite.patch",
                                 "description": "Remove support for the X-Original-Url and X-Rewrite-Url headers"
                             }
    @@ -6747,7 +6747,7 @@
                         "hash": "0dd457fc56b70541be390d5087fe4a4757e62d84",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "zendframework/zend-i18n/gettext-loader-php7.4.patch",
                                 "description": "Add support for PHP 7.4 to Gettext Loader"
                             }
    @@ -6859,12 +6859,17 @@
                         "config-provider": "Zend\\Mail\\ConfigProvider"
                     },
                     "patches_applied": {
    -                    "hash": "2eef0fc6a76db7a14e55b9d0717ffd9092bce7ce",
    +                    "hash": "61244db32f9890c1611bf06341862ee234960019",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "zendframework/zend-mail/fix-idn_to_ascii-deprecation-warning.patch",
                                 "description": "Fix idn_to_ascii deprecation warning"
    +                        },
    +                        {
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
    +                            "path": "zendframework/zend-mail/allow_any_TLS_version.patch",
    +                            "description": "Allow any TLS version"
                             }
                         ]
                     }
    @@ -7039,7 +7044,7 @@
                         "hash": "8d3d823a714a3a76d8a616d01922aa7491e13302",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "zendframework/zend-stdlib/ArrayObject-unserialize-continue.patch",
                                 "description": "Fix ArrayObject::unserialize()"
                             }
    @@ -7168,7 +7173,7 @@
                         "hash": "37d53a3bf7979793505330148334c2e97b2f7a0a",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "zendframework/zend-validator/fix-idn_to_-deprecation-warning.patch",
                                 "description": "Fix idn_to_ascii/idn_to_utf8 deprecation warning"
                             }
    @@ -8184,7 +8189,7 @@
                         "hash": "b230cedd50b7175de81a87b2ec86ad380ee748f9",
                         "list": [
                             {
    -                            "from-package": "concretecms/dependency-patches 1.7.6",
    +                            "from-package": "concretecms/dependency-patches 1.7.7",
                                 "path": "phpunit/phpunit/Getopt-each.patch",
                                 "description": "Avoid each() in Getopt"
                             }
    
  • concrete/composer.json+1 1 modified
    @@ -93,7 +93,7 @@
         "symfony/psr-http-message-bridge": "^1.0",
         "guzzlehttp/guzzle": "^6.3",
         "league/fractal": "^0.17.0",
    -    "theorchard/monolog-cascade": "0.5.*",
    +    "concretecms/monolog-cascade": "0.5.*",
         "commerceguys/addressing": "^1.0 <1.4",
         "enshrined/svg-sanitize": "^0.14.0",
         "concretecms/dependency-patches": "^1.7.2"
    
  • concrete/config/concrete.php+2 2 modified
    @@ -6,8 +6,8 @@
          *
          * @var string
          */
    -    'version' => '8.5.20',
    -    'version_installed' => '8.5.20',
    +    'version' => '8.5.21',
    +    'version_installed' => '8.5.21',
         'version_db' => '20240809105200', // the key of the latest database migration
     
         /*
    
  • concrete/src/Utility/Service/Url.php+40 3 modified
    @@ -5,16 +5,53 @@
     
     class Url
     {
    +    /**
    +     * @param string|string[] $variable
    +     * @param $value
    +     * @param string|bool $url
    +     * @return string
    +     */
         public function setVariable($variable, $value = false, $url = false)
         {
    -        // either it's key/value as variables, or it's an associative array of key/values
    +        // Minimal normalization for URLs that may be injected into HTML attributes.
    +        // We ONLY percent-encode quotes and strip CR/LF to close an XSS vector where
    +        // some call sites forget to escape with htmlspecialchars(). We do NOT HTML-escape
    +        // here to avoid double-encoding at render time (callers often use specialchars(..., false)).
    +        $encodeQuotesAndStripCRLF = static function ($s) {
    +            // Encode " and ' so they can't break out of href="...".
    +            // Remove \r and \n to prevent attribute splitting / header-style injection.
    +            return str_replace(['"', "'", "\r", "\n"], ['%22', '%27', '', ''], (string) $s);
    +        };
     
             if ($url == false) {
    +            // Use the current request as the base, but first strip any HTML-ish content.
    +            // sanitizeString() removes tags like <script>… and similar markup.
                 $url = Loader::helper('security')->sanitizeString($_SERVER['REQUEST_URI']);
    -        } elseif (!strstr($url, '?')) {
    -            $url = $url . '?' . Loader::helper('security')->sanitizeString(isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '');
    +            $url = $encodeQuotesAndStripCRLF($url);
    +        } elseif (strpos($url, '?') === false) {
    +            // Base URL provided without a query: protect it too (in case it contains quotes).
    +            $url = $encodeQuotesAndStripCRLF($url);
    +
    +            // Append the current query string, after sanitizing and applying the same light encoding.
    +            $qs = isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '';
    +            $qs = Loader::helper('security')->sanitizeString($qs);
    +            if ($qs !== false && $qs !== '') {
    +                $url .= '?' . $encodeQuotesAndStripCRLF($qs);
    +            }
             }
     
    +        /*
    +        Why this change?
    +        - Problem: In some places the resulting URL goes directly into HTML attributes (e.g., href="…")
    +          without being escaped, so a literal " or ' in the URL can break the attribute and enable XSS.
    +        - Fix (light touch for backward compatibility): Percent-encode only the characters that break out
    +          of attributes ( " → %22, ' → %27 ) and strip CR/LF. We do NOT HTML-escape here to avoid
    +          double-encoding at call sites that *do* properly escape with specialchars(..., false).
    +        - Explicit replacements: We target just quotes and CR/LF to minimize side effects on existing URLs.
    +          Broader normalization/validation (e.g., rebuilding the URL, enforcing schemes, or encoding more
    +          characters) could change behavior that callers rely on, so we intentionally keep it narrow.
    +        */
    +
             $vars = array();
             if (!is_array($variable)) {
                 $vars[$variable] = $value;
    
  • tests/tests/Utility/Service/UrlTest.php+159 0 added
    @@ -0,0 +1,159 @@
    +<?php
    +
    +namespace Concrete\Tests\Utility\Service;
    +class UrlTest extends \PHPUnit_Framework_TestCase
    +{
    +
    +    /** @var array */
    +    private $serverBackup;
    +
    +    protected function setUp()
    +    {
    +        parent::setUp();
    +        // Backup and seed $_SERVER
    +        $this->serverBackup = $_SERVER;
    +        $_SERVER['REQUEST_URI'] = '/list"items\'here?existing=1';
    +        $_SERVER['QUERY_STRING'] = 'foo="bar"&baz=\'qux\'';
    +    }
    +
    +    protected function tearDown()
    +    {
    +        $_SERVER = $this->serverBackup;
    +        parent::tearDown();
    +    }
    +
    +    /**
    +     * Helper: get the URL helper under test.
    +     */
    +    private function urlHelper()
    +    {
    +        // If your test bootstrap wires \Core::make, use that. Otherwise, new up your helper class directly.
    +        return \Core::make('helper/url');
    +    }
    +
    +    /**
    +     * It should use REQUEST_URI when $url == false and encode quotes / strip CRLF.
    +     */
    +    public function testUsesRequestUriWhenUrlIsFalseAndEncodesQuotes()
    +    {
    +        $uh = $this->urlHelper();
    +
    +        $out = $uh->setVariable([], false, false);
    +
    +        // REQUEST_URI contained both " and ' → must be percent-encoded
    +        $this->assertContains('/list%22items%27here', $out, 'Base path quotes must be encoded.');
    +        // Ensure no CR/LF made it through
    +        $this->assertNotContains("\r", $out);
    +        $this->assertNotContains("\n", $out);
    +    }
    +
    +    /**
    +     * When base URL has no '?', it must:
    +     *  - encode quotes in the base URL
    +     *  - sanitize and encode quotes in the query string before appending
    +     */
    +    public function testBaseUrlWithoutQueryGetsSanitizedQueryAppendedWithEncodedQuotes()
    +    {
    +        $uh = $this->urlHelper();
    +
    +        $_SERVER['QUERY_STRING'] = 'a="1"&b=\'2\''; // quotes to be encoded
    +
    +        $base = "/products/rock'n\"roll"; // both quotes in base URL
    +        $out  = $uh->setVariable([], false, $base);
    +
    +        // Base URL quotes encoded
    +        $this->assertContains('/products/rock%27n%22roll', $out);
    +
    +        // Query string appended and quotes encoded
    +        $this->assertContains('?a=%221%22&b=%272%27', $out);
    +
    +        // No CR/LF
    +        $this->assertNotContains("\r", $out);
    +        $this->assertNotContains("\n", $out);
    +    }
    +
    +    /**
    +     * It should not double-encode when the output is fed back into setVariable() again.
    +     */
    +    public function testReentryDoesNotDoubleEncode()
    +    {
    +        $uh = $this->urlHelper();
    +
    +        $_SERVER['QUERY_STRING'] = 'x="y"';
    +
    +        $base = '/path"quote\'apostrophe';
    +        $first = $uh->setVariable(['p' => 'v'], false, $base);
    +        // Feed the result back in (simulating re-entry)
    +        $second = $uh->setVariable(['p2' => 'v2'], false, $first);
    +
    +        // Quotes should appear encoded once, not as %2522 / %2527
    +        $this->assertContains('/path%22quote%27apostrophe', $second);
    +        $this->assertNotContains('%2522', $second, 'No double-encoding of %22.');
    +        $this->assertNotContains('%2527', $second, 'No double-encoding of %27.');
    +
    +        // Both parameter sets present
    +        $this->assertContains('p=v', $second);
    +        $this->assertContains('p2=v2', $second);
    +        $this->assertContains('x=%22y%22', $second);
    +    }
    +
    +    /**
    +     * If there is already a '?', the elseif branch does not run; verify we still
    +     * retain existing query and that added variables merge correctly (no duplicate encoding).
    +     */
    +    public function testExistingQueryIsPreservedAndMergedWithoutReencoding()
    +    {
    +        $uh = $this->urlHelper();
    +
    +        $in = '/search?q=rock%27n%22roll'; // already percent-encoded quotes
    +        $out = $uh->setVariable(['page' => '1'], false, $in);
    +
    +        // Existing encoding should not change (no %2527 / %2522)
    +        $this->assertContains('q=rock%27n%22roll', $out);
    +        $this->assertNotContains('%2527', $out);
    +        $this->assertNotContains('%2522', $out);
    +
    +        // New param merged
    +        $this->assertContains('page=1', $out);
    +    }
    +
    +    /**
    +     * Control characters in QUERY_STRING should be removed.
    +     */
    +    public function testControlCharsAreStripped()
    +    {
    +        $uh = $this->urlHelper();
    +
    +        $_SERVER['QUERY_STRING'] = "a=1\r\nb=2\"c'3";
    +        $out = $uh->setVariable([], false, '/x');
    +
    +        $this->assertNotContains("\r", $out);
    +        $this->assertNotContains("\n", $out);
    +        // Quotes encoded from the query string portion
    +        $this->assertContains('b=2%22c%273', $out);
    +    }
    +
    +    public function testSimpleUrlsBehaveNormally()
    +    {
    +        $uh = $this->urlHelper();
    +
    +        // 1) When $url == false, we use REQUEST_URI as-is (no quotes to encode)
    +        $_SERVER['REQUEST_URI'] = '/about';
    +        $_SERVER['QUERY_STRING'] = '';
    +        $out1 = $uh->setVariable([], false, false);
    +        $this->assertSame('/about', $out1, 'Plain REQUEST_URI should pass through unchanged.');
    +
    +        // 2) Base URL without "?" picks up QUERY_STRING as-is (ampersands retained; no HTML escaping here)
    +        $_SERVER['QUERY_STRING'] = 'page=2&sort=name';
    +        $out2 = $uh->setVariable([], false, '/shop');
    +        $this->assertSame('/shop?page=2&sort=name', $out2, 'Query string should be appended normally.');
    +
    +        // 3) Base URL with existing query keeps it; added vars merge in without re-encoding
    +        $in   = '/search?q=test';
    +        $out3 = $uh->setVariable(['page' => '1'], false, $in);
    +        $this->assertContains('/search?q=test', $out3, 'Existing query must be preserved.');
    +        $this->assertContains('page=1', $out3, 'New parameter should be merged in.');
    +        $this->assertNotContains('%2520', $out3, 'No double-encoding of percent sequences.');
    +    }
    +
    +}
    
f7630b467d3a

Merge pull request #12643 from aembler/fix-conversations-xss

https://github.com/concretecms/concretecmsAndrew EmblerAug 4, 2025via ghsa
4 files changed · +207 5
  • concrete/controllers/single_page/dashboard/users/search.php+1 1 modified
    @@ -678,7 +678,7 @@ private function getFolderList()
             $rows = $db->fetchAll("SELECT tn.treeNodeId, tn.treeNodeName FROM TreeNodes AS tn LEFT JOIN TreeNodeTypes AS tnt ON (tn.treeNodeTypeID = tnt.treeNodeTypeID) WHERE tnt.treeNodeTypeHandle = 'file_folder' AND tn.treeNodeName != ''");
     
             foreach ($rows as $row) {
    -            $folderList[$row["treeNodeId"]] = $row["treeNodeName"];
    +            $folderList[$row["treeNodeId"]] = h($row["treeNodeName"]);
             }
     
             return $folderList;
    
  • concrete/src/User/Search/ColumnSet/DefaultSet.php+2 1 modified
    @@ -48,7 +48,8 @@ public static function getFolderName($ui)
             $app = Application::getFacadeApplication();
             /** @var Connection $db */
             $db = $app->make(Connection::class);
    -        return (string)$db->fetchColumn("SELECT treeNodeName FROM TreeNodes WHERE treeNodeId = ? LIMIT 1", [$ui->getUserHomeFolderId()]);
    +        $folderName = (string)$db->fetchColumn("SELECT treeNodeName FROM TreeNodes WHERE treeNodeId = ? LIMIT 1", [$ui->getUserHomeFolderId()]);
    +        return h($folderName);
         }
     
         public function __construct()
    
  • concrete/src/Utility/Service/Url.php+40 3 modified
    @@ -5,16 +5,53 @@
     
     class Url
     {
    +    /**
    +     * @param string|string[] $variable
    +     * @param $value
    +     * @param string|bool $url
    +     * @return string
    +     */
         public function setVariable($variable, $value = false, $url = false)
         {
    -        // either it's key/value as variables, or it's an associative array of key/values
    +        // Minimal normalization for URLs that may be injected into HTML attributes.
    +        // We ONLY percent-encode quotes and strip CR/LF to close an XSS vector where
    +        // some call sites forget to escape with htmlspecialchars(). We do NOT HTML-escape
    +        // here to avoid double-encoding at render time (callers often use specialchars(..., false)).
    +        $encodeQuotesAndStripCRLF = static function ($s) {
    +            // Encode " and ' so they can't break out of href="...".
    +            // Remove \r and \n to prevent attribute splitting / header-style injection.
    +            return str_replace(['"', "'", "\r", "\n"], ['%22', '%27', '', ''], (string) $s);
    +        };
     
             if ($url == false) {
    +            // Use the current request as the base, but first strip any HTML-ish content.
    +            // sanitizeString() removes tags like <script>… and similar markup.
                 $url = Loader::helper('security')->sanitizeString($_SERVER['REQUEST_URI']);
    -        } elseif (!strstr($url, '?')) {
    -            $url = $url . '?' . Loader::helper('security')->sanitizeString(isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '');
    +            $url = $encodeQuotesAndStripCRLF($url);
    +        } elseif (strpos($url, '?') === false) {
    +            // Base URL provided without a query: protect it too (in case it contains quotes).
    +            $url = $encodeQuotesAndStripCRLF($url);
    +
    +            // Append the current query string, after sanitizing and applying the same light encoding.
    +            $qs = isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '';
    +            $qs = Loader::helper('security')->sanitizeString($qs);
    +            if ($qs !== false && $qs !== '') {
    +                $url .= '?' . $encodeQuotesAndStripCRLF($qs);
    +            }
             }
     
    +        /*
    +        Why this change?
    +        - Problem: In some places the resulting URL goes directly into HTML attributes (e.g., href="…")
    +          without being escaped, so a literal " or ' in the URL can break the attribute and enable XSS.
    +        - Fix (light touch for backward compatibility): Percent-encode only the characters that break out
    +          of attributes ( " → %22, ' → %27 ) and strip CR/LF. We do NOT HTML-escape here to avoid
    +          double-encoding at call sites that *do* properly escape with specialchars(..., false).
    +        - Explicit replacements: We target just quotes and CR/LF to minimize side effects on existing URLs.
    +          Broader normalization/validation (e.g., rebuilding the URL, enforcing schemes, or encoding more
    +          characters) could change behavior that callers rely on, so we intentionally keep it narrow.
    +        */
    +
             $vars = array();
             if (!is_array($variable)) {
                 $vars[$variable] = $value;
    
  • tests/tests/Utility/Service/UrlTest.php+164 0 added
    @@ -0,0 +1,164 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +namespace Concrete\Tests\Utility\Service;
    +
    +use Concrete\Tests\TestCase;
    +
    +class UrlTest extends TestCase
    +{
    +
    +    /** @var array */
    +    private $serverBackup;
    +
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +        // Backup and seed $_SERVER
    +        $this->serverBackup = $_SERVER;
    +        $_SERVER['REQUEST_URI'] = '/list"items\'here?existing=1';
    +        $_SERVER['QUERY_STRING'] = 'foo="bar"&baz=\'qux\'';
    +    }
    +
    +    protected function tearDown(): void
    +    {
    +        $_SERVER = $this->serverBackup;
    +        parent::tearDown();
    +    }
    +
    +    /**
    +     * Helper: get the URL helper under test.
    +     */
    +    private function urlHelper()
    +    {
    +        // If your test bootstrap wires \Core::make, use that. Otherwise, new up your helper class directly.
    +        return \Core::make('helper/url');
    +    }
    +
    +    /**
    +     * It should use REQUEST_URI when $url == false and encode quotes / strip CRLF.
    +     */
    +    public function testUsesRequestUriWhenUrlIsFalseAndEncodesQuotes(): void
    +    {
    +        $uh = $this->urlHelper();
    +
    +        $out = $uh->setVariable([], false, false);
    +
    +        // REQUEST_URI contained both " and ' → must be percent-encoded
    +        $this->assertStringContainsString('/list%22items%27here', $out, 'Base path quotes must be encoded.');
    +        // Ensure no CR/LF made it through
    +        $this->assertStringNotContainsString("\r", $out);
    +        $this->assertStringNotContainsString("\n", $out);
    +    }
    +
    +    /**
    +     * When base URL has no '?', it must:
    +     *  - encode quotes in the base URL
    +     *  - sanitize and encode quotes in the query string before appending
    +     */
    +    public function testBaseUrlWithoutQueryGetsSanitizedQueryAppendedWithEncodedQuotes(): void
    +    {
    +        $uh = $this->urlHelper();
    +
    +        $_SERVER['QUERY_STRING'] = 'a="1"&b=\'2\''; // quotes to be encoded
    +
    +        $base = "/products/rock'n\"roll"; // both quotes in base URL
    +        $out  = $uh->setVariable([], false, $base);
    +
    +        // Base URL quotes encoded
    +        $this->assertStringContainsString('/products/rock%27n%22roll', $out);
    +
    +        // Query string appended and quotes encoded
    +        $this->assertStringContainsString('?a=%221%22&b=%272%27', $out);
    +
    +        // No CR/LF
    +        $this->assertStringNotContainsString("\r", $out);
    +        $this->assertStringNotContainsString("\n", $out);
    +    }
    +
    +    /**
    +     * It should not double-encode when the output is fed back into setVariable() again.
    +     */
    +    public function testReentryDoesNotDoubleEncode(): void
    +    {
    +        $uh = $this->urlHelper();
    +
    +        $_SERVER['QUERY_STRING'] = 'x="y"';
    +
    +        $base = '/path"quote\'apostrophe';
    +        $first = $uh->setVariable(['p' => 'v'], false, $base);
    +        // Feed the result back in (simulating re-entry)
    +        $second = $uh->setVariable(['p2' => 'v2'], false, $first);
    +
    +        // Quotes should appear encoded once, not as %2522 / %2527
    +        $this->assertStringContainsString('/path%22quote%27apostrophe', $second);
    +        $this->assertStringNotContainsString('%2522', $second, 'No double-encoding of %22.');
    +        $this->assertStringNotContainsString('%2527', $second, 'No double-encoding of %27.');
    +
    +        // Both parameter sets present
    +        $this->assertStringContainsString('p=v', $second);
    +        $this->assertStringContainsString('p2=v2', $second);
    +        $this->assertStringContainsString('x=%22y%22', $second);
    +    }
    +
    +    /**
    +     * If there is already a '?', the elseif branch does not run; verify we still
    +     * retain existing query and that added variables merge correctly (no duplicate encoding).
    +     */
    +    public function testExistingQueryIsPreservedAndMergedWithoutReencoding(): void
    +    {
    +        $uh = $this->urlHelper();
    +
    +        $in = '/search?q=rock%27n%22roll'; // already percent-encoded quotes
    +        $out = $uh->setVariable(['page' => '1'], false, $in);
    +
    +        // Existing encoding should not change (no %2527 / %2522)
    +        $this->assertStringContainsString('q=rock%27n%22roll', $out);
    +        $this->assertStringNotContainsString('%2527', $out);
    +        $this->assertStringNotContainsString('%2522', $out);
    +
    +        // New param merged
    +        $this->assertStringContainsString('page=1', $out);
    +    }
    +
    +    /**
    +     * Control characters in QUERY_STRING should be removed.
    +     */
    +    public function testControlCharsAreStripped(): void
    +    {
    +        $uh = $this->urlHelper();
    +
    +        $_SERVER['QUERY_STRING'] = "a=1\r\nb=2\"c'3";
    +        $out = $uh->setVariable([], false, '/x');
    +
    +        $this->assertStringNotContainsString("\r", $out);
    +        $this->assertStringNotContainsString("\n", $out);
    +        // Quotes encoded from the query string portion
    +        $this->assertStringContainsString('b=2%22c%273', $out);
    +    }
    +
    +    public function testSimpleUrlsBehaveNormally(): void
    +    {
    +        $uh = $this->urlHelper();
    +
    +        // 1) When $url == false, we use REQUEST_URI as-is (no quotes to encode)
    +        $_SERVER['REQUEST_URI'] = '/about';
    +        $_SERVER['QUERY_STRING'] = '';
    +        $out1 = $uh->setVariable([], false, false);
    +        $this->assertSame('/about', $out1, 'Plain REQUEST_URI should pass through unchanged.');
    +
    +        // 2) Base URL without "?" picks up QUERY_STRING as-is (ampersands retained; no HTML escaping here)
    +        $_SERVER['QUERY_STRING'] = 'page=2&sort=name';
    +        $out2 = $uh->setVariable([], false, '/shop');
    +        $this->assertSame('/shop?page=2&sort=name', $out2, 'Query string should be appended normally.');
    +
    +        // 3) Base URL with existing query keeps it; added vars merge in without re-encoding
    +        $in   = '/search?q=test';
    +        $out3 = $uh->setVariable(['page' => '1'], false, $in);
    +        $this->assertStringContainsString('/search?q=test', $out3, 'Existing query must be preserved.');
    +        $this->assertStringContainsString('page=1', $out3, 'New parameter should be merged in.');
    +        $this->assertStringNotContainsString('%2520', $out3, 'No double-encoding of percent sequences.');
    +    }
    +
    +}
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.