diff --git a/application/cmdbabstract.class.inc.php b/application/cmdbabstract.class.inc.php
index a121a003f..563f37aa4 100644
--- a/application/cmdbabstract.class.inc.php
+++ b/application/cmdbabstract.class.inc.php
@@ -3079,7 +3079,7 @@ JS
} else {
$sCancelButtonOnClickScript .= "function() { BackToDetails('$sClass', $iKey, '$sDefaultUrl', $sJSToken)};";
}
- $sCancelButtonOnClickScript .= "$('#form_{$this->m_iFormId} button.cancel').on('click', fOnClick{$this->m_iFormId}CancelButton);";
+ $sCancelButtonOnClickScript .= "$('#form_{$this->m_iFormId} button.cancel').on('click.navigation.itop', fOnClick{$this->m_iFormId}CancelButton);";
$oPage->add_ready_script($sCancelButtonOnClickScript);
$iFieldsCount = count($aFieldsMap);
diff --git a/application/ui.extkeywidget.class.inc.php b/application/ui.extkeywidget.class.inc.php
index 7a35cf2cb..ed74e86f9 100644
--- a/application/ui.extkeywidget.class.inc.php
+++ b/application/ui.extkeywidget.class.inc.php
@@ -250,7 +250,7 @@ class UIExtKeyWidget
foreach ($aAdditionalField as $sAdditionalField) {
array_push($aArguments, $oObj->Get($sAdditionalField));
}
- $aOption['additional_field'] = utils::HtmlEntities(vsprintf($sFormatAdditionalField, $aArguments));
+ $aOption['additional_field'] = utils::HtmlEntities(utils::VSprintf($sFormatAdditionalField, $aArguments));
}
if (!empty($sObjectImageAttCode)) {
// Try to retrieve image for contact
diff --git a/application/utils.inc.php b/application/utils.inc.php
index c212881df..5803a3bc8 100644
--- a/application/utils.inc.php
+++ b/application/utils.inc.php
@@ -1555,6 +1555,11 @@ class utils
}
$aResult[] = new JSPopupMenuItem('UI:Menu:AddToDashboard', Dict::S('UI:Menu:AddToDashboard'), "DashletCreationDlg('$sOQL', '$sContext')");
$aResult[] = new JSPopupMenuItem('UI:Menu:ShortcutList', Dict::S('UI:Menu:ShortcutList'), "ShortcutListDlg('$sOQL', '$sDataTableId', '$sContext')");
+ if (ApplicationMenu::IsMenuIdEnabled('RunQueriesMenu')) {
+ $oMenuItemPlay = new JSPopupMenuItem('UI:Menu:OpenOQL', Dict::S('UI:Edit:TestQuery'), "OpenOql('$sOQL')");
+ $oMenuItemPlay->SetIconClass('fas fa-play');
+ $aResult[] = $oMenuItemPlay;
+ }
break;
@@ -2075,6 +2080,127 @@ SQL;
);
}
+ /**
+ * Format a string using vsprintf with safety checks to avoid ValueError
+ *
+ * This method fills missing arguments with their original format specifiers,
+ * then calls vsprintf with the complete array.
+ *
+ * @param string $sFormat The format string
+ * @param array $aArgs The arguments to format
+ * @param bool $bLogErrors Whether to log errors (defaults to true)
+ *
+ * @return string The formatted string
+ * @since 3.2.2
+ */
+ public static function VSprintf(string $sFormat, array $aArgs, bool $bLogErrors = true): string
+ {
+ // Extract all format specifiers
+ $sPattern = '/%(?:(?:[1-9][0-9]*)\$)?[-+\'0# ]*(?:[0-9]*|\*)?(?:\.(?:[0-9]*|\*))?(?:[hlL])?[diouxXeEfFgGcrs%]/';
+ preg_match_all($sPattern, $sFormat, $aMatches, PREG_OFFSET_CAPTURE);
+
+ // Process matches, keeping track of their positions and excluding escaped percent signs (%%)
+ $aSpecifierMatches = [];
+ foreach ($aMatches[0] as $sMatch) {
+ if ($sMatch[0] !== '%%') {
+ $aSpecifierMatches[] = $sMatch;
+ }
+ }
+
+ // Check for positional specifiers and build position map
+ $bHasPositional = false;
+ $iMaxPosition = 0;
+ $aPositions = [];
+ $aUniquePositions = [];
+
+ foreach ($aSpecifierMatches as $index => $match) {
+ $sSpec = $match[0];
+ if (preg_match('/^%([1-9][0-9]*)\$/', $sSpec, $posMatch)) {
+ $bHasPositional = true;
+ $iPosition = (int)$posMatch[1] - 1; // Convert to 0-based
+ $aPositions[$index] = $iPosition;
+ $aUniquePositions[$iPosition] = true;
+ $iMaxPosition = max($iMaxPosition, $iPosition + 1);
+ } else {
+ $aPositions[$index] = $index;
+ $aUniquePositions[$index] = true;
+ $iMaxPosition = max($iMaxPosition, $index + 1);
+ }
+ }
+
+ // Count unique positions, this tells us how many arguments we actually need
+ $iExpectedCount = count($aUniquePositions);
+ $iActualCount = count($aArgs);
+
+ // If we have enough arguments, just use vsprintf
+ if ($iActualCount >= $iExpectedCount) {
+ return vsprintf($sFormat, $aArgs);
+ }
+ // else log the error if needed
+ if ($bLogErrors) {
+ IssueLog::Warning("Format string requires $iExpectedCount arguments, but only $iActualCount provided. Format: '$sFormat'" );
+ }
+
+ // Create a replacement map
+ if ($bHasPositional) {
+ // For positional, we need to handle the exact positions
+ $aReplacements = array_fill(0, $iMaxPosition, null);
+
+ // Fill in the real arguments first
+ foreach ($aArgs as $index => $sValue) {
+ if ($index < $iMaxPosition) {
+ $aReplacements[$index] = $sValue;
+ }
+ }
+
+ // For null values in the replacement map, use the original specifier
+ foreach ($aSpecifierMatches as $index => $sMatch) {
+ $iPosition = $aPositions[$index];
+ if ($aReplacements[$iPosition] === null) {
+ // Use the original format specifier when we don't have an argument
+ $aReplacements[$iPosition] = $sMatch[0];
+ }
+ }
+
+ // Remove any remaining nulls (for positions that weren't referenced)
+ $aReplacements = array_filter($aReplacements, static function($val) { return $val !== null; });
+ } else {
+ // For non-positional, we need to map each position
+ $aReplacements = [];
+ $iUsed = 0;
+
+ // Create a map of what values to use for each position
+ $aPositionValues = [];
+ for ($i = 0; $i < $iMaxPosition; $i++) {
+ if (isset($aUniquePositions[$i])) {
+ if ($iUsed < $iActualCount) {
+ // We have an actual argument for this position
+ $aPositionValues[$i] = $aArgs[$iUsed++];
+ } else {
+ // Mark this position to use the original specifier
+ $aPositionValues[$i] = null;
+ }
+ }
+ }
+
+ // Build the replacements array preserving the original order
+ foreach ($aSpecifierMatches as $index => $sMatch) {
+ $iPosition = $aPositions[$index];
+ if (isset($aPositionValues[$iPosition])) {
+ $aReplacements[] = $aPositionValues[$iPosition];
+ } else {
+ // Use the original format specifier when we don't have an argument
+ $aReplacements[] = $sMatch[0];
+ // Mark this position as used, so if it appears again, it gets the same replacement
+ $aPositionValues[$iPosition] = $sMatch[0];
+ }
+ }
+ }
+
+ // Process the format string with our filled-in arguments
+ return vsprintf($sFormat, $aReplacements);
+ }
+
/**
* Convert a string containing some (valid) HTML markup to plain text
*
diff --git a/core/dict.class.inc.php b/core/dict.class.inc.php
index 8fbbbe21d..002ca3c4b 100644
--- a/core/dict.class.inc.php
+++ b/core/dict.class.inc.php
@@ -206,7 +206,7 @@ class Dict
}
try{
- return vsprintf($sLocalizedFormat, $aArguments);
+ return utils::VSprintf($sLocalizedFormat, $aArguments);
} catch(\Throwable $e){
\IssueLog::Error("Cannot format dict key", null, ["sFormatCode" => $sFormatCode, "sLangCode" => $sLangCode, 'exception_msg' => $e->getMessage() ]);
return $sFormatCode.' - '.implode(', ', $aArguments);
diff --git a/core/modelreflection.class.inc.php b/core/modelreflection.class.inc.php
index ee8debde8..3ab1ed7cf 100644
--- a/core/modelreflection.class.inc.php
+++ b/core/modelreflection.class.inc.php
@@ -77,7 +77,7 @@ abstract class ModelReflection
return $sFormatCode.' - '.implode(', ', $aArguments);
}
- return vsprintf($sLocalizedFormat, $aArguments);
+ return utils::VSprintf($sLocalizedFormat, $aArguments);
}
/**
diff --git a/core/valuesetdef.class.inc.php b/core/valuesetdef.class.inc.php
index b7c574e0e..47c1ac43a 100644
--- a/core/valuesetdef.class.inc.php
+++ b/core/valuesetdef.class.inc.php
@@ -435,7 +435,7 @@ class ValueSetObjects extends ValueSetDefinition
foreach ($aAdditionalField as $sAdditionalField) {
array_push($aArguments, $oObject->Get($sAdditionalField));
}
- $aData['additional_field'] = vsprintf($sFormatAdditionalField, $aArguments);
+ $aData['additional_field'] = utils::VSprintf($sFormatAdditionalField, $aArguments);
} else {
$aData['additional_field'] = '';
}
diff --git a/js/extkeywidget.js b/js/extkeywidget.js
index e09210165..ab8c3f5fe 100644
--- a/js/extkeywidget.js
+++ b/js/extkeywidget.js
@@ -717,8 +717,11 @@ function ExtKeyWidget(id, sTargetClass, sFilter, sTitle, bSelectMode, oWizHelper
window[sPromiseId].then(function () {
$('#ac_create_'+me.id).dialog('open');
$('#ac_create_'+me.id).dialog("option", "close", me.OnCloseCreateObject);
- // Modify the action of the cancel button
- $('#ac_create_'+me.id+' button.cancel').off('click').on('click', me.CloseCreateObject);
+ // Modify the action of the cancel button and the close button
+ $('#ac_create_'+me.id+' button.cancel').off('click.navigation.itop').on('click.navigation.itop', me.CloseCreateObject);
+ $('.ui-dialog-titlebar:has(+ #ac_create_'+me.id+') button.ui-dialog-titlebar-close').off('click').on('click', function() {
+ $('#ac_create_'+me.id+' button.cancel').trigger('click');
+ });
me.ajax_request = null;
me.sTargetClass = sLocalTargetClass;
// Adjust the dialog's size to fit into the screen
diff --git a/js/utils.js b/js/utils.js
index 354c975e2..78f2dd8b1 100644
--- a/js/utils.js
+++ b/js/utils.js
@@ -373,6 +373,24 @@ function DashletCreationDlg(sOQL, sContext) {
return false;
}
+function OpenOql(sOQL) {
+ sBaseUrl = GetAbsoluteUrlAppRoot() + 'pages/run_query.php';
+ var form = document.createElement("form");
+ form.setAttribute("method", "post");
+ form.setAttribute("action", sBaseUrl);
+ form.setAttribute("target", '_blank');
+ form.setAttribute("id", 'run_query_form');
+ var input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = 'expression';
+ input.value = sOQL;
+ form.appendChild(input);
+ document.body.appendChild(form);
+ // form.submit() is blocked by the browser
+ $('#run_query_form').submit();
+ document.body.removeChild(form);
+}
+
function ShortcutListDlg(sOQL, sDataTableId, sContext) {
var sDataTableName = 'datatable_'+sDataTableId;
var oTableSettings = {
diff --git a/pages/csvimport.php b/pages/csvimport.php
index 093fe4195..17616eee4 100644
--- a/pages/csvimport.php
+++ b/pages/csvimport.php
@@ -89,9 +89,9 @@ try {
$oOption = SelectOptionUIBlockFactory::MakeForSelectOption("", Dict::S('UI:CSVImport:ClassesSelectOne'), false);
$oSelectBlock->AddSubBlock($oOption);
$aValidClasses = array();
- $aClassCategories = array('bizmodel', 'addon/authentication');
+ $aClassCategories = array('bizmodel', 'addon/authentication', 'grant_by_profile');
if (UserRights::IsAdministrator()) {
- $aClassCategories = array('bizmodel', 'application', 'addon/authentication');
+ $aClassCategories = array('bizmodel', 'application', 'addon/authentication', 'grant_by_profile');
}
foreach ($aClassCategories as $sClassCategory) {
foreach (MetaModel::GetClasses($sClassCategory) as $sClassName) {
diff --git a/setup/moduleinstallation.class.inc.php b/setup/moduleinstallation.class.inc.php
index 81b68a55a..535f22daf 100644
--- a/setup/moduleinstallation.class.inc.php
+++ b/setup/moduleinstallation.class.inc.php
@@ -25,7 +25,7 @@
* @license http://opensource.org/licenses/AGPL-3.0
*/
-class ModuleInstallation extends cmdbAbstractObject
+class ModuleInstallation extends DBObject
{
public static function Init()
{
diff --git a/sources/Controller/Base/Layout/ObjectController.php b/sources/Controller/Base/Layout/ObjectController.php
index 137d7827a..84db6f59e 100644
--- a/sources/Controller/Base/Layout/ObjectController.php
+++ b/sources/Controller/Base/Layout/ObjectController.php
@@ -82,7 +82,9 @@ class ObjectController extends AbstractController
{
throw new ApplicationException(Dict::Format('UI:Error:1ParametersMissing', 'class'));
}
-
+ if (!is_subclass_of($sClass, cmdbAbstractObject::class)) {
+ throw new SecurityException('The class "'.$sClass.'" is not a subclass of cmdbAbstractObject so it can\'t be created by the user');
+ }
// If the specified class has subclasses, ask the user an instance of which class to create
$aSubClasses = MetaModel::EnumChildClasses($sClass, ENUM_CHILD_CLASSES_ALL); // Including the specified class itself
$aPossibleClasses = array();
diff --git a/sources/Core/Email/EmailLaminas.php b/sources/Core/Email/EmailLaminas.php
index afe9fc9df..4f5cfbd6d 100644
--- a/sources/Core/Email/EmailLaminas.php
+++ b/sources/Core/Email/EmailLaminas.php
@@ -435,7 +435,7 @@ class EMailLaminas extends Email
// Add body content to as a new part
$oNewPart = new Part($sBody);
- $oNewPart->encoding = Mime::ENCODING_8BIT;
+ $oNewPart->encoding = Mime::ENCODING_BASE64;
$oNewPart->type = $sMimeType;
$oNewPart->charset = 'UTF-8';
$oBody->addPart($oNewPart);
@@ -463,7 +463,7 @@ class EMailLaminas extends Email
}
$this->m_aData['parts'][] = array('text' => $sText, 'mimeType' => $sMimeType);
$oNewPart = new Part($sText);
- $oNewPart->encoding = Mime::ENCODING_8BIT;
+ $oNewPart->encoding = Mime::ENCODING_BASE64;
$oNewPart->type = $sMimeType;
// setBody called only to refresh Content-Type to multipart/mixed
diff --git a/sources/Service/Base/ObjectRepository.php b/sources/Service/Base/ObjectRepository.php
index 14a8b1637..1058ed3fa 100644
--- a/sources/Service/Base/ObjectRepository.php
+++ b/sources/Service/Base/ObjectRepository.php
@@ -213,7 +213,7 @@ class ObjectRepository
foreach ($aComplementAttributeSpec[1] as $sAdditionalField) {
$aArguments[] = $oDbObject->Get($sAdditionalField);
}
- $aData['additional_field'] = vsprintf($aComplementAttributeSpec[0], $aArguments);
+ $aData['additional_field'] = utils::VSprintf($aComplementAttributeSpec[0], $aArguments);
$sAdditionalFieldForHtml = utils::EscapeHtml($aData['additional_field']);
$aData['full_description'] = "{$sFriendlyNameForHtml}
{$sAdditionalFieldForHtml}";
} else {
diff --git a/tests/php-unit-tests/integration-tests/DictionariesConsistencyAfterSetupTest.php b/tests/php-unit-tests/integration-tests/DictionariesConsistencyAfterSetupTest.php
index 6e854fb13..e69d5cb65 100644
--- a/tests/php-unit-tests/integration-tests/DictionariesConsistencyAfterSetupTest.php
+++ b/tests/php-unit-tests/integration-tests/DictionariesConsistencyAfterSetupTest.php
@@ -41,7 +41,7 @@ class DictionariesConsistencyAfterSetupTest extends ItopTestCase
],
'traduction that breaks expected nb of arguments' => [
'sTemplate' => 'toto %1$s titi %2$s',
- 'sExpectedTraduction' => 'ITOP::DICT:FORMAT:BROKEN:KEY - 1',
+ 'sExpectedTraduction' => 'toto 1 titi %2$s',
],
'traduction ok' => [
'sTemplate' => 'toto %1$s titi',
diff --git a/tests/php-unit-tests/unitary-tests/application/utilsTest.php b/tests/php-unit-tests/unitary-tests/application/utilsTest.php
index f5e67928d..f1e04100b 100644
--- a/tests/php-unit-tests/unitary-tests/application/utilsTest.php
+++ b/tests/php-unit-tests/unitary-tests/application/utilsTest.php
@@ -847,4 +847,94 @@ class utilsTest extends ItopTestCase
$this->assertFalse(utils::GetDocumentFromSelfURL($sURL));
}
+ /**
+ * @dataProvider VSprintfProvider
+ */
+ public function testVSprintf($sFormat, $aArgs, $sExpected)
+ {
+ $sTested = utils::VSprintf($sFormat, $aArgs, false);
+ $this->assertEquals($sExpected, $sTested);
+ }
+
+ public function VSprintfProvider()
+ {
+ return [
+ // Basic positional specifier tests
+ 'Basic positional with enough args' => [
+ 'Format: %1$s, %2$d, %3$s',
+ ['Hello', 42, 'World'],
+ 'Format: Hello, 42, World'
+ ],
+ 'Basic positional with args in different order' => [
+ 'Format: %2$s, %1$d, %3$s',
+ [42, 'Hello', 'World'],
+ 'Format: Hello, 42, World'
+ ],
+ 'Positional with reused specifiers' => [
+ 'Format: %1$s, %2$d, %1$s again',
+ ['Hello', 42],
+ 'Format: Hello, 42, Hello again'
+ ],
+
+ // Missing arguments tests
+ 'Missing one positional arg' => [
+ 'Format: %1$s, %2$d, %3$s',
+ ['Hello', 42],
+ 'Format: Hello, 42, %3$s'
+ ],
+ 'Missing multiple positional args' => [
+ 'Format: %1$s, %2$s, %3$s, %4$s',
+ ['Hello'],
+ 'Format: Hello, %2$s, %3$s, %4$s'
+ ],
+ 'Missing first positional arg' => [
+ 'Format: %1$s, %2$s, %3$s',
+ [],
+ 'Format: %1$s, %2$s, %3$s'
+ ],
+
+ // Edge cases
+ 'Positional with larger numbers' => [
+ 'Format: %2$s, %1$d, %3$s, %2$s again',
+ [123456, 'Hello', 'World'],
+ 'Format: Hello, 123456, World, Hello again'
+ ],
+ 'Positional specifiers with non-sequential indexes' => [
+ 'Format: %3$s then %1$s and %5$d',
+ ['first', 'second', 'third', 'fourth', 42],
+ 'Format: third then first and 42'
+ ],
+
+ // More complex format specifiers
+ 'Positional with format modifiers' => [
+ 'Format: %1$\'*10s, %2$04d',
+ ['Hello', 42],
+ 'Format: *****Hello, 0042'
+ ],
+ 'Positional with various types' => [
+ 'Format: String: %1$s, Integer: %2$d, Char: %3$c',
+ ['Hello', 42, 65],
+ 'Format: String: Hello, Integer: 42, Char: A'
+ ],
+
+ // Testing with non-Latin characters
+ 'Positional with UTF-8 characters' => [
+ 'Format: %1$s %2$s %3$s',
+ ['こんにちは', 'Здравствуйте', '你好'],
+ 'Format: こんにちは Здравствуйте 你好'
+ ],
+
+ // Mixed formats
+ 'Mixed positional with complex specifiers' => [
+ 'Format: %1$-10s | %2$+d',
+ ['Hello', 42],
+ 'Format: Hello | +42'
+ ],
+ 'Reused positional indexes with some missing' => [
+ 'Format: %1$s %2$d %1$s %3$s %2$d',
+ ['Hello', 42],
+ 'Format: Hello 42 Hello %3$s 42'
+ ]
+ ];
+ }
}
diff --git a/webservices/rest.php b/webservices/rest.php
index e0cbfc081..a629a4bdb 100644
--- a/webservices/rest.php
+++ b/webservices/rest.php
@@ -302,9 +302,17 @@ if (MetaModel::GetConfig()->Get('log_rest_service'))
$iUnescapeSlashAndUnicode = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
$sJsonOuputWithPrettyPrinting = json_encode($oResult, $iUnescapeSlashAndUnicode | JSON_PRETTY_PRINT);
$sJsonOutputWithoutPrettyPrinting = json_encode($oResult, $iUnescapeSlashAndUnicode);
- !$oLog->StringFitsInField('json_output', $sJsonOuputWithPrettyPrinting) ?
+ !StringFitsInLogField( $sJsonOuputWithPrettyPrinting) ?
$oLog->SetTrim('json_output', $sJsonOutputWithoutPrettyPrinting) : // too long, we don't make it pretty
$oLog->SetTrim('json_output', $sJsonOuputWithPrettyPrinting);
$oLog->DBInsertNoReload();
+}
+
+/**
+ * @deprecated - will be removed in 3.3.0
+ */
+function StringFitsInLogField(string $sLog): bool
+{
+ return mb_strlen($sLog) <= 16383; // hardcoded value, see N°8260
}
\ No newline at end of file