CVE-2024-23830 (published 2024-02-20T22:15Z)

MantisBT is an open source issue tracker. Prior to version 2.26.1, an unauthenticated attacker who knows a user's email address and username can hijack the user's account by poisoning the link in the password reset notification message. A patch is available in version 2.26.1. As a workaround, define `$g_path` as appropriate in `config_inc.php`.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/mantisbt/mantisbt
commit_details: repo_url: https://github.com/mantisbt/mantisbt
author: Damien Regad
author_date: 2024-02-20 22:10:20+01:00
msg: Merge pull request from GHSA-mcqj-7p29-9528

* Address host header injection vulnerability

$g_path is empty by default, and should be defined in config_inc.php.
Not doing so is a security risk, as the path will then be set based on
headers from the HTTP request, exposing the system to Host Header
injection attacks.

Document the risk in PHPDoc and Admin Guide.

Move the code that initializes $g_path's default value from
config_defaults_inc.php to a function in core.php.

Detect if $g_path was defaulted, and if yes alert the user in:
- Login Page (if $g_admin_checks == ON)
- Admin Checks

Fixes #19381, CVE-2024-23830, GHSA-mcqj-7p29-9528

* Remove dead code

* Use OWASP as reference for host header injection

* Link to OWASP reference page from admin guide

* Invalid $g_path at install time is a hard fail

Empty $g_path remains just a warning about the security risk.
Request and set $g_path at install time

This is an improvement on the original patch for CVE-2024-23830.

The admin is now able to set $g_path when installing MantisBT. A default
value is provided, based on the URL used to perform the installation
(using the same logic that is applied when $g_path is empty).

A check of the provided URL is performed during install stage 2, and an
error is reported if it is invalid. If an empty $g_path is given, then
we only display a warning about the security risk.

The URL is then stored as $g_path in the generated config_inc.php file
at stage 5.

This greatly reduces the risk of the admin forgetting to set $g_path
manually, while still allowing them to set it to empty should they want
to.

Fixes #19381

* Add Reset button to path input

Reuse the existing functionality implemented for database prefix/suffix,
with the following changes

- Rename `reset-prefix` selector class to `reset` to be more generic
- Add Reset functionality markup to path input including default value
- Add title attribute to Reset buttons
- Adapt initialization logic to only set the default value for the
  table-prefix fields
repository: repo_url: https://github.com/mantisbt/mantisbt
repo_name: mantisbt/mantisbt
description: Mantis Bug Tracker (MantisBT)
repo_language: PHP
owner: mantisbt
file_changes: filename: check_paths_inc.php
change_type: ModificationType.MODIFY
diff: @@ -36,6 +36,18 @@ require_api( 'config_api.php' );
 
 check_print_section_header_row( 'Paths' );
 
