CVE-2026-50635
Description
LimeSurvey's password reset feature is vulnerable to Host Header Injection, allowing attackers to hijack accounts by controlling the reset link's domain.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
LimeSurvey's password reset feature is vulnerable to Host Header Injection, allowing attackers to hijack accounts by controlling the reset link's domain.
Vulnerability
LimeSurvey versions prior to the fix in pull request #5032 are vulnerable to Host Header Injection. The application constructs password-reset links using the client-supplied HTTP Host header without proper validation. The allowedHosts allowlist, intended to constrain this, is not configured by default, rendering the LSHttpRequest::checkIsAllowedHost() check ineffective [3].
Exploitation
A remote, unauthenticated attacker can exploit this vulnerability by submitting a forgotten-password request for a known account. This requires only the target's username and email. The attacker spoofs the Host header in the request, causing LimeSurvey to send a password-reset link to the victim's email address. This link contains an attacker-controlled hostname but embeds the genuine validation key [3].
Impact
When the victim or an automated inbound mail-security link scanner clicks the malicious reset link, the valid reset token is disclosed to the attacker. The attacker can then replay this token against the legitimate host's newPassword endpoint to set a new password, effectively taking over the victim's account [3].
Mitigation
A fix for this vulnerability was released on June 9, 2026, as part of pull request #5032 [2]. Users should update to a patched version of LimeSurvey. No specific workarounds are mentioned in the available references, and there is no information regarding an EOL status or KEV listing at this time [1, 2, 3].
AI Insight generated on Jun 9, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2(expand)+ 1 more
- (no CPE)
- (no CPE)
Patches
1b394cb64328aFixed issue #20548: [security] Host header injection in password reset (#5032)
10 files changed · +598 −25
application/controllers/admin/Authentication.php+15 −0 modified@@ -175,6 +175,21 @@ public static function prepareLogin() $event->set('identity', $identity); App()->getPluginManager()->dispatchEvent($event); + // If allowed_hosts.php does not exist, write the current host as valid + $allowedHosts = App()->loadAllowedHosts(); + if (empty($allowedHosts)) { + $currentHost = App()->request->getServerName(); + if (App()->writeAllowedHosts([$currentHost])) { + Yii::app()->setFlashMessage( + sprintf( + gT('The allowed hosts file (application/config/allowed_hosts.php) has been created with "%s" as trusted host. For security reasons, LimeSurvey can only be accessed through that domain. If you need additional hosts, please edit the allowed hosts file directly.'), + htmlspecialchars($currentHost) + ), + 'info' + ); + } + } + return array('success'); } else { // Failed
application/core/ConsoleApplication.php+13 −0 modified@@ -76,6 +76,13 @@ public function __construct($aApplicationConfig = null) $lsConfig = array_merge($lsConfig, $securityConfig); } } + if (file_exists(__DIR__ . '/../config/allowed_hosts.php')) { + /** @psalm-suppress MissingFile file is auto-generated and may not exist in CI */ + $allowedHostsConfig = require(__DIR__ . '/../config/allowed_hosts.php'); + if (is_array($allowedHostsConfig)) { + $lsConfig = array_merge($lsConfig, $allowedHostsConfig); + } + } /* Custom config file */ $configdir = $coreConfig['configdir']; if (file_exists($configdir . '/security.php')) { @@ -84,6 +91,12 @@ public function __construct($aApplicationConfig = null) $lsConfig = array_merge($lsConfig, $securityConfig); } } + if (file_exists($configdir . '/allowed_hosts.php')) { + $allowedHostsConfig = require($configdir . '/allowed_hosts.php'); + if (is_array($allowedHostsConfig)) { + $lsConfig = array_merge($lsConfig, $allowedHostsConfig); + } + } if (file_exists(__DIR__ . '/../config/config.php')) { $userConfigs = require(__DIR__ . '/../config/config.php');
application/core/LimeMailer.php+34 −9 modified@@ -866,22 +866,47 @@ public function getTokenReplacements() $aTokenReplacements[strtoupper((string) $attribute)] = $value; } } - /* Set the minimal url and add it to Placeholders */ - $aTokenReplacements["OPTOUTURL"] = App()->getController() - ->createAbsoluteUrl("/optout/tokens", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + /* Set the minimal url and add it to Placeholders - use validated URLs to prevent host header injection */ + $aTokenReplacements["OPTOUTURL"] = App() + ->createValidatedAbsoluteUrl("/optout/tokens", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + if ($aTokenReplacements["OPTOUTURL"] === false) { + $aTokenReplacements["OPTOUTURL"] = App()->getController() + ->createAbsoluteUrl("/optout/tokens", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + } $this->addUrlsPlaceholders("OPTOUT"); - $aTokenReplacements["GLOBALOPTOUTURL"] = App()->getController() - ->createAbsoluteUrl("/optout/participants", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + $aTokenReplacements["GLOBALOPTOUTURL"] = App() + ->createValidatedAbsoluteUrl("/optout/participants", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + if ($aTokenReplacements["GLOBALOPTOUTURL"] === false) { + $aTokenReplacements["GLOBALOPTOUTURL"] = App()->getController() + ->createAbsoluteUrl("/optout/participants", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + } $this->addUrlsPlaceholders("GLOBALOPTOUT"); - $aTokenReplacements["OPTINURL"] = App()->getController() - ->createAbsoluteUrl("/optin/tokens", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + $aTokenReplacements["OPTINURL"] = App() + ->createValidatedAbsoluteUrl("/optin/tokens", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + if ($aTokenReplacements["OPTINURL"] === false) { + $aTokenReplacements["OPTINURL"] = App()->getController() + ->createAbsoluteUrl("/optin/tokens", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + } $this->addUrlsPlaceholders("OPTIN"); - $aTokenReplacements["GLOBALOPTINURL"] = App()->getController() - ->createAbsoluteUrl("/optin/participants", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + $aTokenReplacements["GLOBALOPTINURL"] = App() + ->createValidatedAbsoluteUrl("/optin/participants", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + if ($aTokenReplacements["GLOBALOPTINURL"] === false) { + $aTokenReplacements["GLOBALOPTINURL"] = App()->getController() + ->createAbsoluteUrl("/optin/participants", array("surveyid" => $this->surveyId, "token" => $token,"langcode" => $language)); + } $this->addUrlsPlaceholders("GLOBALOPTINURL"); $aTokenReplacements["SURVEYURL"] = $survey->getSurveyUrl($language, ["token" => $token]); + // Validate the survey URL host against allowed hosts to prevent host header injection + $parsedSurveyUrl = parse_url($aTokenReplacements["SURVEYURL"]); + if (isset($parsedSurveyUrl['host']) && !App()->isHostAllowed($parsedSurveyUrl['host'])) { + $aTokenReplacements["SURVEYURL"] = ''; + } $this->addUrlsPlaceholders("SURVEY"); $aTokenReplacements["SURVEYIDURL"] = $survey->getSurveyUrl($language, ["token" => $token], false); + $parsedSurveyIdUrl = parse_url($aTokenReplacements["SURVEYIDURL"]); + if (isset($parsedSurveyIdUrl['host']) && !App()->isHostAllowed($parsedSurveyIdUrl['host'])) { + $aTokenReplacements["SURVEYIDURL"] = ''; + } $this->addUrlsPlaceholders("SURVEYID"); return $aTokenReplacements; }
application/core/LSHttpRequest.php+9 −9 modified@@ -298,19 +298,19 @@ public function getHostInfo($schema = '') } /** - * Check if an url are in allowed host (if exist) - * @var string $hostInfo - * @throw Exception + * Check if a URL's host is in the allowed hosts list. + * Delegates to App()->isHostAllowed() which is lenient when + * allowed_hosts.php does not exist yet, and strict once configured. + * + * @param string $hostInfo The URL or host info to validate. + * @throws CHttpException if the host is not allowed. * @return void */ public static function checkIsAllowedHost($hostInfo) { - $allowedHosts = App()->getConfig('allowedHosts'); - if (!empty($allowedHosts) && is_array($allowedHosts)) { - $host = parse_url($hostInfo, PHP_URL_HOST); - if ($host && !in_array($host, $allowedHosts)) { - throw new CHttpException(400, gT("The requested hostname is invalid.", 'unescaped')); - } + $host = parse_url($hostInfo, PHP_URL_HOST); + if ($host && !App()->isHostAllowed($host)) { + throw new CHttpException(400, gT("The requested hostname is invalid.", 'unescaped')); } } }
application/core/LSYii_Application.php+6 −0 modified@@ -164,6 +164,12 @@ public function setConfigs() $this->config = array_merge($this->config, $securityConfig); } } + if (file_exists($configdir . '/allowed_hosts.php')) { + $allowedHostsConfig = require($configdir . '/allowed_hosts.php'); + if (is_array($allowedHostsConfig)) { + $this->config = array_merge($this->config, $allowedHostsConfig); + } + } if (file_exists($configdir . '/config.php')) { $userConfigs = require($configdir . '/config.php'); if (is_array($userConfigs['config'])) {
application/core/Traits/LSApplicationTrait.php+153 −0 modified@@ -75,4 +75,157 @@ public function getPublicBaseUrl($absolute = false) } return $baseUrl; } + + /** + * Creates an absolute URL that is validated against allowed hosts. + * This prevents host header injection attacks by ensuring the generated URL + * uses a trusted host from allowed_hosts.php or the configured publicurl. + * + * @param string $route the URL route. + * @param array $params additional GET parameters (name=>value). + * @param string $schema schema to use (e.g. http, https). + * @param string $ampersand the token separating name-value pairs in the URL. + * @return string|false the constructed URL with a validated host, or false if no trusted host is available. + */ + public function createValidatedAbsoluteUrl($route, $params = array(), $schema = '', $ampersand = '&') + { + // Use createPublicUrl which builds the URL from the configured publicurl, + // avoiding reliance on the potentially untrusted Host header. + try { + $url = $this->createPublicUrl($route, $params, $schema, $ampersand); + } catch (\CHttpException $e) { + // getHostInfo() may throw if the request host is not in allowed_hosts.php. + // Return false gracefully for email URL generation paths. + return false; + } + + $parsedUrl = parse_url($url); + if (!isset($parsedUrl['scheme']) || !isset($parsedUrl['host'])) { + return false; + } + + // Defense in depth: verify the host is in the allowlist + if ($this->isHostAllowed($parsedUrl['host'])) { + return $url; + } + + return false; + } + + /** + * Checks whether a given host name is in the allowed hosts list. + * Lenient when allowed_hosts.php does not exist yet (returns true). + * Once the file exists with entries, strictly enforces the allowlist. + * The host from publicurl (if configured) is always auto-included. + * + * @param string $host The host name to validate. + * @return bool True if the host is allowed, false otherwise. + */ + public function isHostAllowed($host) + { + $allowedHosts = $this->loadAllowedHosts(); + + // If no allowed_hosts.php is configured yet, be lenient (don't block). + if (empty($allowedHosts)) { + return true; + } + + // Normalize: strip IPv6 brackets, trim, lowercase + $host = strtolower(trim($host, " \t\n\r\0\x0B[]")); + + // publicurl host is always trusted + $publicUrl = Yii::app()->getConfig('publicurl'); + if (!empty($publicUrl)) { + $parsed = parse_url($publicUrl); + if (isset($parsed['host']) && strtolower(trim($parsed['host'], " \t\n\r\0\x0B[]")) === $host) { + return true; + } + } + + foreach ($allowedHosts as $allowed) { + if (strtolower(trim($allowed, " \t\n\r\0\x0B[]")) === $host) { + return true; + } + } + + return false; + } + + /** + * Loads the allowed hosts from the application config. + * The config key 'allowedHosts' is populated from application/config/allowed_hosts.php + * (loaded at application startup, same pattern as security.php). + * + * @return array List of allowed host names, or empty array if not configured. + */ + public function loadAllowedHosts() + { + $hosts = Yii::app()->getConfig('allowedHosts'); + if (is_array($hosts) && !empty($hosts)) { + return $hosts; + } + return []; + } + + /** + * Writes the allowed_hosts.php config file with the given hosts array. + * + * @param array $hosts Array of allowed domain names (no protocol, no port). + * @return bool True on success, false on failure. + */ + public function writeAllowedHosts(array $hosts) + { + // Sanitize: only allow valid domain names or IP addresses + $sanitized = []; + foreach ($hosts as $host) { + if (!is_string($host)) { + continue; + } + $host = trim($host); + if ( + filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false + || filter_var($host, FILTER_VALIDATE_IP) !== false + ) { + $sanitized[] = $host; + } + } + if (empty($sanitized)) { + return false; + } + + $filePath = Yii::app()->getConfig('configdir') . DIRECTORY_SEPARATOR . 'allowed_hosts.php'; + $content = "<?php if (!defined('BASEPATH')) exit('No direct script access allowed');\n" + . "/**\n" + . " * Allowed Hosts Configuration\n" + . " *\n" + . " * This file contains the list of trusted host names for this LimeSurvey\n" + . " * installation. Once this file exists with entries, any request whose\n" + . " * HTTP Host header does not match an entry here (or the configured publicurl)\n" + . " * will be rejected. This prevents host header injection attacks.\n" + . " *\n" + . " * Each entry should be a domain name only.\n" + . " * Do NOT include the protocol/scheme (http:// or https://) or port numbers.\n" + . " * The host from the 'publicurl' setting is always trusted implicitly.\n" + . " *\n" + . " * Examples:\n" + . " * 'example.com'\n" + . " * 'surveys.example.com'\n" + . " * 'localhost'\n" + . " *\n" + . " * This file is auto-generated on first admin login if it does not exist.\n" + . " * You may edit it manually to add or change allowed hosts.\n" + . " */\n" + . "\n\$hosts = [\n"; + foreach ($sanitized as $host) { + $content .= " " . var_export($host, true) . ",\n"; + } + $content .= "];\n" + . "\nreturn [ 'allowedHosts' => \$hosts ];\n"; + $result = @file_put_contents($filePath, $content); + if ($result !== false) { + // Update running config so the change takes effect immediately + Yii::app()->setConfig('allowedHosts', $sanitized); + } + return $result !== false; + } }
application/models/services/PasswordManagement.php+24 −7 modified@@ -40,11 +40,13 @@ public function generateAdminCreationEmail() { $adminEmail = []; $siteName = \Yii::app()->getConfig("sitename"); - /* Usage of Yii::app()->createAbsoluteUrl, disable publicurl, See mantis #19619 */ - $loginUrl = \Yii::app()->createAbsoluteUrl( + $loginUrl = \Yii::app()->createValidatedAbsoluteUrl( 'admin/authentication/sa/newPassword', ['param' => $this->user->validation_key] ); + if ($loginUrl === false) { + return false; + } $siteAdminEmail = \Yii::app()->getConfig("siteadminemail"); $emailSubject = \Yii::app()->getConfig("admincreationemailsubject"); $emailTemplate = \Yii::app()->getConfig("admincreationemailtemplate"); @@ -164,11 +166,14 @@ public function sendForgotPasswordEmailLink(): string $now = new DateTime(); $this->user->last_forgot_email_password = $now->format('Y-m-d H:i:s'); $this->user->save(); - /* Usage of Yii::app()->createAbsoluteUrl, disable publicurl, See mantis #19619 */ - $linkToResetPage = \Yii::app()->createAbsoluteUrl( + $linkToResetPage = \Yii::app()->createValidatedAbsoluteUrl( 'admin/authentication/sa/newPassword/', ['param' => $this->user->validation_key] ); + if ($linkToResetPage === false) { + $sMessage = gT('The system is not properly configured to send password reset emails. Please contact the administrator.'); + return $sMessage; + } $body = array(); $body[] = gT('You have requested to reset the password for your account.'); $body[] = sprintf(gT('To complete this process, please click on the following link: %s') . "\n", $linkToResetPage); @@ -220,6 +225,11 @@ private function sendAdminMail($type = self::EMAIL_TYPE_REGISTRATION): \LimeMail switch ($type) { case self::EMAIL_TYPE_RESET_PW: $renderArray = $this->getRenderArray(); + if (empty($renderArray)) { + $mailer = new \LimeMailer(); + $mailer->ErrorInfo = gT('The system is not properly configured to send emails. Please contact the administrator.'); + return $mailer; + } $subject = "[" . \Yii::app()->getConfig("sitename") . "] " . gT( "Your login credentials have been reset" ); @@ -233,6 +243,11 @@ private function sendAdminMail($type = self::EMAIL_TYPE_REGISTRATION): \LimeMail default: //Get email template from globalSettings $aAdminEmail = $this->generateAdminCreationEmail(); + if ($aAdminEmail === false) { + $mailer = new \LimeMailer(); + $mailer->ErrorInfo = gT('The system is not properly configured to send emails. Please contact the administrator.'); + return $mailer; + } $subject = $aAdminEmail["subject"]; $body = $aAdminEmail["body"]; break; @@ -256,12 +271,14 @@ private function sendAdminMail($type = self::EMAIL_TYPE_REGISTRATION): \LimeMail */ public function getRenderArray() { - /* Usage of Yii::app()->createAbsoluteUrl, disable publicurl, See mantis #19619 */ - $absoluteUrl = \Yii::app()->createAbsoluteUrl("/admin"); - $passwordResetUrl = \Yii::app()->createAbsoluteUrl( + $absoluteUrl = \Yii::app()->createValidatedAbsoluteUrl("/admin"); + $passwordResetUrl = \Yii::app()->createValidatedAbsoluteUrl( 'admin/authentication/sa/newPassword', ['param' => $this->user->validation_key] ); + if ($absoluteUrl === false || $passwordResetUrl === false) { + return []; + } return [ 'surveyapplicationname' => \Yii::app()->getConfig("sitename"), 'emailMessage' => sprintf(gT("Hello %s,"), $this->user->full_name) . "<br />"
application/views/admin/globalsettings/_security.php+20 −0 modified@@ -257,6 +257,26 @@ </div> </div> </div> + + <!-- Allowed Hosts --> + <div class="col-6 mt-4"> + <h3><?= gT('Allowed hosts (host header injection protection)') ?></h3> + <div class="mb-3"> + <p class="form-text"> + <?php eT("The following domain names are configured as trusted hosts. Requests from any other hostname will be rejected. The publicurl host is always trusted implicitly. This list is stored in application/config/allowed_hosts.php."); ?> + </p> + <?php + $allowedHosts = App()->loadAllowedHosts(); + if (!empty($allowedHosts)) : ?> + <textarea class="form-control" readonly rows="<?= min(count($allowedHosts), 5) ?>"><?= htmlspecialchars(implode("\n", $allowedHosts)) ?></textarea> + <?php else : ?> + <?php App()->getController()->widget('ext.AlertWidget.AlertWidget', [ + 'text' => gT("No allowed hosts configured. The file application/config/allowed_hosts.php does not exist or is empty. It will be auto-generated on the next admin login."), + 'type' => 'warning', + ]); ?> + <?php endif; ?> + </div> + </div> </div> </div>
.gitignore+1 −0 modified@@ -2,6 +2,7 @@ /application/config/config.php /application/config/config.*.php /application/config/security.php +/application/config/allowed_hosts.php # Don't include LS4 security.php
tests/unit/ValidatedAbsoluteUrlTest.php+323 −0 added@@ -0,0 +1,323 @@ +<?php + +namespace ls\tests; + +use Yii; + +/** + * Tests for the createValidatedAbsoluteUrl, getValidatedHost, + * loadAllowedHosts, and writeAllowedHosts methods in LSApplicationTrait. + * + * These tests verify the host header injection prevention mechanism. + */ +class ValidatedAbsoluteUrlTest extends TestBaseClass +{ + /** @var string|null */ + private static $originalPublicUrl; + + /** @var string Path to allowed_hosts.php */ + private static $allowedHostsFile; + + /** @var string|null Original content of allowed_hosts.php if it existed */ + private static $originalAllowedHostsContent; + + /** @var mixed Original allowedHosts config value */ + private static $originalAllowedHosts; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::$originalPublicUrl = Yii::app()->getConfig('publicurl'); + self::$originalAllowedHosts = Yii::app()->getConfig('allowedHosts'); + self::$allowedHostsFile = Yii::app()->getBasePath() . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'allowed_hosts.php'; + + // Backup existing allowed_hosts.php if present + if (file_exists(self::$allowedHostsFile)) { + self::$originalAllowedHostsContent = file_get_contents(self::$allowedHostsFile); + } + } + + public static function tearDownAfterClass(): void + { + // Restore publicurl and allowedHosts + Yii::app()->setConfig('publicurl', self::$originalPublicUrl); + Yii::app()->setConfig('allowedHosts', self::$originalAllowedHosts); + + // Restore allowed_hosts.php + if (self::$originalAllowedHostsContent !== null) { + file_put_contents(self::$allowedHostsFile, self::$originalAllowedHostsContent); + } elseif (file_exists(self::$allowedHostsFile)) { + unlink(self::$allowedHostsFile); + } + + parent::tearDownAfterClass(); + } + + protected function setUp(): void + { + parent::setUp(); + // Clean state before each test + if (file_exists(self::$allowedHostsFile)) { + unlink(self::$allowedHostsFile); + } + Yii::app()->setConfig('publicurl', ''); + Yii::app()->setConfig('allowedHosts', null); + } + + protected function tearDown(): void + { + // Clean up after each test + if (file_exists(self::$allowedHostsFile)) { + unlink(self::$allowedHostsFile); + } + Yii::app()->setConfig('publicurl', self::$originalPublicUrl); + Yii::app()->setConfig('allowedHosts', self::$originalAllowedHosts); + parent::tearDown(); + } + + // ------------------------------------------------------------------ + // loadAllowedHosts tests + // ------------------------------------------------------------------ + + /** + * Test that loadAllowedHosts returns empty array when config is not set. + */ + public function testLoadAllowedHostsReturnsEmptyWhenNotConfigured() + { + $hosts = Yii::app()->loadAllowedHosts(); + $this->assertIsArray($hosts); + $this->assertEmpty($hosts); + } + + /** + * Test that loadAllowedHosts returns the array from config. + */ + public function testLoadAllowedHostsReturnsArrayFromConfig() + { + Yii::app()->setConfig('allowedHosts', ['example.com', 'www.example.com']); + + $hosts = Yii::app()->loadAllowedHosts(); + $this->assertIsArray($hosts); + $this->assertCount(2, $hosts); + $this->assertSame('example.com', $hosts[0]); + $this->assertSame('www.example.com', $hosts[1]); + } + + /** + * Test that loadAllowedHosts returns empty array when config is empty array. + */ + public function testLoadAllowedHostsReturnsEmptyWhenConfigEmpty() + { + Yii::app()->setConfig('allowedHosts', []); + + $hosts = Yii::app()->loadAllowedHosts(); + $this->assertIsArray($hosts); + $this->assertEmpty($hosts); + } + + /** + * Test that loadAllowedHosts returns empty array when config is non-array. + */ + public function testLoadAllowedHostsReturnsEmptyWhenConfigNonArray() + { + Yii::app()->setConfig('allowedHosts', 'not an array'); + + $hosts = Yii::app()->loadAllowedHosts(); + $this->assertIsArray($hosts); + $this->assertEmpty($hosts); + } + + // ------------------------------------------------------------------ + // writeAllowedHosts tests + // ------------------------------------------------------------------ + + /** + * Test that writeAllowedHosts creates the file with correct content. + */ + public function testWriteAllowedHostsCreatesFile() + { + $hosts = ['mysite.com', 'admin.mysite.com']; + $result = Yii::app()->writeAllowedHosts($hosts); + + $this->assertTrue($result); + $this->assertFileExists(self::$allowedHostsFile); + + // Verify the file is valid PHP and returns a config array with allowedHosts + $loaded = require(self::$allowedHostsFile); + $this->assertIsArray($loaded); + $this->assertArrayHasKey('allowedHosts', $loaded); + $this->assertSame($hosts, $loaded['allowedHosts']); + } + + /** + * Test that writeAllowedHosts overwrites existing file. + */ + public function testWriteAllowedHostsOverwritesExistingFile() + { + Yii::app()->writeAllowedHosts(['old.example.com']); + Yii::app()->writeAllowedHosts(['new.example.com']); + + $loaded = require(self::$allowedHostsFile); + $this->assertSame(['new.example.com'], $loaded['allowedHosts']); + } + + // ------------------------------------------------------------------ + // isHostAllowed tests + // ------------------------------------------------------------------ + + /** + * Test that isHostAllowed returns true when host is in allowed_hosts.php. + */ + public function testIsHostAllowedReturnsTrueForAllowedHost() + { + Yii::app()->writeAllowedHosts(['trusted.example.com', 'also-trusted.example.com']); + + $this->assertTrue(Yii::app()->isHostAllowed('trusted.example.com')); + $this->assertTrue(Yii::app()->isHostAllowed('also-trusted.example.com')); + } + + /** + * Test that isHostAllowed returns false for a host not in the list. + */ + public function testIsHostAllowedReturnsFalseForUnknownHost() + { + Yii::app()->writeAllowedHosts(['trusted.example.com']); + + $this->assertFalse(Yii::app()->isHostAllowed('attacker.com')); + } + + /** + * Test that isHostAllowed is case-insensitive. + */ + public function testIsHostAllowedIsCaseInsensitive() + { + Yii::app()->writeAllowedHosts(['Example.COM']); + + $this->assertTrue(Yii::app()->isHostAllowed('example.com')); + $this->assertTrue(Yii::app()->isHostAllowed('EXAMPLE.COM')); + } + + /** + * Test that isHostAllowed is lenient (returns true) when no file exists and publicurl is set. + */ + public function testIsHostAllowedIsLenientWhenNoFileExists() + { + Yii::app()->setConfig('publicurl', 'https://public.example.com/limesurvey'); + + $this->assertTrue(Yii::app()->isHostAllowed('public.example.com')); + $this->assertTrue(Yii::app()->isHostAllowed('other.example.com')); + } + + /** + * Test that isHostAllowed is lenient (returns true) when no source is configured. + */ + public function testIsHostAllowedIsLenientWhenNoSource() + { + Yii::app()->setConfig('publicurl', ''); + + $this->assertTrue(Yii::app()->isHostAllowed('anything.com')); + } + + /** + * Test that isHostAllowed is lenient (returns true) when publicurl has no valid host. + */ + public function testIsHostAllowedIsLenientForInvalidPublicUrl() + { + Yii::app()->setConfig('publicurl', '/relative/path'); + + $this->assertTrue(Yii::app()->isHostAllowed('localhost')); + } + + // ------------------------------------------------------------------ + // createValidatedAbsoluteUrl tests + // ------------------------------------------------------------------ + + /** + * Test that createValidatedAbsoluteUrl is lenient (returns URL) when no file exists. + */ + public function testCreateValidatedAbsoluteUrlIsLenientWhenNoFile() + { + Yii::app()->setConfig('publicurl', ''); + + $url = Yii::app()->createValidatedAbsoluteUrl('admin/authentication/sa/newPassword', ['param' => 'abc123']); + $this->assertIsString($url); + $this->assertStringContainsString('abc123', $url); + } + + /** + * Test that createValidatedAbsoluteUrl returns the URL when host is allowed. + */ + public function testCreateValidatedAbsoluteUrlReturnsUrlWhenHostAllowed() + { + // In test environment, createAbsoluteUrl generates URLs with 'localhost' + Yii::app()->writeAllowedHosts(['localhost']); + + $url = Yii::app()->createValidatedAbsoluteUrl('admin/authentication/sa/newPassword', ['param' => 'testkey123']); + + $this->assertIsString($url); + $this->assertStringContainsString('localhost', $url); + $this->assertStringContainsString('testkey123', $url); + } + + /** + * Test that createValidatedAbsoluteUrl uses publicurl for URL construction. + */ + public function testCreateValidatedAbsoluteUrlUsesPublicUrl() + { + // Set publicurl to a distinct host that differs from the default test host + Yii::app()->setConfig('publicurl', 'https://example.test/limesurvey'); + + $url = Yii::app()->createValidatedAbsoluteUrl('admin/authentication/sa/newPassword', ['param' => 'key456']); + + $this->assertIsString($url); + $this->assertStringContainsString('example.test', $url); + $this->assertStringContainsString('key456', $url); + $this->assertStringNotContainsString('localhost', $url); + } + + /** + * Test that createValidatedAbsoluteUrl returns false when host is NOT in allowed list. + */ + public function testCreateValidatedAbsoluteUrlReturnsFalseWhenHostNotAllowed() + { + // Only allow a host that doesn't match the test environment + Yii::app()->writeAllowedHosts(['production.example.com']); + // Clear publicurl so localhost is not auto-trusted + Yii::app()->setConfig('publicurl', ''); + + $url = Yii::app()->createValidatedAbsoluteUrl('admin/authentication/sa/login'); + + $this->assertFalse($url); + } + + /** + * Test that createValidatedAbsoluteUrl preserves the route and params. + */ + public function testCreateValidatedAbsoluteUrlPreservesRouteAndParams() + { + Yii::app()->writeAllowedHosts(['localhost']); + + $url = Yii::app()->createValidatedAbsoluteUrl( + 'admin/authentication/sa/newPassword', + ['param' => 'validation_key_xyz', 'extra' => 'value'] + ); + + $this->assertIsString($url); + $this->assertStringContainsString('validation_key_xyz', $url); + $this->assertStringContainsString('extra', $url); + $this->assertStringContainsString('value', $url); + } + + /** + * Test that createValidatedAbsoluteUrl allows multiple hosts in the filter. + */ + public function testCreateValidatedAbsoluteUrlAllowsMultipleHosts() + { + Yii::app()->writeAllowedHosts(['production.example.com', 'localhost']); + + $url = Yii::app()->createValidatedAbsoluteUrl('admin/authentication/sa/login'); + + $this->assertIsString($url); + $this->assertStringContainsString('localhost', $url); + } +}
Vulnerability mechanics
No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.
References
3News mentions
0No linked articles in our index yet.