This is an automated email from the ASF dual-hosted git repository.
DaanHoogland pushed a commit to branch 4.22
in repository https://gitbox.apache.org/repos/asf/cloudstack.git
The following commit(s) were added to refs/heads/4.22 by this push:
new c4748669bfc UI: submit object storage creation using POST (#13768)
c4748669bfc is described below
commit c4748669bfc8faa1c427a15de457cb65ca14b231
Author: Brad <[email protected]>
AuthorDate: Thu Oct 22 16:31:41 2026 +0100
UI: submit object storage creation using POST (#13768)
---
.../admin/storage/AddObjectStoragePoolCmd.java | 2 +-
.../admin/storage/AddObjectStoragePoolCmdTest.java | 37 +++
server/src/main/java/com/cloud/api/ApiServlet.java | 66 +++-
.../test/java/com/cloud/api/ApiServletTest.java | 362 +++++++++++++++++++++
ui/src/utils/plugins.js | 1 -
ui/src/utils/request.js | 1 -
ui/src/views/infra/AddObjectStorage.vue | 6 +-
ui/tests/unit/views/infra/AddObjectStorage.spec.js | 99 ++++++
.../src/main/java/com/cloud/utils/StringUtils.java | 2 +-
.../test/java/com/cloud/utils/StringUtilsTest.java | 29 ++
10 files changed, 590 insertions(+), 15 deletions(-)
diff --git
a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/AddObjectStoragePoolCmd.java
b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/AddObjectStoragePoolCmd.java
index 460b8d642e9..c24d61b7200 100644
---
a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/AddObjectStoragePoolCmd.java
+++
b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/AddObjectStoragePoolCmd.java
@@ -32,7 +32,7 @@ import java.util.Iterator;
import java.util.Map;
@APICommand(name = "addObjectStoragePool", description = "Adds a object
storage pool", responseObject = ObjectStoreResponse.class, since = "4.19.0",
- requestHasSensitiveInfo = false, responseHasSensitiveInfo = false)
+ requestHasSensitiveInfo = true, responseHasSensitiveInfo = false)
public class AddObjectStoragePoolCmd extends BaseCmd {
/////////////////////////////////////////////////////
diff --git
a/api/src/test/java/org/apache/cloudstack/api/command/admin/storage/AddObjectStoragePoolCmdTest.java
b/api/src/test/java/org/apache/cloudstack/api/command/admin/storage/AddObjectStoragePoolCmdTest.java
index c7aeb8ba99b..1d0c3e6673e 100644
---
a/api/src/test/java/org/apache/cloudstack/api/command/admin/storage/AddObjectStoragePoolCmdTest.java
+++
b/api/src/test/java/org/apache/cloudstack/api/command/admin/storage/AddObjectStoragePoolCmdTest.java
@@ -20,6 +20,7 @@ package org.apache.cloudstack.api.command.admin.storage;
import com.cloud.exception.DiscoveryException;
import com.cloud.storage.StorageService;
+import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ResponseGenerator;
import org.apache.cloudstack.api.response.ObjectStoreResponse;
import org.apache.cloudstack.context.CallContext;
@@ -38,6 +39,8 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.util.HashMap;
import java.util.Map;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@RunWith(MockitoJUnitRunner.class)
@@ -98,4 +101,38 @@ public class AddObjectStoragePoolCmdTest {
Mockito.verify(storageService, Mockito.times(1))
.discoverObjectStore(Mockito.any(), Mockito.any(),
Mockito.any(), Mockito.any(), Mockito.any());
}
+
+ @Test
+ public void testRequestIsMarkedAsContainingSensitiveInformation() {
+ APICommand apiCommand =
AddObjectStoragePoolCmd.class.getAnnotation(APICommand.class);
+
+ assertNotNull(apiCommand);
+ assertTrue(apiCommand.requestHasSensitiveInfo());
+ }
+
+ @Test
+ public void testCredentialsAndProviderOptionsReachDiscoveryUnchanged()
throws DiscoveryException {
+ Map<String, String> expected = new HashMap<>();
+ expected.put("accesskey", "ACCESS_SENTINEL");
+ expected.put("secretkey", "SECRET+key&with=symbols");
+ expected.put("validateSSL", "true");
+ expected.put("s3Url", "https://s3.example.test");
+ expected.put("iamUrl", "https://iam.example.test");
+ Map<Integer, Map<String, String>> indexedDetails = new HashMap<>();
+ int index = 0;
+ for (Map.Entry<String, String> entry : expected.entrySet()) {
+ Map<String, String> detail = new HashMap<>();
+ detail.put("key", entry.getKey());
+ detail.put("value", entry.getValue());
+ indexedDetails.put(index++, detail);
+ }
+ ReflectionTestUtils.setField(addObjectStoragePoolCmdSpy, "details",
indexedDetails);
+
Mockito.doReturn(objectStore).when(storageService).discoverObjectStore(Mockito.anyString(),
+ Mockito.anyString(), Mockito.anyLong(), Mockito.anyString(),
any());
+ Mockito.doReturn(new
ObjectStoreResponse()).when(responseGenerator).createObjectStoreResponse(any());
+
+ addObjectStoragePoolCmdSpy.execute();
+
+ Mockito.verify(storageService).discoverObjectStore(name, url, size,
provider, expected);
+ }
}
diff --git a/server/src/main/java/com/cloud/api/ApiServlet.java
b/server/src/main/java/com/cloud/api/ApiServlet.java
index 6a14a00198a..3ea9e7b5b5b 100644
--- a/server/src/main/java/com/cloud/api/ApiServlet.java
+++ b/server/src/main/java/com/cloud/api/ApiServlet.java
@@ -20,6 +20,7 @@ import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.net.URLDecoder;
import java.net.UnknownHostException;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
@@ -85,6 +86,8 @@ public class ApiServlet extends HttpServlet {
private static final Logger ACCESSLOGGER =
LogManager.getLogger("apiserver." + ApiServlet.class.getName());
private static final String REPLACEMENT = "_";
private static final String LOGGER_REPLACEMENTS = "[\n\r\t]";
+ private static final Pattern SENSITIVE_QUERY_PARAMETER = Pattern.compile(
+
".*(?:password|privatekey|accesskey|secretkey)|sessionkey|secretcode|token|userdata",
Pattern.DOTALL);
private static final Pattern GET_REQUEST_COMMANDS =
Pattern.compile("^(get|list|query|find)(\\w+)+$");
private static final HashSet<String> GET_REQUEST_COMMANDS_LIST = new
HashSet<>(Set.of("isaccountallowedtocreateofferingswithtags",
"readyforshutdown", "cloudianisenabled", "quotabalance",
"quotasummary", "quotatarifflist", "quotaisenabled", "quotastatement",
"verifyoauthcodeandgetuser"));
@@ -100,6 +103,7 @@ public class ApiServlet extends HttpServlet {
"updaterolepermission",
"updateprojectrolepermission",
"createstoragepool",
+ "addobjectstoragepool",
"addhost",
"updatehostpassword",
"addcluster",
@@ -172,7 +176,7 @@ public class ApiServlet extends HttpServlet {
String value = decodeUtf8(paramTokens[1]);
params.put(name, new String[] {value});
} else {
- LOGGER.debug("Invalid parameter in URL found. param: " +
param);
+ LOGGER.debug("Invalid parameter in URL found (missing
equals sign).");
}
}
}
@@ -199,8 +203,8 @@ public class ApiServlet extends HttpServlet {
private void checkSingleQueryParameterValue(Map<String, String[]> params) {
params.forEach((k, v) -> {
if (v.length > 1) {
- String message = String.format("Query parameter '%s' has
multiple values %s. Only the last value will be respected." +
- "It is advised to pass only a single parameter", k,
Arrays.toString(v));
+ String message = String.format("Query parameter '%s' has %d
values. Only the last value will be respected. " +
+ "It is advised to pass only a single parameter",
saveLogString(k), v.length);
LOGGER.warn(message);
}
});
@@ -237,17 +241,15 @@ public class ApiServlet extends HttpServlet {
// logging the request start and end in management log for easy
debugging
String reqStr = "";
- String cleanQueryString =
StringUtils.cleanString(req.getQueryString());
+ String cleanQueryString = getCleanQueryString(command,
req.getQueryString(), reqParams);
if (LOGGER.isDebugEnabled()) {
reqStr = auditTrailSb.toString() + " " + cleanQueryString;
if (req.getMethod().equalsIgnoreCase("POST") &&
org.apache.commons.lang3.StringUtils.isNotBlank(command)) {
- if
(!POST_REQUESTS_TO_DISABLE_LOGGING.contains(command.toLowerCase()) &&
!reqParams.containsKey(ApiConstants.USER_DATA)) {
+ if (shouldLogRequestParameters(command, reqParams)) {
String cleanParamsString = getCleanParamsString(reqParams);
if
(org.apache.commons.lang3.StringUtils.isNotBlank(cleanParamsString)) {
reqStr += "\n" + cleanParamsString;
}
- } else {
- reqStr += " " + command;
}
}
LOGGER.debug("===START=== " + reqStr);
@@ -580,7 +582,7 @@ public class ApiServlet extends HttpServlet {
@Nullable
private String saveLogString(String stringToLog) {
- return stringToLog == null ? null :
stringToLog.replace(LOGGER_REPLACEMENTS, REPLACEMENT);
+ return stringToLog == null ? null :
stringToLog.replaceAll(LOGGER_REPLACEMENTS, REPLACEMENT);
}
/**
@@ -779,4 +781,52 @@ public class ApiServlet extends HttpServlet {
return cleanParamsString.toString();
}
+
+ protected boolean shouldLogRequestParameters(String command, Map<String,
String[]> reqParams) {
+ return (org.apache.commons.lang3.StringUtils.isBlank(command)
+ ||
!POST_REQUESTS_TO_DISABLE_LOGGING.contains(command.toLowerCase(java.util.Locale.ROOT)))
+ && !reqParams.containsKey(ApiConstants.USER_DATA);
+ }
+
+ protected String getCleanQueryString(String command, String queryString,
Map<String, String[]> reqParams) {
+ boolean sensitiveRequest = !shouldLogRequestParameters(command,
reqParams);
+ boolean includeCommand = sensitiveRequest &&
org.apache.commons.lang3.StringUtils.isNotBlank(command);
+ if (org.apache.commons.lang3.StringUtils.isEmpty(queryString)) {
+ return includeCommand ? "command=" + saveLogString(command) : "";
+ }
+
+ List<String> cleanParameters = new ArrayList<>();
+ try {
+ // Split before decoding so an encoded ampersand remains part of
its original value.
+ // Read the query only: reqParams also contains the POST body.
+ for (String parameter : queryString.split("&", -1)) {
+ int separator = parameter.indexOf('=');
+ if (separator < 0) {
+ continue;
+ }
+ String name = decodeUtf8(parameter.substring(0, separator));
+ if (ApiConstants.COMMAND.equals(name)) {
+ includeCommand = false;
+ }
+ String lowerName = name.toLowerCase(java.util.Locale.ROOT);
+ if (SENSITIVE_QUERY_PARAMETER.matcher(lowerName).matches()) {
+ continue;
+ }
+ // Sensitive requests can carry credentials in indexed maps.
Omit those entries
+ // rather than trust descriptors that may be in the POST body
or use different case.
+ if (sensitiveRequest && (name.indexOf('[') >= 0 ||
ApiConstants.DETAILS.equals(lowerName))) {
+ continue;
+ }
+ cleanParameters.add(parameter);
+ }
+ } catch (IllegalArgumentException e) {
+ // Formatting must not expose an undecodable query or change
request processing.
+ return includeCommand ? "command=" + saveLogString(command) : "";
+ }
+ if (includeCommand) {
+ cleanParameters.add(0, "command=" + saveLogString(command));
+ }
+ // Preserve the existing cleaning of nested URLs and JSON in the
retained query fields.
+ return StringUtils.cleanString(String.join("&", cleanParameters));
+ }
}
diff --git a/server/src/test/java/com/cloud/api/ApiServletTest.java
b/server/src/test/java/com/cloud/api/ApiServletTest.java
index 79fe4b86f85..367bba9f3c1 100644
--- a/server/src/test/java/com/cloud/api/ApiServletTest.java
+++ b/server/src/test/java/com/cloud/api/ApiServletTest.java
@@ -26,19 +26,32 @@ import com.cloud.user.AccountService;
import com.cloud.user.User;
import com.cloud.user.UserAccount;
import com.cloud.utils.HttpUtils;
+import com.cloud.utils.StringUtils;
+import com.cloud.api.dispatch.DispatchTask;
+import com.cloud.api.dispatch.ParamUnpackWorker;
import com.cloud.vm.UserVmManager;
import org.apache.cloudstack.api.ApiConstants;
+import org.apache.cloudstack.api.ApiErrorCode;
+import org.apache.cloudstack.api.ServerApiException;
+import org.apache.cloudstack.api.command.admin.storage.AddImageStoreCmd;
+import org.apache.cloudstack.api.command.admin.storage.AddObjectStoragePoolCmd;
import org.apache.cloudstack.api.auth.APIAuthenticationManager;
import org.apache.cloudstack.api.auth.APIAuthenticationType;
import org.apache.cloudstack.api.auth.APIAuthenticator;
import org.apache.cloudstack.api.command.admin.config.ListCfgsByCmd;
import org.apache.cloudstack.framework.config.ConfigKey;
import org.apache.cloudstack.framework.config.impl.ConfigDepotImpl;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Property;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;
@@ -52,9 +65,14 @@ import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.net.InetAddress;
+import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.nullable;
@@ -461,4 +479,348 @@ public class ApiServletTest {
Assert.assertEquals(false, result);
}
+
+ @Test
+ public void preservesPostParameterLoggingPolicy() {
+ for (String command : new String[] {"addObjectStoragePool", "addHost",
"createAccount"}) {
+ Assert.assertFalse(servlet.shouldLogRequestParameters(command, new
HashMap<>()));
+ }
+ Assert.assertTrue(servlet.shouldLogRequestParameters("listZones", new
HashMap<>()));
+ Map<String, String[]> params = new HashMap<>();
+ params.put(ApiConstants.USER_DATA, new String[]
{"USER_DATA_SENTINEL"});
+
Assert.assertFalse(servlet.shouldLogRequestParameters("deployVirtualMachine",
params));
+ }
+
+ @Test
+ public void preservesAddHostQueryContextInAllRequestLogs() {
+ String query =
"command=addHost&zoneid=zone-1&username=test-user&password=PASSWORD_HEAD%26PASSWORD_TAIL&response=json";
+ List<String> logs = captureRequestLogs("GET", query,
queryParameters(query), true, false, HttpServletResponse.SC_OK);
+ assertRequestLogs(logs,
"command=addHost&zoneid=zone-1&username=test-user&response=json",
"PASSWORD_HEAD", "PASSWORD_TAIL");
+ Mockito.verify(apiServer).handleRequest(Mockito.anyMap(),
Mockito.eq("json"), Mockito.any(StringBuilder.class));
+ }
+
+ @Test
+ public void preservesCreateAccountQueryContextInAllRequestLogs() {
+ String query =
"command=createAccount&username=test-user&account=test-account&password=PASSWORD_SENTINEL&response=json";
+ List<String> logs = captureRequestLogs("GET", query,
queryParameters(query), true, false, HttpServletResponse.SC_OK);
+ assertRequestLogs(logs,
"command=createAccount&username=test-user&account=test-account&response=json",
"PASSWORD_SENTINEL");
+ }
+
+ @Test
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public void
redactsUserDataWithoutRemovingItsQueryContextOrChangingTheRequest() {
+ String query =
"command=deployVirtualMachine&id=vm-1&userdata=USER_DATA_HEAD%26USER_DATA_TAIL&response=json";
+ List<String> logs = captureRequestLogs("GET", query,
queryParameters(query), true, false, HttpServletResponse.SC_OK);
+ assertRequestLogs(logs,
"command=deployVirtualMachine&id=vm-1&response=json", "USER_DATA_HEAD",
"USER_DATA_TAIL");
+ ArgumentCaptor<Map> captured = ArgumentCaptor.forClass(Map.class);
+ Mockito.verify(apiServer).handleRequest(captured.capture(),
Mockito.eq("json"), Mockito.any(StringBuilder.class));
+ Assert.assertArrayEquals(new String[]
{"USER_DATA_HEAD&USER_DATA_TAIL"}, (Object[])
captured.getValue().get(ApiConstants.USER_DATA));
+ }
+
+ @Test
+ public void preservesPostEnforcementAndRedactsRejectedObjectStoreGet() {
+ String query =
"command=addObjectStoragePool&name=store-1&provider=MinIO&details[0].key=accesskey"
+ + "&details[0].value=ACCESS_SENTINEL&details[1].key=secretkey"
+ +
"&details[1].value=SECRET_HEAD%2B%26SECRET_TAIL%3D&response=json";
+ List<String> logs = captureRequestLogs("GET", query,
queryParameters(query), false, true, HttpServletResponse.SC_BAD_REQUEST);
+ assertRequestLogs(logs,
"command=addObjectStoragePool&name=store-1&provider=MinIO&response=json",
"ACCESS_SENTINEL", "SECRET_HEAD", "SECRET_TAIL");
+ Mockito.verify(apiServer,
Mockito.never()).verifyRequest(Mockito.anyMap(), nullable(Long.class),
Mockito.any(InetAddress.class));
+ Mockito.verify(apiServer,
Mockito.never()).handleRequest(Mockito.anyMap(), Mockito.anyString(),
Mockito.any(StringBuilder.class));
+ }
+
+ @Test
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public void acceptsObjectStorePostWithoutLoggingOrChangingItsBody() {
+ Map<String, String[]> params =
queryParameters("command=addObjectStoragePool&name=store-1&provider=MinIO&response=json");
+ params.put("url", new String[] {"https://storage.example.test"});
+ params.put("details[0].key", new String[] {"accesskey"});
+ params.put("details[0].value", new String[] {"BODY_ACCESS_SENTINEL"});
+ params.put("details[1].key", new String[] {"secretkey"});
+ params.put("details[1].value", new String[]
{"BODY_SECRET+&=SENTINEL"});
+ String responseBody =
"{\"addobjectstoragepoolresponse\":{\"objectstore\":{\"id\":\"store-id\"}}}";
+ Mockito.when(apiServer.handleRequest(Mockito.anyMap(),
Mockito.eq("json"), Mockito.any(StringBuilder.class))).thenReturn(responseBody);
+ List<String> logs = captureRequestLogs("POST", null, params, true,
true, HttpServletResponse.SC_OK);
+ assertRequestLogs(logs, "command=addObjectStoragePool",
"BODY_ACCESS_SENTINEL", "BODY_SECRET", "SENTINEL");
+ Assert.assertEquals(responseBody, responseWriter.toString());
+ ArgumentCaptor<Map> captured = ArgumentCaptor.forClass(Map.class);
+ Mockito.verify(apiServer).handleRequest(captured.capture(),
Mockito.eq("json"), Mockito.any(StringBuilder.class));
+ Assert.assertArrayEquals(new String[] {"BODY_SECRET+&=SENTINEL"},
(Object[]) captured.getValue().get("details[1].value"));
+ Assert.assertArrayEquals(new String[] {"POST"}, (Object[])
captured.getValue().get("httpmethod"));
+ }
+
+ @Test
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public void
protectsMixedQueryAndBodyDetailsWithoutReinterpretingFieldCase() {
+ String query =
"command=addObjectStoragePool&name=store-1&provider=MinIO&response=json"
+ +
"&details[1].KEY=public&details[1].value=MIXED_SECRET_SENTINEL";
+ Map<String, String[]> params = queryParameters(query);
+ params.put("details[1].key", new String[] {"secretkey"});
+ params.put("details[0].key", new String[] {"accesskey"});
+ params.put("details[0].value", new String[] {"BODY_ACCESS_SENTINEL"});
+ List<String> logs = captureRequestLogs("POST", query, params, true,
true, HttpServletResponse.SC_OK);
+ assertRequestLogs(logs,
"command=addObjectStoragePool&name=store-1&provider=MinIO&response=json",
"MIXED_SECRET_SENTINEL", "BODY_ACCESS_SENTINEL");
+
+ ArgumentCaptor<Map> captured = ArgumentCaptor.forClass(Map.class);
+ Mockito.verify(apiServer).handleRequest(captured.capture(),
Mockito.eq("json"), Mockito.any(StringBuilder.class));
+ Map<String, String> commandParameters = new HashMap<>();
+ Map<String, Object[]> forwarded = captured.getValue();
+ forwarded.forEach((key, values) -> commandParameters.put(key, (String)
values[0]));
+ AddObjectStoragePoolCmd command = new AddObjectStoragePoolCmd();
+ DispatchTask task = new DispatchTask(command, commandParameters);
+ new ParamUnpackWorker().handle(task);
+ command.setDetails((Map) task.getParams().get(ApiConstants.DETAILS));
+ Assert.assertEquals("MIXED_SECRET_SENTINEL",
command.getDetails().get("secretkey"));
+ Assert.assertEquals("BODY_ACCESS_SENTINEL",
command.getDetails().get("accesskey"));
+ }
+
+ @Test
+ public void
retainsQueryMetadataButNeverCopiesPostOnlyFieldsIntoTheAccessLog() {
+ String query = "name=query-store&provider=MinIO&response=json";
+ Map<String, String[]> params = queryParameters(query);
+ params.put(ApiConstants.COMMAND, new String[]
{"addObjectStoragePool"});
+ params.put("details[1].key", new String[] {"secretkey"});
+ params.put("details[1].value", new String[] {"BODY_SECRET_SENTINEL"});
+ params.put("tags", new String[] {"BODY_ONLY_TAG_SENTINEL"});
+ List<String> logs = captureRequestLogs("POST", query, params, true,
true, HttpServletResponse.SC_OK);
+ assertRequestLogs(logs,
"command=addObjectStoragePool&name=query-store&provider=MinIO&response=json",
"BODY_SECRET_SENTINEL", "BODY_ONLY_TAG_SENTINEL");
+ }
+
+ @Test
+ public void preservesAuthorizationRejectionWithoutLoggingProviderDetails()
{
+ String query =
"command=addObjectStoragePool&name=store-1&provider=Cloudian%20HyperStore"
+ +
"&details[2].key=s3Url&details[2].value=PROVIDER_DETAIL_SENTINEL&response=json";
+ List<String> logs = captureRequestLogs("POST", query,
queryParameters(query), false, true, HttpServletResponse.SC_UNAUTHORIZED);
+ assertRequestLogs(logs,
"command=addObjectStoragePool&name=store-1&provider=Cloudian%20HyperStore&response=json",
"PROVIDER_DETAIL_SENTINEL");
+ Mockito.verify(apiServer,
Mockito.never()).handleRequest(Mockito.anyMap(), Mockito.anyString(),
Mockito.any(StringBuilder.class));
+ }
+
+ @Test
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public void
doesNotPrintDuplicateCredentialValuesAndKeepsExistingLastQueryValue() {
+ String query =
"command=addObjectStoragePool&name=store-1&details[1].key=secretkey"
+ +
"&details[1].value=FIRST_SECRET_SENTINEL&details[1].value=LAST_SECRET_SENTINEL&response=json";
+ List<String> logs = captureRequestLogs("POST", query,
queryParameters(query), true, true, HttpServletResponse.SC_OK);
+ assertRequestLogs(logs,
"command=addObjectStoragePool&name=store-1&response=json",
"FIRST_SECRET_SENTINEL", "LAST_SECRET_SENTINEL");
+ Assert.assertTrue(logs.stream().anyMatch(line -> line.contains("has 2
values")));
+ ArgumentCaptor<Map> captured = ArgumentCaptor.forClass(Map.class);
+ Mockito.verify(apiServer).handleRequest(captured.capture(),
Mockito.eq("json"), Mockito.any(StringBuilder.class));
+ Assert.assertArrayEquals(new String[] {"LAST_SECRET_SENTINEL"},
(Object[]) captured.getValue().get("details[1].value"));
+ }
+
+ @Test
+ public void doesNotEchoUnstructuredQueryTokensBeforeRedaction() {
+ String query =
"command=addObjectStoragePool&name=store-1&MALFORMED_SECRET_SENTINEL&response=json";
+ List<String> logs = captureRequestLogs("POST", query,
queryParameters(query), false, false, HttpServletResponse.SC_UNAUTHORIZED);
+ assertRequestLogs(logs,
"command=addObjectStoragePool&name=store-1&response=json",
"MALFORMED_SECRET_SENTINEL");
+ Assert.assertTrue(logs.stream().anyMatch(line ->
line.contains("Invalid parameter in URL found")));
+ }
+
+ @Test
+ public void preservesBackendFailureStatusAndDescription() {
+ String query =
"command=addObjectStoragePool&name=store-1&details[1].key=secretkey"
+ + "&details[1].value=ERROR_SECRET_SENTINEL&response=json";
+ Mockito.when(apiServer.handleRequest(Mockito.anyMap(),
Mockito.eq("json"), Mockito.any(StringBuilder.class)))
+ .thenThrow(new ServerApiException(ApiErrorCode.INTERNAL_ERROR,
"Provider unavailable"));
+ List<String> logs = captureRequestLogs("POST", query,
queryParameters(query), true, true, ApiErrorCode.INTERNAL_ERROR.getHttpCode());
+ assertRequestLogs(logs,
"command=addObjectStoragePool&name=store-1&response=json",
"ERROR_SECRET_SENTINEL");
+ Mockito.verify(response).setHeader("X-Description", "Provider
unavailable");
+ }
+
+ @Test
+ public void keepsOrdinaryGetAndPostDiagnostics() {
+ String query = "command=listZones&name=zone-1&response=json";
+ List<String> logs = captureRequestLogs("POST", query,
queryParameters(query), true, false, HttpServletResponse.SC_OK);
+ assertRequestLogs(logs, query);
+ Assert.assertTrue(logs.stream().anyMatch(line ->
line.contains("===START===") && line.contains("\n") &&
line.contains("name=zone-1")));
+ Assert.assertEquals(query, servlet.getCleanQueryString("listZones",
query, queryParameters(query)));
+ }
+
+ @Test
+ public void
excludesObjectStorageDetailsRegardlessOfEncodingOrderOrDescriptor() {
+ for (String fields : new String[] {
+
"details%5b001%5d.KEY=public&DETAILS[%2B1].value=SECRET_SENTINEL",
+ "details[1].value=SECRET_SENTINEL&details[1].key=secretkey",
+ "details[-1]suffix.VALUE=SECRET_SENTINEL",
+ "details[invalid].value=SECRET_SENTINEL",
+ "details[1].value=SECRET_SENTINEL",
+ "details=SECRET_SENTINEL"}) {
+ String query = "command=addObjectStoragePool&name=store-1&" +
fields + "&response=json";
+
Assert.assertEquals("command=addObjectStoragePool&name=store-1&response=json",
+ servlet.getCleanQueryString("addObjectStoragePool", query,
new HashMap<>()));
+ }
+ }
+
+ @Test
+ public void
redactsCompleteSensitiveTopLevelValuesButKeepsNonSensitiveFlags() {
+ String query =
"command=listZones&userdataid=id-1&passwordenabled=true&newPassword=PASSWORD_HEAD%26PASSWORD_TAIL"
+ +
"&ACCESSKEY=ACCESS_SENTINEL&privatekey=PRIVATE_SENTINEL&SeSsIoNkEy=SESSION_SENTINEL%2B%3D"
+ +
"&secretcode=CODE_SENTINEL&token=RESET_TOKEN_SENTINEL&UserData=DATA_SENTINEL&response=json";
+
Assert.assertEquals("command=listZones&userdataid=id-1&passwordenabled=true&response=json",
+ servlet.getCleanQueryString("listZones", query, new
HashMap<>()));
+ }
+
+ @Test
+ public void keepsExistingNestedUrlCleaningAndOrdinaryDetails() {
+ String query =
"command=addHost&zoneid=zone-1&url=cifs%3A%2F%2Fstorage%2Fshare%3Fuser%3Dtest"
+ + "%26password%3DPASSWORD_SENTINEL%26domain%3Dexample";
+ Assert.assertEquals(StringUtils.cleanString(query),
servlet.getCleanQueryString("addHost", query, new HashMap<>()));
+ String ordinary =
"command=deployVirtualMachine&details[0].key=keyboard&details[0].value=us&response=json";
+ Assert.assertEquals(ordinary,
servlet.getCleanQueryString("deployVirtualMachine", ordinary, new HashMap<>()));
+ }
+
+ @Test
+ public void cannotEchoAnUndecodableQueryAndSanitizesCommandMetadata() {
+ String query = "details%ZZ.value=SECRET_SENTINEL";
+ Assert.assertEquals("command=addObjectStoragePool",
servlet.getCleanQueryString("addObjectStoragePool", query, new HashMap<>()));
+ Assert.assertEquals("", servlet.getCleanQueryString("listZones",
query, new HashMap<>()));
+ Map<String, String[]> params = new HashMap<>();
+ params.put(ApiConstants.USER_DATA, new String[] {"DATA_SENTINEL"});
+ Assert.assertEquals("command=unknown_command__",
servlet.getCleanQueryString("unknown\ncommand\r\t", null, params));
+ }
+
+ @Test
+ public void
preservesContextWithoutExposingOtherSensitiveCommandsIndexedMaps() {
+ String[][] cases = {
+ {"createStoragePool",
"details[0].key=secretkey&details[0].value=STORAGE_SECRET_SENTINEL%26TAIL"},
+ {"createAccount",
"accountdetails[0].key=secretkey&accountdetails[0].value=ACCOUNT_SECRET_SENTINEL"},
+ {"addHost",
"externaldetails[0].endpoint.password=HOST_SECRET_SENTINEL"}
+ };
+ for (String[] testCase : cases) {
+ String query = "command=" + testCase[0] + "&name=resource-1&" +
testCase[1] + "&response=json";
+ Assert.assertEquals("command=" + testCase[0] +
"&name=resource-1&response=json",
+ servlet.getCleanQueryString(testCase[0], query, new
HashMap<>()));
+ }
+ String reset =
"command=resetPassword&username=test-user&token=RESET_TOKEN_SENTINEL&password=PASSWORD_SENTINEL";
+ Assert.assertEquals("command=resetPassword&username=test-user",
+ servlet.getCleanQueryString("resetPassword", reset, new
HashMap<>()));
+ }
+
+ @Test
+ public void
preservesImageStoreCredentialRedactionAfterRemovingTrailingSessionKey() {
+ assertImageStoreQueryRemainsRedacted(false);
+ }
+
+ @Test
+ public void
preservesEncodedImageStoreCredentialRedactionAfterRemovingTrailingSessionKey() {
+ assertImageStoreQueryRemainsRedacted(true);
+ }
+
+ @Test
+ public void
preservesIndexedCredentialRedactionAfterFilteringOtherTrailingFields() {
+ String context = "command=addImageStore&provider=S3&name=store-1";
+ String credentials =
"&details[1].value=SECRET_SENTINEL&details[1].key=secretkey";
+ for (String trailing : new String[] {"",
"&sessionkey=SESSION_SENTINEL", "&password=PASSWORD_SENTINEL",
+ "&token=TOKEN_SENTINEL", "&privatekey=PRIVATE_SENTINEL",
"&userdata=USERDATA_SENTINEL"}) {
+ String query = context + credentials + trailing;
+ String cleaned = servlet.getCleanQueryString("addImageStore",
query, queryParameters(query));
+ Assert.assertTrue("Missing metadata: " + cleaned,
cleaned.startsWith(context));
+ Assert.assertFalse("Credential survived filtering: " + cleaned,
cleaned.contains("SENTINEL"));
+ }
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private void assertImageStoreQueryRemainsRedacted(boolean encodedBrackets)
{
+ String context =
"command=addImageStore&provider=S3&name=store-1&response=json"
+ + "&details[2].key=bucket&details[2].value=archive";
+ String query = context +
"&details[0].key=accesskey&details[0].value=ACCESS_SENTINEL"
+ +
"&details[1].value=SECRET_SENTINEL&details[1].key=secretkey&sessionkey=SESSION_SENTINEL";
+ if (encodedBrackets) {
+ query = query.replace("[", "%5B").replace("]", "%5D");
+ context = context.replace("[", "%5B").replace("]", "%5D");
+ }
+ List<String> logs = captureRequestLogs("GET", query,
queryParameters(query), true, false, HttpServletResponse.SC_OK);
+ assertRequestLogs(logs, context, "ACCESS_SENTINEL", "SECRET_SENTINEL",
"SESSION_SENTINEL");
+
+ ArgumentCaptor<Map> captured = ArgumentCaptor.forClass(Map.class);
+ Mockito.verify(apiServer).handleRequest(captured.capture(),
Mockito.eq("json"), Mockito.any(StringBuilder.class));
+ Map<String, String> unpackInput = new HashMap<>();
+ Map<String, Object[]> forwarded = captured.getValue();
+ forwarded.forEach((key, values) -> unpackInput.put(key, (String)
values[0]));
+ AddImageStoreCmd command = new AddImageStoreCmd();
+ DispatchTask task = new DispatchTask(command, unpackInput);
+ new ParamUnpackWorker().handle(task);
+ command.setDetails((Map) task.getParams().get(ApiConstants.DETAILS));
+ Map<String, String> expectedDetails = new HashMap<>();
+ expectedDetails.put("accesskey", "ACCESS_SENTINEL");
+ expectedDetails.put("secretkey", "SECRET_SENTINEL");
+ expectedDetails.put("bucket", "archive");
+ Assert.assertEquals(expectedDetails, command.getDetails());
+ }
+
+ private Map<String, String[]> queryParameters(String query) {
+ Map<String, String[]> params = new HashMap<>();
+ for (String part : query.split("&")) {
+ String[] pair = part.split("=", 2);
+ if (pair.length != 2) {
+ continue;
+ }
+ String name = URLDecoder.decode(pair[0], StandardCharsets.UTF_8);
+ String value = URLDecoder.decode(pair[1], StandardCharsets.UTF_8);
+ params.merge(name, new String[] {value}, (existing, added) -> {
+ String[] values = Arrays.copyOf(existing, existing.length + 1);
+ values[existing.length] = added[0];
+ return values;
+ });
+ }
+ return params;
+ }
+
+ private List<String> captureRequestLogs(String method, String query,
Map<String, String[]> params,
+ boolean authorized, boolean enforcePost, int expectedStatus) {
+ Map<String, String[]> original = new HashMap<>();
+ params.forEach((key, values) -> original.put(key, values.clone()));
+ List<String> logs = new ArrayList<>();
+ AbstractAppender appender = new
AbstractAppender("ApiServletRequestLoggingTest", null, null, true,
Property.EMPTY_ARRAY) {
+ @Override
+ public void append(LogEvent event) {
+ logs.add(event.getLoggerName() + ": " +
event.getMessage().getFormattedMessage());
+ }
+ };
+ org.apache.logging.log4j.core.Logger managementLogger =
(org.apache.logging.log4j.core.Logger) LogManager.getLogger(ApiServlet.class);
+ org.apache.logging.log4j.core.Logger accessLogger =
(org.apache.logging.log4j.core.Logger) LogManager.getLogger("apiserver." +
ApiServlet.class.getName());
+ Level managementLevel = managementLogger.getLevel();
+ Level accessLevel = accessLogger.getLevel();
+ appender.start();
+ managementLogger.addAppender(appender);
+ accessLogger.addAppender(appender);
+ managementLogger.setLevel(Level.DEBUG);
+ accessLogger.setLevel(Level.INFO);
+ try {
+ Mockito.when(request.getMethod()).thenReturn(method);
+ Mockito.when(request.getQueryString()).thenReturn(query);
+ Mockito.when(request.getParameterMap()).thenReturn(params);
+
Mockito.when(authManager.getAPIAuthenticator(Mockito.anyString())).thenReturn(null);
+ if (authorized) {
+ Mockito.when(apiServer.verifyRequest(Mockito.anyMap(),
nullable(Long.class), Mockito.any(InetAddress.class))).thenReturn(true);
+ }
+ if (enforcePost) {
+
Mockito.when(apiServer.isPostRequestsAndTimestampsEnforced()).thenReturn(true);
+ }
+ servlet.processRequestInContext(request, response);
+ Mockito.verify(response).setStatus(expectedStatus);
+ Assert.assertEquals(original.keySet(), params.keySet());
+ original.forEach((key, values) -> Assert.assertArrayEquals(values,
params.get(key)));
+ return logs;
+ } finally {
+ managementLogger.removeAppender(appender);
+ accessLogger.removeAppender(appender);
+ managementLogger.setLevel(managementLevel);
+ accessLogger.setLevel(accessLevel);
+ appender.stop();
+ }
+ }
+
+ private void assertRequestLogs(List<String> logs, String expectedContext,
String... secrets) {
+ int boundaries = 0;
+ for (String line : logs) {
+ if (line.contains("===START===") || line.contains("===END===") ||
line.startsWith("apiserver.")) {
+ boundaries++;
+ Assert.assertTrue("Missing request context: " + line,
line.contains(expectedContext));
+ }
+ for (String secret : secrets) {
+ Assert.assertFalse("Sensitive data in log: " + line,
line.contains(secret));
+ }
+ }
+ Assert.assertEquals("Expected START, END and access records", 3,
boundaries);
+ }
}
diff --git a/ui/src/utils/plugins.js b/ui/src/utils/plugins.js
index 729cef84d02..17c53e1c936 100644
--- a/ui/src/utils/plugins.js
+++ b/ui/src/utils/plugins.js
@@ -217,7 +217,6 @@ export const pollJobPlugin = {
export const notifierPlugin = {
install (app) {
app.config.globalProperties.$notifyError = function (error) {
- console.log(error)
var msg = i18n.global.t('message.request.failed')
var desc = ''
if (error && error.response) {
diff --git a/ui/src/utils/request.js b/ui/src/utils/request.js
index 2317aac0446..8d16dbe8aa5 100644
--- a/ui/src/utils/request.js
+++ b/ui/src/utils/request.js
@@ -34,7 +34,6 @@ const err = (error) => {
const response = error.response
let countNotify = store.getters.countNotify
if (response) {
- console.log(response)
if (response.status === 403) {
const data = response.data
countNotify++
diff --git a/ui/src/views/infra/AddObjectStorage.vue
b/ui/src/views/infra/AddObjectStorage.vue
index 5410a9b9502..8c206ffb9d0 100644
--- a/ui/src/views/infra/AddObjectStorage.vue
+++ b/ui/src/views/infra/AddObjectStorage.vue
@@ -87,7 +87,7 @@
<a-input v-model:value="form.accessKey" />
</a-form-item>
<a-form-item name="secretKey" ref="secretKey"
:label="$t('label.secret.key')">
- <a-input v-model:value="form.secretKey" />
+ <a-input-password v-model:value="form.secretKey"
autocomplete="off" />
</a-form-item>
<a-form-item name="size" ref="size">
<template #label>
@@ -106,7 +106,7 @@
</template>
<script>
import { ref, reactive, toRaw } from 'vue'
-import { getAPI } from '@/api'
+import { postAPI } from '@/api'
import { mixinForm } from '@/utils/mixin'
import ResourceIcon from '@/components/view/ResourceIcon'
import TooltipLabel from '@/components/widgets/TooltipLabel'
@@ -209,7 +209,7 @@ export default {
},
addObjectStore (params) {
return new Promise((resolve, reject) => {
- getAPI('addObjectStoragePool', params).then(json => {
+ postAPI('addObjectStoragePool', params).then(json => {
resolve()
}).catch(error => {
reject(error)
diff --git a/ui/tests/unit/views/infra/AddObjectStorage.spec.js
b/ui/tests/unit/views/infra/AddObjectStorage.spec.js
new file mode 100644
index 00000000000..0d8933ac008
--- /dev/null
+++ b/ui/tests/unit/views/infra/AddObjectStorage.spec.js
@@ -0,0 +1,99 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import mockAxios from '../../../mock/mockAxios'
+import AddObjectStorage from '@/views/infra/AddObjectStorage'
+import { mount } from '@vue/test-utils'
+
+jest.mock('axios', () => mockAxios)
+jest.mock('@/vue-app', () => ({
+ vueProps: {
+ $localStorage: {
+ get: jest.fn(() => null)
+ }
+ }
+}))
+
+describe('Views > infra > AddObjectStorage.vue', () => {
+ beforeEach(() => {
+ mockAxios.mockReset()
+ })
+
+ it('submits addObjectStoragePool using POST with the request in the body',
async () => {
+ mockAxios.mockResolvedValue({})
+
+ const params = {
+ name: 'test-store',
+ provider: 'MinIO',
+ url: 'https://object-storage.example.test',
+ 'details[0].key': 'accesskey',
+ 'details[0].value': 'test-access-key',
+ 'details[1].key': 'secretkey',
+ 'details[1].value': 'test-secret+key&with=symbols'
+ }
+
+ await AddObjectStorage.methods.addObjectStore(params)
+
+ expect(mockAxios).toHaveBeenCalledTimes(1)
+ const request = mockAxios.mock.calls[0][0]
+ expect(request).toMatchObject({
+ url: '/',
+ method: 'POST'
+ })
+ expect(request.params).toBeUndefined()
+ expect(request.data).toBeInstanceOf(URLSearchParams)
+ expect(request.data.get('command')).toBe('addObjectStoragePool')
+ expect(request.data.get('response')).toBe('json')
+ expect(request.data.get('url')).toBe(params.url)
+
expect(request.data.get('details[0].value')).toBe(params['details[0].value'])
+
expect(request.data.get('details[1].value')).toBe(params['details[1].value'])
+ })
+
+ it('propagates an addObjectStoragePool API failure', async () => {
+ const error = new Error('request failed')
+ mockAxios.mockRejectedValue(error)
+
+ await
expect(AddObjectStorage.methods.addObjectStore({})).rejects.toBe(error)
+ })
+
+ it('masks the object storage secret key', () => {
+ const wrapper = mount(AddObjectStorage, {
+ props: {
+ resource: {}
+ },
+ global: {
+ mocks: {
+ $getApiParams: jest.fn(() => ({
+ name: {},
+ provider: {},
+ url: {},
+ size: {}
+ })),
+ $t: key => key
+ },
+ provide: {
+ parentFetchData: jest.fn()
+ }
+ }
+ })
+
+ const secretKeyInput = wrapper.find('input[type="password"]')
+
+ expect(secretKeyInput.exists()).toBe(true)
+ expect(secretKeyInput.attributes('autocomplete')).toBe('off')
+ })
+})
diff --git a/utils/src/main/java/com/cloud/utils/StringUtils.java
b/utils/src/main/java/com/cloud/utils/StringUtils.java
index ef09c29edbc..e581a0e4795 100644
--- a/utils/src/main/java/com/cloud/utils/StringUtils.java
+++ b/utils/src/main/java/com/cloud/utils/StringUtils.java
@@ -147,7 +147,7 @@ public class StringUtils extends
org.apache.commons.lang3.StringUtils {
// removes a password/accesskey/ property from a response json object
private static final Pattern REGEX_PASSWORD_JSON =
Pattern.compile("\"(([pP])assword|privatekey|accesskey|secretkey)\":\\s?\".*?\",?");
- private static final Pattern REGEX_PASSWORD_DETAILS =
Pattern.compile("(&|%26)?details(\\[|%5B)\\d*(\\]|%5D)\\.key(=|%3D)(([pP])assword|accesskey|secretkey)(?=(%26|[&'\"]))");
+ private static final Pattern REGEX_PASSWORD_DETAILS =
Pattern.compile("(&|%26)?details(\\[|%5B)\\d*(\\]|%5D)\\.key(=|%3D)(([pP])assword|accesskey|secretkey)(?=(%26|[&'\"]|$))");
private static final Pattern REGEX_PASSWORD_DETAILS_INDEX =
Pattern.compile("details(\\[|%5B)\\d*(\\]|%5D)");
diff --git a/utils/src/test/java/com/cloud/utils/StringUtilsTest.java
b/utils/src/test/java/com/cloud/utils/StringUtilsTest.java
index 8a41f956043..62beec11535 100644
--- a/utils/src/test/java/com/cloud/utils/StringUtilsTest.java
+++ b/utils/src/test/java/com/cloud/utils/StringUtilsTest.java
@@ -277,4 +277,33 @@ public class StringUtilsTest {
assertEquals(key, output.first());
assertEquals(value, output.second());
}
+
+ @Test
+ public void testCleanSensitiveDetailsAtEndOfQuery() {
+ for (String index : new String[] {"[1]", "%5B1%5D"}) {
+ for (String key : new String[] {"password", "Password",
"accesskey", "secretkey"}) {
+ String query = "command=addImageStore&name=store-1"
+ + "&details[2].key=bucket&details[2].value=archive"
+ + "&details" + index +
".value=SECRET_SENTINEL&details" + index + ".key=" + key;
+ assertEquals("Terminal descriptor " + key + " at " + index,
+
"command=addImageStore&name=store-1&details[2].key=bucket&details[2].value=archive&",
+ StringUtils.cleanString(query));
+ }
+ }
+ }
+
+ @Test
+ public void
testCleanDetailsDoesNotTreatSensitiveKeyPrefixesAsCredentials() {
+ for (String key : new String[] {"secretkeyname", "accesskeyid",
"passwordenabled", "bucket"}) {
+ String query =
"command=addImageStore&details[1].value=retained-value&details[1].key=" + key;
+ assertEquals(query, StringUtils.cleanString(query));
+ }
+ }
+
+ @Test
+ public void testCleanSensitiveDetailsAtEndOfEncodedQuery() {
+ String query = "command%3DaddImageStore%26name%3Dstore-1"
+ +
"%26details%5B1%5D.value%3DSECRET_SENTINEL%26details%5B1%5D.key%3Dsecretkey";
+ assertEquals("command%3DaddImageStore%26name%3Dstore-1&",
StringUtils.cleanString(query));
+ }
}