+global $g_defaulted_path;
+const HOST_HEADER_INJECTION_URL = 'https://owasp.org/www-project-web-security-testing-guide/stable/4-Web_Application_Security_Testing/07-Input_Validation_Testing/17-Testing_for_Host_Header_Injection';
+check_print_test_warn_row(
+	'"path" is defined in config_inc.php.',
+	!$g_defaulted_path,
+	array( false =>
+		'Leaving it empty is a security risk, as the path will be set based on '
+		. 'headers from the HTTP request, exposing your system to '
+		. '<a href="' . HOST_HEADER_INJECTION_URL . '">Host Header Injection attacks</a>.'
+	)
+);
+
 $t_path_config_names = array(
 	'absolute_path',
 	'core_path',

programming_language: PHP
filename: install.php
change_type: ModificationType.MODIFY
diff: @@ -240,6 +240,7 @@ if( $t_config_exists && $t_install_state <= 1 ) {
 	$f_db_username            = config_get_global( 'db_username', '' );
 	$f_db_password            = config_get_global( 'db_password', '' );
 	$f_timezone               = config_get( 'default_timezone', '' );
+	$f_path                   = config_get_global( 'path', '' );
 
 	# Set default prefix/suffix form variables ($f_db_table_XXX)
 	$t_prefix_type = 'other';
@@ -258,6 +259,7 @@ if( $t_config_exists && $t_install_state <= 1 ) {
 		$f_db_password = config_get_global( 'db_password' );
 	}
 	$f_timezone           = gpc_get( 'timezone', config_get( 'default_timezone' ) );
+	$f_path               = gpc_get( 'path', config_get_global( 'path', '' ) );
 
 	# Set default prefix/suffix form variables ($f_db_table_XXX)
 	$t_prefix_type = $f_db_type == 'oci8' ? $f_db_type : 'other';
@@ -471,6 +473,40 @@ if( 2 == $t_install_state ) {
 	}
 	?>
 </tr>
+<tr>
+	<td>
+		Checking URL to installation
+	</td>
+	<?php
+		$t_url_check = '';
+		if( !$f_path ) {
+			# Empty URL - warn admin about security risk
+			$t_url_check = "Using an empty path is a security risk, as MantisBT "
+				. "will dynamically set it based on headers from the HTTP request, "
+				. "exposing your system to Host Header Injection attacks.";
+			$t_hard_fail = false;
+		} else {
+			# Make sure we have a trailing '/'
+			$f_path = rtrim( $f_path, '/' ) . '/';
+
+			# Check that the URL is valid
+			if( !filter_var( $f_path, FILTER_VALIDATE_URL ) ) {
+				$t_url_check = "'$f_path' is not a valid URL.";
+			} else {
+				require_api( 'url_api.php' );
+				$t_page_contents = url_get( $f_path );
+				if( !$t_page_contents ) {
+					$t_url_check = "Can't retrieve web page at '$f_path'.";
+				} elseif( false === strpos( $t_page_contents, 'MantisBT') ) {
+					$t_url_check = "Web page at '$f_path' does not appear to be a MantisBT site.";
+				}
+			}
+			$t_hard_fail = true;
+		}
+
+		print_test_result( $t_url_check ? BAD : GOOD, $t_hard_fail, $t_url_check );
+	?>
+</tr>
 <?php
 	if( $f_db_exists ) {
 		?>
@@ -724,13 +760,13 @@ if( !$g_database_upgrade ) {
 		echo "\t</td>\n\t<td>\n\t\t";
 		$t_required = $t_key == 'db_table_plugin_prefix' ? 'required' : '';
 		/** @noinspection HtmlUnknownAttribute */
-		printf( '<input id="%1$s" name="%1$s" type="text" class="table-prefix" %2$s value="%3$s">',
+		printf( '<input id="%1$s" name="%1$s" type="text" class="table-prefix reset" %2$s value="%3$s">',
 			$t_key,
 			$t_key == 'db_table_plugin_prefix' ? 'required' : '',
 			${'f_' . $t_key} // The actual value of the corresponding form variable
 		);
 		echo "\n&nbsp;";
-		printf( '<button id="%s" type="button" class="btn btn-sm btn-primary btn-white btn-round reset-prefix">%s</button>',
+		printf( '<button id="%s" type="button" class="btn btn-sm btn-primary btn-white btn-round reset">%s</button>',
 			"btn_$t_key",
 			lang_get( 'reset' )
 		);
@@ -765,6 +801,27 @@ if( !$g_database_upgrade ) {
 		</select>
 	</td>
 </tr>
+
+<!-- URL to installation ($g_path) -->
+<tr>
+	<td>
+		<label for="path">URL to your installation (<em>$g_path</em>, see
+			<a href="https://mantisbt.org/docs/master/en-US/Admin_Guide/html-desktop/#admin.config.path"
+			   target="_blank">documentation</a>)
+		</label>
+	</td>
+	<td>
+		<input id="path" name="path" type="text" size="50" class="reset"
+			   value="<?php echo string_attribute( $f_path ) ?>"
+			   data-defval="<?php global $g_path; echo string_attribute( $g_path ); ?>"
+		/>
+
+		<button id="btn_path" type="button" class="btn btn-sm btn-primary btn-white btn-round reset">
+			<?php echo lang_get( 'reset' ); ?>
+		</button>
+	</td>
+</tr>
+
 <?php
 } # end install-only fields
 ?>
@@ -1324,6 +1381,11 @@ if( 5 == $t_install_state ) {
 		. (!$t_crypto_master_salt ? "# The installer could not generate the Master Salt; please set it manually.\n" : '')
 		. "\$g_crypto_master_salt     = '" . addslashes( $t_crypto_master_salt ) . "';" . PHP_EOL;
 
+	if( $f_path ) {
+		$t_config .= PHP_EOL
+			. "\$g_path                   = '" . addslashes( $f_path ) . "';" . PHP_EOL;
+	}
+
 	$t_write_failed = true;
 
 	if( !$t_config_exists ) {

programming_language: PHP
filename: config_defaults_inc.php
change_type: ModificationType.MODIFY
diff: @@ -194,83 +194,34 @@ unset( $t_local_config );
 # MantisBT Path Settings #
 ##########################
 
-$t_protocol = 'http';
-$t_host = 'localhost';
-if( isset ( $_SERVER['SCRIPT_NAME'] ) ) {
-	$t_protocol = http_is_protocol_https() ? 'https' : 'http';
-
-	# $_SERVER['SERVER_PORT'] is not defined in case of php-cgi.exe
-	if( isset( $_SERVER['SERVER_PORT'] ) ) {
-		$t_port = ':' . $_SERVER['SERVER_PORT'];
-		if( ( ':80' == $t_port && 'http' == $t_protocol )
-		  || ( ':443' == $t_port && 'https' == $t_protocol )) {
-			$t_port = '';
-		}
-	} else {
-		$t_port = '';
-	}
-
-	if( isset( $_SERVER['HTTP_X_FORWARDED_HOST'] ) ) { # Support ProxyPass
-		$t_hosts = explode( ',', $_SERVER['HTTP_X_FORWARDED_HOST'] );
-		$t_host = $t_hosts[0];
-	} else if( isset( $_SERVER['HTTP_HOST'] ) ) {
-		$t_host = $_SERVER['HTTP_HOST'];
-	} else if( isset( $_SERVER['SERVER_NAME'] ) ) {
-		$t_host = $_SERVER['SERVER_NAME'] . $t_port;
-	} else if( isset( $_SERVER['SERVER_ADDR'] ) ) {
-		$t_host = $_SERVER['SERVER_ADDR'] . $t_port;
-	}
-
-	if( !isset( $_SERVER['SCRIPT_NAME'] )) {
-		echo 'Invalid server configuration detected. Please set $g_path manually in ' . $g_config_path . 'config_inc.php.';
-		if( isset( $_SERVER['SERVER_SOFTWARE'] ) && ( stripos($_SERVER['SERVER_SOFTWARE'], 'nginx') !== false ) )
-			echo ' Please try to add "fastcgi_param SCRIPT_NAME $fastcgi_script_name;" to the nginx server configuration.';
-		die;
-	}
-
-	# Prevent XSS if the path is displayed later on. This is the equivalent of
-	# FILTER_SANITIZE_STRING, which was deprecated in PHP 8.1:
-	# strip tags and null bytes, then encode quotes into HTML entities
-	$t_path = preg_replace( '/\x00|<[^>]*>?/', '', $_SERVER['SCRIPT_NAME'] );
-	$t_path = str_replace( ["'", '"'], ['&#39;', '&#34;'], $t_path );
-
-	$t_path = dirname( $t_path );
-	switch( basename( $t_path ) ) {
-		case 'admin':
-			$t_path = dirname( $t_path );
-			break;
-		case 'check':		# admin checks dir
-		case 'soap':
-		case 'rest':
-			$t_path = dirname( $t_path, 2 );
-			break;
-		case 'swagger':
-			$t_path = dirname( $t_path, 3 );
-			break;
-	}
-	$t_path = rtrim( $t_path, '/\\' ) . '/';
-
-	if( strpos( $t_path, '&#' ) ) {
-		echo 'Can not safely determine $g_path. Please set $g_path manually in ' . $g_config_path . 'config_inc.php';
-		die;
-	}
-} else {
-	$t_path = 'mantisbt/';
-}
-
 /**
- * Path to your installation as seen from the web browser
- * requires trailing /
+ * Full URL to your installation as seen from the web browser.
+ *
+ * Requires trailing `/`.
+ *
+ * If not set, MantisBT will default this to a working URL valid for most
+ * installations.
+ *
+ * WARNING: The default is built based on headers from the HTTP request
+ * ({@see set_default_path()} in core.php). This is a potential security risk,
+ * as the system will be exposed to Host Header injection attacks, so it is
+ * strongly recommended to initialize this in config_inc.php.
+ *
  * @global string $g_path
  */
-$g_path	= $t_protocol . '://' . $t_host . $t_path;
+$g_path	= '';
 
 /**
- * Short web path without the domain name
- * requires trailing /
+ * Short web path without the domain name.
+ *
+ * requires trailing `/`.
+ *
+ * This is defined by MantisBT core based on the script being executed, and
+ * should not be set in config_inc.php.
+ *
  * @global string $g_short_path
  */
-$g_short_path = $t_path;
+$g_short_path = '';
 
 /**
  * Used to link to manual for User Documentation.
@@ -5052,10 +5003,6 @@ $g_public_config_names = array(
 	'wrap_in_preformatted_text'
 );
 
-# Temporary variables should not remain defined in global scope
-unset( $t_protocol, $t_host, $t_hosts, $t_port, $t_self, $t_path );
-
-
 ############################
 # Webservice Configuration #
 ############################

programming_language: PHP
filename: core.php
change_type: ModificationType.MODIFY
diff: @@ -99,11 +99,16 @@ if( file_exists( $g_config_path . 'custom_constants_inc.php' ) ) {
 
 # config_inc may not be present if this is a new install
 $t_config_inc_found = file_exists( $g_config_path . 'config_inc.php' );
-
 if( $t_config_inc_found ) {
 	require_once( $g_config_path . 'config_inc.php' );
 }
 
+# Set global path variables
+# NOTE: We store whether $g_path was defaulted, so we can later warn the admin
+# about the exposure to host header injection attacks if they didn't set it.
+global $g_defaulted_path;
+$g_defaulted_path = set_default_path();
+
 # Ensure PHP LDAP extension is available when Login Method is LDAP
 global $g_login_method;
 if ( $g_login_method == LDAP ) {
@@ -306,6 +311,89 @@ function http_is_protocol_https() {
 	return false;
 }
 
+/**
+ * Set Global Path variables.
+ *
+ * @see $g_path
+ * @see $g_short_path
+ *
+ * @return bool True if default $g_path was assigned, false if it was already set.
+ */
+function set_default_path() {
+	global $g_path, $g_short_path, $g_config_path;
+
+	# $g_path is set in config_inc.php
+	if( $g_path ) {
+		# Derive $g_short_path from $g_path if not set
+		if( !$g_short_path ) {
+			$g_short_path = parse_url( $g_path, PHP_URL_PATH );
+		}
+		return false;
+	}
+
+	$t_protocol = 'http';
+	$t_host = 'localhost';
+	if( isset( $_SERVER['SCRIPT_NAME'] ) ) {
+		$t_protocol = http_is_protocol_https() ? 'https' : 'http';
+
+		# $_SERVER['SERVER_PORT'] is not defined in case of php-cgi.exe
+		if( isset( $_SERVER['SERVER_PORT'] ) ) {
+			$t_port = ':' . $_SERVER['SERVER_PORT'];
+			if( ( ':80' == $t_port && 'http' == $t_protocol )
+				|| ( ':443' == $t_port && 'https' == $t_protocol )) {
+				$t_port = '';
+			}
+		} else {
+			$t_port = '';
+		}
+
+		if( isset( $_SERVER['HTTP_X_FORWARDED_HOST'] ) ) { # Support ProxyPass
+			$t_hosts = explode( ',', $_SERVER['HTTP_X_FORWARDED_HOST'] );
+			$t_host = $t_hosts[0];
+		} else if( isset( $_SERVER['HTTP_HOST'] ) ) {
+			$t_host = $_SERVER['HTTP_HOST'];
+		} else if( isset( $_SERVER['SERVER_NAME'] ) ) {
+			$t_host = $_SERVER['SERVER_NAME'] . $t_port;
+		} else if( isset( $_SERVER['SERVER_ADDR'] ) ) {
+			$t_host = $_SERVER['SERVER_ADDR'] . $t_port;
+		}
+
+		# Prevent XSS if the path is displayed later on. This is the equivalent of
+		# FILTER_SANITIZE_STRING, which was deprecated in PHP 8.1:
+		# strip tags and null bytes, then encode quotes into HTML entities
+		$t_path = preg_replace( '/\x00|<[^>]*>?/', '', $_SERVER['SCRIPT_NAME'] );
+		$t_path = str_replace( ["'", '"'], ['&#39;', '&#34;'], $t_path );
+
+		$t_path = dirname( $t_path );
+		switch( basename( $t_path ) ) {
+			case 'admin':
+				$t_path = dirname( $t_path );
+				break;
+			case 'check': # admin checks dir
+			case 'soap':
+			case 'rest':
+				$t_path = dirname( $t_path, 2 );
+				break;
+			case 'swagger':
+				$t_path = dirname( $t_path, 3 );
+				break;
+		}
+		$t_path = rtrim( $t_path, '/\\' ) . '/';
+
+		if( strpos( $t_path, '&#' ) ) {
+			echo 'Can not safely determine $g_path. Please set $g_path manually in ' . $g_config_path . 'config_inc.php';
+			die;
+		}
+	} else {
+		$t_path = 'mantisbt/';
+	}
+
+	$g_path	= $t_protocol . '://' . $t_host . $t_path;
+	$g_short_path = $t_path;
+
+	return true;
+}
+
 /**
  * Define an autoload function to automatically load classes when referenced
  *

programming_language: PHP
filename: path.xml
change_type: ModificationType.MODIFY
diff: @@ -13,13 +13,29 @@
 		<varlistentry>
 			<term>$g_path</term>
 			<listitem>
-				<para>URL to your installation as seen from the web browser; this
-					is what you type into the URL field. Requires trailing '/'
-					character. eg. 'https://www.example.com/mantisbt/'.  MantisBT will default this to
-					the correct value. However, in some cases it might be necessary to
-					override the default.  This is typically needed when an installation can be
-					accessed by multiple URLs (internal vs external).
+				<para>
+					Full URL to your installation as seen from the web browser.
 				</para>
+				<para>
+					This is what users type into the URL field, e.g.
+					<literal>https://www.example.com/mantisbt/</literal>.
+					Requires trailing `/`.
+				</para>
+				<para>
+					If not set, MantisBT will default this to a working URL valid
+					for most installations. However, in some cases (typically when
+					an installation can be accessed by multiple URLs, e.g. internal
+					vs external), it might be necessary to override the default.
+				</para>
+				<warning><para>
+					The default is built based on headers from the HTTP request.
+					This is a potential security risk, as the system will be exposed to
+					<ulink url="https://owasp.org/www-project-web-security-testing-guide/stable/4-Web_Application_Security_Testing/07-Input_Validation_Testing/17-Testing_for_Host_Header_Injection"
+					>Host Header injection</ulink>
+					attacks, so it is
+					strongly recommended to initialize this in config_inc.php.
+					</para>
+				</warning>
 			</listitem>
 		</varlistentry>
 		<varlistentry>

programming_language: HTML
filename: install.js
change_type: ModificationType.MODIFY
diff: @@ -70,15 +70,22 @@ function PrefixInput (inputId) {
 	};
 }
 
-var reset_buttons = $('button.reset-prefix');
+var reset_buttons = $('button.reset').each( function () {
+	this.title = "Reset the field to its default value";
+});
 
 /**
  * Initialize all input's default values and disable the reset buttons
  */
-var inputs = $('input.table-prefix').each(function () {
+$('input.table-prefix').each(function () {
 	var input = new PrefixInput(this.id);
 	input.setDefault(input.getValue());
-	input.disableButton();
+});
+var inputs = $('input.reset').each(function () {
+	var input = new PrefixInput(this.id);
+	if (input.isValueDefault()) {
+		input.disableButton();
+	}
 });
 
 /**
@@ -133,7 +140,7 @@ inputs.on('input', function () {
  * Buttons to reset the prefix/suffix to the current default value
  */
 reset_buttons.on('click', function () {
-	var input = new PrefixInput($(this).prev('input.table-prefix')[0].id);
+	var input = new PrefixInput($(this).prev('input.reset')[0].id);
 	input.resetValue();
 	update_sample_table_names();
 });

programming_language: JavaScript
filename: strings_english.txt
change_type: ModificationType.MODIFY
diff: @@ -627,6 +627,7 @@ $s_please_report = 'Please report this to the %1$s.';
 $s_warning_plain_password_authentication = '<strong>Warning:</strong> Plain password authentication is used, this will expose your passwords to administrators.';
 $s_warning_default_administrator_account_present = '<strong>Warning:</strong> You should disable the default \'administrator\' account or change its password.';
 $s_warning_admin_directory_present = '<strong>Warning:</strong> "admin" directory should be removed, or access to it restricted.';
+$s_warning_host_header_injection_hazard = '<strong>Warning:</strong> "path" was not defined in config_inc.php. Leaving it empty is a security risk, as the path will be set based on headers from the HTTP request, exposing your system to Host Header Injection attacks.';
 $s_warning_change_setting = '<strong>Warning:</strong> "%1$s" is not set to its default value (%2$s).';
 $s_warning_security_hazard = 'This is a potential security hazard as it can expose sensitive information.';
 $s_warning_integrity_hazard = 'This will cause MantisBT to continue when errors occurs and may lead to system/data integrity issues.';

programming_language: Perl
filename: login_page.php
change_type: ModificationType.MODIFY
diff: @@ -152,6 +152,12 @@ if( config_get_global( 'admin_checks' ) == ON ) {
 		}
 	}
 
+	# $g_path was defaulted - risk of Host Header injection attack
+	global $g_defaulted_path;
+	if( $g_defaulted_path ) {
+		$t_warnings[] = lang_get( 'warning_host_header_injection_hazard' );
+	}
+
 	/**
 	 * Display Warnings for enabled debugging / developer settings
 	 * @param string $p_type    Message Type.
@@ -161,8 +167,8 @@ if( config_get_global( 'admin_checks' ) == ON ) {
 	 */
 	function debug_setting_message ( $p_type, $p_setting, $p_value ) {
 		return sprintf( lang_get( 'warning_change_setting' ), $p_setting, $p_value )
-			. sprintf( lang_get( 'word_separator' ) )
-			. sprintf( lang_get( "warning_{$p_type}_hazard" ) );
+			. lang_get( 'word_separator' )
+			. lang_get( "warning_{$p_type}_hazard" );
 	}
 
 	$t_config = 'show_detailed_errors';
@@ -180,6 +186,7 @@ if( config_get_global( 'admin_checks' ) == ON ) {
 	# Check for db upgrade for versions > 1.0.0 using new installer and schema
 	if( $t_admin_dir_is_accessible ) {
 		require_once( 'admin/schema.php' );
+		/** @var array $g_upgrade */
 		$t_upgrades_reqd = count( $g_upgrade ) - 1;
 
 		if( ( 0 < $t_db_version ) &&

programming_language: PHP