From 03a4b9f4fd33d69717e2a21e5963e5f26612df90 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Wed, 17 Sep 2025 01:20:38 +0530 Subject: [PATCH] server,utils: improve js interpretation functionality Make JS interpretation functionalities configurable via a hidden config - js.interpretation.enabled Default value is false, making such functionalities disabled, ie, new heuristic rules cannot be added or updated. For JsInterpretor, use --no-java --no-syntax-extensions args and a deny-all ClassFilter. Replace string-spliced vars with ENGINE_SCOPE Bindings, use a fresh ScriptContext per run, and compile before eval. Use a named daemon worker with hard timeouts and capture stdout. Signed-off-by: Abhishek Kumar --- .../com/cloud/server/ManagementService.java | 13 +- .../cloudstack/quota/QuotaManagerImpl.java | 4 +- .../quota/QuotaManagerImplTest.java | 4 +- .../response/QuotaResponseBuilderImpl.java | 23 ++- .../apache/cloudstack/quota/QuotaService.java | 12 +- .../cloudstack/quota/QuotaServiceImpl.java | 9 + .../cloud/resource/ResourceManagerImpl.java | 6 + .../cloud/server/ManagementServerImpl.java | 22 ++- .../com/cloud/storage/StorageManagerImpl.java | 9 + .../utils/jsinterpreter/JsInterpreter.java | 171 ++++++++++++------ .../utils/jsinterpreter/TagAsRuleHelper.java | 7 +- .../jsinterpreter/JsInterpreterTest.java | 81 ++++++--- 12 files changed, 253 insertions(+), 108 deletions(-) diff --git a/api/src/main/java/com/cloud/server/ManagementService.java b/api/src/main/java/com/cloud/server/ManagementService.java index 18f3e901cd9..d6d7f7a59c6 100644 --- a/api/src/main/java/com/cloud/server/ManagementService.java +++ b/api/src/main/java/com/cloud/server/ManagementService.java @@ -20,7 +20,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import com.cloud.user.UserData; import org.apache.cloudstack.api.command.admin.cluster.ListClustersCmd; import org.apache.cloudstack.api.command.admin.config.ListCfgGroupsByCmd; import org.apache.cloudstack.api.command.admin.config.ListCfgsByCmd; @@ -66,6 +65,7 @@ import org.apache.cloudstack.api.command.user.vm.GetVMPasswordCmd; import org.apache.cloudstack.api.command.user.vmgroup.UpdateVMGroupCmd; import org.apache.cloudstack.config.Configuration; import org.apache.cloudstack.config.ConfigurationGroup; +import org.apache.cloudstack.framework.config.ConfigKey; import com.cloud.alert.Alert; import com.cloud.capacity.Capacity; @@ -85,6 +85,7 @@ import com.cloud.storage.GuestOSHypervisor; import com.cloud.storage.GuestOsCategory; import com.cloud.storage.StoragePool; import com.cloud.user.SSHKeyPair; +import com.cloud.user.UserData; import com.cloud.utils.Pair; import com.cloud.utils.Ternary; import com.cloud.vm.InstanceGroup; @@ -98,6 +99,14 @@ import com.cloud.vm.VirtualMachine.Type; public interface ManagementService { static final String Name = "management-server"; + ConfigKey JsInterpretationEnabled = new ConfigKey<>("Hidden" + , Boolean.class + , "js.interpretation.enabled" + , "false" + , "Enable/Disable all JavaScript interpretation related functionalities to create or update Javascript rules." + , false + , ConfigKey.Scope.Global); + /** * returns the a map of the names/values in the configuration table * @@ -481,4 +490,6 @@ public interface ManagementService { Pair patchSystemVM(PatchSystemVMCmd cmd); + void checkJsInterpretationAllowedIfNeededForParameterValue(String paramName, boolean paramValue); + } diff --git a/framework/quota/src/main/java/org/apache/cloudstack/quota/QuotaManagerImpl.java b/framework/quota/src/main/java/org/apache/cloudstack/quota/QuotaManagerImpl.java index c9254814f46..99181f80c29 100644 --- a/framework/quota/src/main/java/org/apache/cloudstack/quota/QuotaManagerImpl.java +++ b/framework/quota/src/main/java/org/apache/cloudstack/quota/QuotaManagerImpl.java @@ -32,7 +32,6 @@ import java.util.stream.Collectors; import javax.inject.Inject; import javax.naming.ConfigurationException; -import com.cloud.user.Account; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.quota.activationrule.presetvariables.GenericPresetVariable; import org.apache.cloudstack.quota.activationrule.presetvariables.PresetVariableHelper; @@ -62,6 +61,7 @@ import org.springframework.stereotype.Component; import com.cloud.usage.UsageVO; import com.cloud.usage.dao.UsageDao; +import com.cloud.user.Account; import com.cloud.user.AccountVO; import com.cloud.user.dao.AccountDao; import com.cloud.utils.DateUtil; @@ -467,7 +467,7 @@ public class QuotaManagerImpl extends ManagerBase implements QuotaManager { } - jsInterpreter.injectStringVariable("resourceType", presetVariables.getResourceType()); + jsInterpreter.injectVariable("resourceType", presetVariables.getResourceType()); jsInterpreter.injectVariable("value", presetVariables.getValue().toString()); jsInterpreter.injectVariable("zone", presetVariables.getZone().toString()); } diff --git a/framework/quota/src/test/java/org/apache/cloudstack/quota/QuotaManagerImplTest.java b/framework/quota/src/test/java/org/apache/cloudstack/quota/QuotaManagerImplTest.java index 5dfc12f7ef8..3b2ea54e86d 100644 --- a/framework/quota/src/test/java/org/apache/cloudstack/quota/QuotaManagerImplTest.java +++ b/framework/quota/src/test/java/org/apache/cloudstack/quota/QuotaManagerImplTest.java @@ -270,7 +270,7 @@ public class QuotaManagerImplTest { Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("account"), Mockito.anyString()); Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("domain"), Mockito.anyString()); Mockito.verify(jsInterpreterMock, Mockito.never()).injectVariable(Mockito.eq("project"), Mockito.anyString()); - Mockito.verify(jsInterpreterMock).injectStringVariable(Mockito.eq("resourceType"), Mockito.anyString()); + Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("resourceType"), Mockito.anyString()); Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("value"), Mockito.anyString()); Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("zone"), Mockito.anyString()); } @@ -291,7 +291,7 @@ public class QuotaManagerImplTest { Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("account"), Mockito.anyString()); Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("domain"), Mockito.anyString()); Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("project"), Mockito.anyString()); - Mockito.verify(jsInterpreterMock).injectStringVariable(Mockito.eq("resourceType"), Mockito.anyString()); + Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("resourceType"), Mockito.anyString()); Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("value"), Mockito.anyString()); Mockito.verify(jsInterpreterMock).injectVariable(Mockito.eq("zone"), Mockito.anyString()); } diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java index 1c486759e43..00110bb0c0a 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java @@ -39,7 +39,6 @@ import java.util.stream.Collectors; import javax.inject.Inject; -import com.cloud.utils.DateUtil; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.QuotaBalanceCmd; @@ -70,8 +69,8 @@ import org.apache.cloudstack.quota.dao.QuotaCreditsDao; import org.apache.cloudstack.quota.dao.QuotaEmailConfigurationDao; import org.apache.cloudstack.quota.dao.QuotaEmailTemplatesDao; import org.apache.cloudstack.quota.dao.QuotaTariffDao; -import org.apache.cloudstack.quota.vo.QuotaAccountVO; import org.apache.cloudstack.quota.dao.QuotaUsageDao; +import org.apache.cloudstack.quota.vo.QuotaAccountVO; import org.apache.cloudstack.quota.vo.QuotaBalanceVO; import org.apache.cloudstack.quota.vo.QuotaCreditsVO; import org.apache.cloudstack.quota.vo.QuotaEmailConfigurationVO; @@ -79,26 +78,28 @@ import org.apache.cloudstack.quota.vo.QuotaEmailTemplatesVO; import org.apache.cloudstack.quota.vo.QuotaTariffVO; import org.apache.cloudstack.quota.vo.QuotaUsageVO; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; +import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.reflect.FieldUtils; -import org.apache.commons.lang3.ObjectUtils; -import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.springframework.stereotype.Component; import com.cloud.domain.DomainVO; import com.cloud.domain.dao.DomainDao; +import com.cloud.event.ActionEvent; +import com.cloud.event.EventTypes; import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.PermissionDeniedException; import com.cloud.user.Account; import com.cloud.user.AccountManager; import com.cloud.user.AccountVO; import com.cloud.user.User; import com.cloud.user.dao.AccountDao; import com.cloud.user.dao.UserDao; +import com.cloud.utils.DateUtil; import com.cloud.utils.Pair; import com.cloud.utils.db.Filter; -import com.cloud.event.ActionEvent; -import com.cloud.event.EventTypes; @Component public class QuotaResponseBuilderImpl implements QuotaResponseBuilder { @@ -139,6 +140,12 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder { @Inject private ApiDiscoveryService apiDiscoveryService; + protected void checkActivationRulesAllowed(String activationRule) { + if (!_quotaService.isJsInterpretationEnabled() && StringUtils.isNotEmpty(activationRule)) { + throw new PermissionDeniedException("Quota Tariff Activation Rule cannot be set, as Javascript interpretation is disabled in the configuration."); + } + } + @Override public QuotaTariffResponse createQuotaTariffResponse(QuotaTariffVO tariff, boolean returnActivationRule) { final QuotaTariffResponse response = new QuotaTariffResponse(); @@ -440,6 +447,8 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder { throw new InvalidParameterValueException(String.format("There is no quota tariffs with name [%s].", name)); } + checkActivationRulesAllowed(activationRule); + Date currentQuotaTariffStartDate = currentQuotaTariff.getEffectiveOn(); currentQuotaTariff.setRemoved(now); @@ -696,6 +705,8 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder { throw new InvalidParameterValueException(String.format("A quota tariff with name [%s] already exist.", name)); } + checkActivationRulesAllowed(activationRule); + if (startDate.compareTo(now) < 0) { throw new InvalidParameterValueException(String.format("The value passed as Quota tariff's start date is in the past: [%s]. " + "Please, inform a date in the future or do not pass the parameter to use the current date and time.", startDate)); diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaService.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaService.java index 8f3c34982c0..f6a34e01be8 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaService.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaService.java @@ -16,15 +16,15 @@ //under the License. package org.apache.cloudstack.quota; -import com.cloud.user.AccountVO; -import com.cloud.utils.component.PluggableService; +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; import org.apache.cloudstack.quota.vo.QuotaBalanceVO; import org.apache.cloudstack.quota.vo.QuotaUsageVO; -import java.math.BigDecimal; -import java.util.Date; -import java.util.List; +import com.cloud.user.AccountVO; +import com.cloud.utils.component.PluggableService; public interface QuotaService extends PluggableService { @@ -40,4 +40,6 @@ public interface QuotaService extends PluggableService { boolean saveQuotaAccount(AccountVO account, BigDecimal aggrUsage, Date endDate); + boolean isJsInterpretationEnabled(); + } diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaServiceImpl.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaServiceImpl.java index 17fa7bd8425..73a8bc30486 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaServiceImpl.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaServiceImpl.java @@ -60,6 +60,7 @@ import com.cloud.configuration.Config; import com.cloud.domain.dao.DomainDao; import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.PermissionDeniedException; +import com.cloud.server.ManagementService; import com.cloud.user.Account; import com.cloud.user.AccountVO; import com.cloud.user.dao.AccountDao; @@ -86,6 +87,8 @@ public class QuotaServiceImpl extends ManagerBase implements QuotaService, Confi private TimeZone _usageTimezone; + private boolean jsInterpretationEnabled = false; + public QuotaServiceImpl() { super(); } @@ -97,6 +100,8 @@ public class QuotaServiceImpl extends ManagerBase implements QuotaService, Confi String timeZoneStr = ObjectUtils.defaultIfNull(_configDao.getValue(Config.UsageAggregationTimezone.toString()), "GMT"); _usageTimezone = TimeZone.getTimeZone(timeZoneStr); + jsInterpretationEnabled = ManagementService.JsInterpretationEnabled.value(); + return true; } @@ -284,4 +289,8 @@ public class QuotaServiceImpl extends ManagerBase implements QuotaService, Confi } } + @Override + public boolean isJsInterpretationEnabled() { + return jsInterpretationEnabled; + } } diff --git a/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java b/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java index 2ef7f4b6637..24f89548490 100755 --- a/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java +++ b/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java @@ -154,6 +154,7 @@ import com.cloud.org.Cluster; import com.cloud.org.Grouping; import com.cloud.org.Managed; import com.cloud.serializer.GsonHelper; +import com.cloud.server.ManagementService; import com.cloud.service.ServiceOfferingVO; import com.cloud.service.dao.ServiceOfferingDao; import com.cloud.service.dao.ServiceOfferingDetailsDao; @@ -271,6 +272,8 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager, private ServiceOfferingDetailsDao _serviceOfferingDetailsDao; @Inject private UserVmManager userVmManager; + @Inject + ManagementService managementService; private List _discoverers; @@ -1936,6 +1939,9 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager, @Override public Host updateHost(final UpdateHostCmd cmd) throws NoTransitionException { + managementService.checkJsInterpretationAllowedIfNeededForParameterValue(ApiConstants.IS_TAG_A_RULE, + Boolean.TRUE.equals(cmd.getIsTagARule())); + return updateHost(cmd.getId(), cmd.getName(), cmd.getOsCategoryId(), cmd.getAllocationState(), cmd.getUrl(), cmd.getHostTags(), cmd.getIsTagARule(), cmd.getAnnotation(), false); } diff --git a/server/src/main/java/com/cloud/server/ManagementServerImpl.java b/server/src/main/java/com/cloud/server/ManagementServerImpl.java index 56e8a56f2e2..58ac6891e5f 100644 --- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java +++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java @@ -1040,6 +1040,8 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe protected List _planners; + private boolean jsInterpretationEnabled = false; + private final List supportedHypervisors = new ArrayList<>(); public List getPlanners() { @@ -1126,6 +1128,8 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe supportedHypervisors.add(HypervisorType.KVM); supportedHypervisors.add(HypervisorType.XenServer); + jsInterpretationEnabled = JsInterpretationEnabled.value(); + return true; } @@ -4022,8 +4026,10 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe cmdList.add(ListGuestVlansCmd.class); cmdList.add(AssignVolumeCmd.class); cmdList.add(ListSecondaryStorageSelectorsCmd.class); - cmdList.add(CreateSecondaryStorageSelectorCmd.class); - cmdList.add(UpdateSecondaryStorageSelectorCmd.class); + if (jsInterpretationEnabled) { + cmdList.add(CreateSecondaryStorageSelectorCmd.class); + cmdList.add(UpdateSecondaryStorageSelectorCmd.class); + } cmdList.add(RemoveSecondaryStorageSelectorCmd.class); cmdList.add(ListAffectedVmsForStorageScopeChangeCmd.class); @@ -4066,7 +4072,8 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe @Override public ConfigKey[] getConfigKeys() { - return new ConfigKey[] {vmPasswordLength, sshKeyLength, humanReadableSizes, customCsIdentifier}; + return new ConfigKey[] {vmPasswordLength, sshKeyLength, humanReadableSizes, customCsIdentifier, + JsInterpretationEnabled}; } protected class EventPurgeTask extends ManagedContextRunnable { @@ -5523,4 +5530,13 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe _lockControllerListener = lockControllerListener; } + @Override + public void checkJsInterpretationAllowedIfNeededForParameterValue(String paramName, boolean paramValue) { + if (!paramValue || jsInterpretationEnabled) { + return; + } + throw new InvalidParameterValueException(String.format( + "The parameter %s cannot be set to true as JS interpretation is disabled", + paramName)); + } } diff --git a/server/src/main/java/com/cloud/storage/StorageManagerImpl.java b/server/src/main/java/com/cloud/storage/StorageManagerImpl.java index 323c0eb3ee4..5a66ad502f2 100644 --- a/server/src/main/java/com/cloud/storage/StorageManagerImpl.java +++ b/server/src/main/java/com/cloud/storage/StorageManagerImpl.java @@ -213,6 +213,7 @@ import com.cloud.org.Grouping.AllocationState; import com.cloud.resource.ResourceState; import com.cloud.server.ConfigurationServer; import com.cloud.server.ManagementServer; +import com.cloud.server.ManagementService; import com.cloud.server.StatsCollector; import com.cloud.service.dao.ServiceOfferingDetailsDao; import com.cloud.storage.Storage.ImageFormat; @@ -398,6 +399,8 @@ public class StorageManagerImpl extends ManagerBase implements StorageManager, C ConfigurationDao configurationDao; @Inject private ImageStoreDetailsUtil imageStoreDetailsUtil; + @Inject + ManagementService managementService; protected List _discoverers; @@ -1015,6 +1018,9 @@ public class StorageManagerImpl extends ManagerBase implements StorageManager, C throw new PermissionDeniedException(String.format("Cannot perform this operation, Zone is currently disabled: %s", zone)); } + managementService.checkJsInterpretationAllowedIfNeededForParameterValue(ApiConstants.IS_TAG_A_RULE, + Boolean.TRUE.equals(cmd.isTagARule())); + Map params = new HashMap<>(); params.put("zoneId", zone.getId()); params.put("clusterId", clusterId); @@ -1197,6 +1203,9 @@ public class StorageManagerImpl extends ManagerBase implements StorageManager, C // Input validation Long id = cmd.getId(); + managementService.checkJsInterpretationAllowedIfNeededForParameterValue(ApiConstants.IS_TAG_A_RULE, + Boolean.TRUE.equals(cmd.isTagARule())); + StoragePoolVO pool = _storagePoolDao.findById(id); if (pool == null) { throw new IllegalArgumentException("Unable to find storage pool with ID: " + id); diff --git a/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreter.java b/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreter.java index 1a3d9d843c3..3126da50bca 100644 --- a/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreter.java +++ b/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreter.java @@ -19,31 +19,52 @@ package org.apache.cloudstack.utils.jsinterpreter; import java.io.Closeable; import java.io.IOException; +import java.io.StringWriter; +import java.util.Arrays; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import org.apache.commons.collections.MapUtils; -import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.LogManager; +import javax.script.Bindings; +import javax.script.Compilable; +import javax.script.CompiledScript; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptException; +import javax.script.SimpleBindings; +import javax.script.SimpleScriptContext; -import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.commons.collections.MapUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.openjdk.nashorn.api.scripting.ClassFilter; import org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory; -import javax.script.ScriptEngine; +import com.cloud.utils.exception.CloudRuntimeException; /** - * A class to execute JavaScript scripts, with the possibility to inject context to the scripts. + * Executes JavaScript with strong restrictions to mitigate RCE risks. + * - Disables Java interop via --no-java AND a deny-all ClassFilter + * - Disables Nashorn syntax extensions + * - Uses Bindings instead of string-splicing variables + * - Fresh ScriptContext per execution, with timeout on a daemon worker */ public class JsInterpreter implements Closeable { protected Logger logger = LogManager.getLogger(JsInterpreter.class); + protected static final List RESTRICTED_TOKENS = Arrays.asList( "engine", "context", "factory", + "Java", "java", "Packages"," javax", "load", "loadWithNewGlobal", "print", "factory", "getClass", + "runCommand", "Runtime", "exec", "ProcessBuilder", "Thread", "thread", "Threads", "Class", "class"); + protected ScriptEngine interpreter; protected String interpreterName; private final String injectingLogMessage = "Injecting variable [%s] with value [%s] into the JS interpreter."; @@ -51,21 +72,40 @@ public class JsInterpreter implements Closeable { private TimeUnit defaultTimeUnit = TimeUnit.MILLISECONDS; private long timeout; private String timeoutDefaultMessage; - protected Map variables = new LinkedHashMap<>(); + + // Store variables as Objects; they go into Bindings (no code splicing) + protected Map variables = new LinkedHashMap<>(); + + /** Deny-all filter: no Java class is visible from scripts. */ + static final class DenyAllClassFilter implements ClassFilter { + @Override public boolean exposeToScripts(String className) { return false; } + } /** * Constructor created exclusively for unit testing. */ - protected JsInterpreter() { - } + protected JsInterpreter() { } public JsInterpreter(long timeout) { this.timeout = timeout; - this.timeoutDefaultMessage = String.format("Timeout (in milliseconds) defined in the global setting [quota.activationrule.timeout]: [%s].", this.timeout); + this.timeoutDefaultMessage = String.format( + "Timeout (in milliseconds) defined in the global setting [quota.activationrule.timeout]: [%s].", this.timeout); + + if (System.getProperty("nashorn.args") == null) { + System.setProperty("nashorn.args", "--no-java --no-syntax-extensions"); + } + + this.executor = new ThreadPoolExecutor( + 1, 1, 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(), + r -> { + Thread t = new Thread(r, "JsInterpreter-worker"); + t.setDaemon(true); + return t; + } + ); - executor = Executors.newSingleThreadExecutor(); NashornScriptEngineFactory factory = new NashornScriptEngineFactory(); - this.interpreterName = factory.getEngineName(); logger.trace(String.format("Initiating JS interpreter: %s.", interpreterName)); @@ -73,49 +113,53 @@ public class JsInterpreter implements Closeable { } protected void setScriptEngineDisablingJavaLanguage(NashornScriptEngineFactory factory) { - interpreter = factory.getScriptEngine("--no-java"); + String[] opts = new String[] { + "--no-java", + "--no-syntax-extensions", + }; + interpreter = factory.getScriptEngine( + opts, + JsInterpreter.class.getClassLoader(), + new DenyAllClassFilter() + ); } - /** - * Discards the current variables map and create a new one. - */ + /** Discards the current variables map and create a new one. */ public void discardCurrentVariables() { logger.trace("Discarding current variables map and creating a new one."); variables = new LinkedHashMap<>(); } /** - * Adds the parameters to a Map that will be converted to JS variables right before executing the script. - * @param key The name of the variable. - * @param value The value of the variable. + * Adds a variable that will be exposed via ENGINE_SCOPE bindings. + * Safe against code injection (no string concatenation). */ - public void injectVariable(String key, String value) { - logger.trace(String.format(injectingLogMessage, key, value)); + public void injectVariable(String key, Object value) { + if (key == null) return; + logger.trace(String.format(injectingLogMessage, key, String.valueOf(value))); variables.put(key, value); } /** - * Adds the parameter, surrounded by double quotes, to a Map that will be converted to a JS variable right before executing the script. - * @param key The name of the variable. - * @param value The value of the variable. + * @deprecated Not needed when using Bindings; kept for source compatibility. + * Prefer {@link #injectVariable(String, Object)}. */ + @Deprecated public void injectStringVariable(String key, String value) { if (value == null) { logger.trace(String.format("Not injecting [%s] because its value is null.", key)); return; } - value = String.format("\"%s\"", value); - logger.trace(String.format(injectingLogMessage, key, value)); - variables.put(key, value); + injectVariable(key, value); } /** - * Injects the variables to the script and execute it. + * Injects the variables via Bindings and executes the script with a fresh context. * @param script Code to be executed. * @return The result of the executed script. */ public Object executeScript(String script) { - script = addVariablesToScript(script); + Objects.requireNonNull(script, "script"); logger.debug(String.format("Executing script [%s].", script)); @@ -126,43 +170,60 @@ public class JsInterpreter implements Closeable { } protected Object executeScriptInThread(String script) { - Callable task = () -> interpreter.eval(script); + final Callable task = () -> { + final SimpleScriptContext ctx = new SimpleScriptContext(); - Future future = executor.submit(task); + final Bindings engineBindings = new SimpleBindings(); + if (MapUtils.isNotEmpty(variables)) { + engineBindings.putAll(variables); + } + for (String token : RESTRICTED_TOKENS) { + engineBindings.put(token, null); + } + ctx.setBindings(engineBindings, ScriptContext.ENGINE_SCOPE); + + final StringWriter out = new StringWriter(); + ctx.setWriter(out); + + try { + final CompiledScript compiled = ((Compilable) interpreter).compile(script); + Object result = compiled.eval(ctx); + if (out.getBuffer().length() > 0) { + logger.info("Script produced output on stdout: [{}]", out); + } + return result; + } catch (ScriptException se) { + String msg = se.getMessage() == null ? "Script error" : se.getMessage(); + throw new ScriptException("Script error: " + msg, se.getFileName(), se.getLineNumber(), se.getColumnNumber()); + } + }; + + final Future future = executor.submit(task); try { return future.get(this.timeout, defaultTimeUnit); - } catch (TimeoutException | InterruptedException | ExecutionException e) { - String message = String.format("Unable to execute script [%s] due to [%s]", script, e.getMessage()); - - if (e instanceof TimeoutException) { - message = String.format("Execution of script [%s] took too long and timed out. %s", script, timeoutDefaultMessage); - } - + } catch (TimeoutException e) { + String message = String.format( + "Execution of script [%s] took too long and timed out. %s", script, timeoutDefaultMessage); logger.error(message, e); throw new CloudRuntimeException(message, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + String message = String.format("Execution of script [%s] was interrupted.", script); + logger.error(message, e); + throw new CloudRuntimeException(message, e); + } catch (ExecutionException e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + String message = String.format("Unable to execute script [%s] due to [%s]", script, cause.getMessage()); + logger.error(message, cause); + throw new CloudRuntimeException(message, cause); } finally { future.cancel(true); } } - protected String addVariablesToScript(String script) { - if (MapUtils.isEmpty(variables)) { - logger.trace(String.format("There is no variables to add to script [%s]. Returning.", script)); - return script; - } - - String variablesToString = ""; - for (Map.Entry variable : variables.entrySet()) { - variablesToString = String.format("%s %s = %s;", variablesToString, variable.getKey(), variable.getValue()); - } - - return String.format("%s %s", variablesToString, script); - } - - @Override public void close() throws IOException { - executor.shutdown(); + executor.shutdownNow(); } } diff --git a/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/TagAsRuleHelper.java b/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/TagAsRuleHelper.java index 8cf9c13fc19..da3da612a22 100644 --- a/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/TagAsRuleHelper.java +++ b/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/TagAsRuleHelper.java @@ -16,12 +16,12 @@ //under the License. package org.apache.cloudstack.utils.jsinterpreter; -import com.cloud.utils.exception.CloudRuntimeException; -import org.apache.commons.lang3.StringEscapeUtils; +import java.io.IOException; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.io.IOException; +import com.cloud.utils.exception.CloudRuntimeException; public class TagAsRuleHelper { @@ -32,7 +32,6 @@ public class TagAsRuleHelper { public static boolean interpretTagAsRule(String rule, String tags, long timeout) { String script = PARSE_TAGS + rule; - tags = String.format("'%s'", StringEscapeUtils.escapeEcmaScript(tags)); try (JsInterpreter jsInterpreter = new JsInterpreter(timeout)) { jsInterpreter.injectVariable("tags", tags); Object scriptReturn = jsInterpreter.executeScript(script); diff --git a/utils/src/test/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreterTest.java b/utils/src/test/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreterTest.java index ea8a0247f9c..a78ee7b908e 100644 --- a/utils/src/test/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreterTest.java +++ b/utils/src/test/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreterTest.java @@ -20,6 +20,7 @@ package org.apache.cloudstack.utils.jsinterpreter; import java.io.IOException; import java.util.LinkedHashMap; import java.util.Map; +import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -27,7 +28,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeoutException; -import com.cloud.utils.exception.CloudRuntimeException; +import javax.script.ScriptEngine; + import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -36,9 +38,10 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; +import org.openjdk.nashorn.api.scripting.ClassFilter; import org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory; -import javax.script.ScriptEngine; +import com.cloud.utils.exception.CloudRuntimeException; @RunWith(MockitoJUnitRunner.class) public class JsInterpreterTest { @@ -61,30 +64,6 @@ public class JsInterpreterTest { Assert.assertTrue(executor.isShutdown()); } - @Test - public void addVariablesToScriptTestVariablesMapIsEmptyReturnScript() { - String script = "a + b"; - jsInterpreterSpy.variables = new LinkedHashMap<>(); - - String result = jsInterpreterSpy.addVariablesToScript(script); - - Assert.assertEquals(script, result); - } - - @Test - public void addVariablesToScriptTestVariablesMapIsNotEmptyInjectVariableToScript() { - String script = "a + b"; - String var1 = "{test: \"test\"}"; - jsInterpreterSpy.injectVariable("var1", var1); - jsInterpreterSpy.injectVariable("var2", var1); - - String expected = String.format(" var1 = %s; var2 = %s; %s", var1, var1, script); - - String result = jsInterpreterSpy.addVariablesToScript(script); - - Assert.assertEquals(expected, result); - } - @Test public void executeScriptTestReturnResultOfScriptExecution() { String script = "5"; @@ -154,7 +133,7 @@ public class JsInterpreterTest { @Test public void discardCurrentVariablesTestInstantiateNewMap() { - Map variables = new LinkedHashMap<>(); + Map variables = new LinkedHashMap<>(); variables.put("a", "b"); variables.put("b", null); @@ -170,12 +149,14 @@ public class JsInterpreterTest { NashornScriptEngineFactory nashornScriptEngineFactoryMock = Mockito.spy(NashornScriptEngineFactory.class); ScriptEngine scriptEngineMock = Mockito.mock(ScriptEngine.class); - Mockito.doReturn(scriptEngineMock).when(nashornScriptEngineFactoryMock).getScriptEngine(Mockito.anyString()); + Mockito.doReturn(scriptEngineMock).when(nashornScriptEngineFactoryMock).getScriptEngine(Mockito.any(), + Mockito.any(ClassLoader.class), Mockito.any(ClassFilter.class)); jsInterpreterSpy.setScriptEngineDisablingJavaLanguage(nashornScriptEngineFactoryMock); Assert.assertEquals(scriptEngineMock, jsInterpreterSpy.interpreter); - Mockito.verify(nashornScriptEngineFactoryMock).getScriptEngine("--no-java"); + Mockito.verify(nashornScriptEngineFactoryMock).getScriptEngine(Mockito.any(), + Mockito.any(ClassLoader.class), Mockito.any(ClassFilter.class)); } @Test @@ -193,6 +174,46 @@ public class JsInterpreterTest { jsInterpreterSpy.injectStringVariable("a", "b"); - Assert.assertEquals(jsInterpreterSpy.variables.get("a"), "\"b\""); + Assert.assertEquals(jsInterpreterSpy.variables.get("a"), "b"); + } + + @Test + public void executeScriptTestValidScriptShouldPassWithMixedVariables() { + try (JsInterpreter jsInterpreter = new JsInterpreter(1000)) { + jsInterpreter.injectVariable("x", 10); + jsInterpreter.injectVariable("y", "hello"); + jsInterpreter.injectVariable("z", true); + String validScript = "var result = x + (z ? 1 : 0); y + '-' + result;"; + Object result = jsInterpreter.executeScript(validScript); + Assert.assertEquals("hello-11", result); + } catch (IOException exception) { + Assert.fail("IOException not expected here"); + } + } + + private void runMaliciousScriptFileTest(String script, String filename) { + try (JsInterpreter jsInterpreter = new JsInterpreter(1000)) { + jsInterpreter.executeScript(script); + } catch (CloudRuntimeException ex) { + Assert.assertTrue(ex.getMessage().contains("Unable to execute script")); + java.io.File f = new java.io.File(filename); + Assert.assertFalse(f.exists()); + } catch (IOException exception) { + Assert.fail("IOException not expected here"); + } + } + + @Test + public void executeScriptTestMaliciousScriptShouldThrowException1() { + String filename = "/tmp/hack1-" + UUID.randomUUID(); + String maliciousScript = "Java.type('java.lang.Runtime').getRuntime().exec('touch " + filename + "')"; + runMaliciousScriptFileTest(maliciousScript, filename); + } + + @Test + public void executeScriptTestMaliciousScriptShouldThrowException2() { + String filename = "/tmp/hack2-" + UUID.randomUUID(); + String maliciousScript = "var e=this.engine.getFactory().getScriptEngine('-Dnashorn.args=--no-java=False'); e.eval(\"java.lang.Runtime.getRuntime().exec(['/bin/bash','-c','touch " + filename + "']);\");"; + runMaliciousScriptFileTest(maliciousScript, filename); } }