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 460b8d642e90..c24d61b7200b 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.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 c7aeb8ba99bf..1669bcb60e6a 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 @@ 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 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,12 @@ public void testAddObjectStore() throws DiscoveryException { 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()); + } } diff --git a/server/src/main/java/com/cloud/api/ApiServlet.java b/server/src/main/java/com/cloud/api/ApiServlet.java index 3ac5bbb01a75..9f9deb0fa8f9 100644 --- a/server/src/main/java/com/cloud/api/ApiServlet.java +++ b/server/src/main/java/com/cloud/api/ApiServlet.java @@ -88,7 +88,7 @@ public class ApiServlet extends HttpServlet { private static final Pattern GET_REQUEST_COMMANDS = Pattern.compile("^(get|list|query|find)(\\w+)+$"); private static final HashSet GET_REQUEST_COMMANDS_LIST = new HashSet<>(Set.of("isaccountallowedtocreateofferingswithtags", "readyforshutdown", "cloudianisenabled", "quotabalance", "quotasummary", "quotatarifflist", "quotaisenabled", "quotastatement", "verifyoauthcodeandgetuser")); - private static final HashSet POST_REQUESTS_TO_DISABLE_LOGGING = new HashSet<>(Set.of( + private static final HashSet REQUESTS_TO_DISABLE_PARAMETER_LOGGING = new HashSet<>(Set.of( "login", "oauthlogin", "createaccount", @@ -100,6 +100,7 @@ public class ApiServlet extends HttpServlet { "updaterolepermission", "updateprojectrolepermission", "createstoragepool", + "addobjectstoragepool", "addhost", "updatehostpassword", "addcluster", @@ -237,17 +238,15 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp // 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); @@ -771,4 +770,17 @@ private String getCleanParamsString(Map reqParams) { return cleanParamsString.toString(); } + + protected boolean shouldLogRequestParameters(String command, Map reqParams) { + return (org.apache.commons.lang3.StringUtils.isBlank(command) + || !REQUESTS_TO_DISABLE_PARAMETER_LOGGING.contains(command.toLowerCase(java.util.Locale.ROOT))) + && !reqParams.containsKey(ApiConstants.USER_DATA); + } + + protected String getCleanQueryString(String command, String queryString, Map reqParams) { + if (!shouldLogRequestParameters(command, reqParams)) { + return org.apache.commons.lang3.StringUtils.isBlank(command) ? "" : "command=" + saveLogString(command); + } + return StringUtils.cleanString(queryString); + } } diff --git a/server/src/test/java/com/cloud/api/ApiServletTest.java b/server/src/test/java/com/cloud/api/ApiServletTest.java index 79fe4b86f859..1470979f2d2a 100644 --- a/server/src/test/java/com/cloud/api/ApiServletTest.java +++ b/server/src/test/java/com/cloud/api/ApiServletTest.java @@ -461,4 +461,62 @@ public void testVerify2FAWhenExpectedCommandIsNotCalled() throws UnknownHostExce Assert.assertEquals(false, result); } + + @Test + public void shouldNotLogRequestParametersForAddObjectStoragePool() { + boolean result = servlet.shouldLogRequestParameters("addObjectStoragePool", new HashMap<>()); + + Assert.assertFalse(result); + } + + @Test + public void shouldLogRequestParametersForCommandWithoutSensitiveParameters() { + boolean result = servlet.shouldLogRequestParameters("listZones", new HashMap<>()); + + Assert.assertTrue(result); + } + + @Test + public void shouldNotLogRequestParametersContainingUserData() { + Map params = new HashMap<>(); + params.put(ApiConstants.USER_DATA, new String[] {"sensitive-user-data"}); + + boolean result = servlet.shouldLogRequestParameters("deployVirtualMachine", params); + + Assert.assertFalse(result); + } + + @Test + public void shouldReplaceQueryStringContainingUserDataWithCommandName() { + Map params = new HashMap<>(); + params.put(ApiConstants.USER_DATA, new String[] {"SYNTHETIC_USER_DATA"}); + String queryString = "command=deployVirtualMachine&userdata=SYNTHETIC_USER_DATA"; + + String result = servlet.getCleanQueryString("deployVirtualMachine", queryString, params); + + Assert.assertEquals("command=deployVirtualMachine", result); + Assert.assertFalse(result.contains("SYNTHETIC_USER_DATA")); + } + + @Test + public void shouldReplaceSensitiveQueryStringWithCommandName() { + Map params = new HashMap<>(); + String queryString = "command=addObjectStoragePool&details%5B1%5D.value=SYNTHETIC_SECRET_KEY"; + + String result = servlet.getCleanQueryString("addObjectStoragePool", queryString, params); + + Assert.assertEquals("command=addObjectStoragePool", result); + Assert.assertFalse(result.contains("SYNTHETIC_SECRET_KEY")); + } + + @Test + public void shouldKeepOrdinaryQueryString() { + Map params = new HashMap<>(); + String queryString = "command=listZones&response=json"; + + String result = servlet.getCleanQueryString("listZones", queryString, params); + + Assert.assertEquals(queryString, result); + } + } diff --git a/ui/src/utils/apiError.js b/ui/src/utils/apiError.js new file mode 100644 index 000000000000..356a55c5b703 --- /dev/null +++ b/ui/src/utils/apiError.js @@ -0,0 +1,56 @@ +// 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. + +function cleanLogValue (value) { + if (typeof value !== 'string') { + return undefined + } + return value.replace(/[\n\r\t]/g, '_').slice(0, 256) +} + +function getCommand (config) { + if (config?.params?.command) { + return config.params.command + } + + const data = config?.data + if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) { + return data.get('command') + } + if (typeof data === 'string') { + return new URLSearchParams(data).get('command') + } +} + +export function getSafeApiErrorDetails (error) { + const response = error?.response + const config = response?.config || error?.config + const method = cleanLogValue(config?.method) + + return { + name: cleanLogValue(error?.name), + code: cleanLogValue(error?.code), + status: Number.isInteger(response?.status) ? response.status : undefined, + statusText: cleanLogValue(response?.statusText), + method: typeof method === 'string' ? method.toUpperCase() : method, + command: cleanLogValue(getCommand(config)) + } +} + +export function logApiError (error) { + console.error('CloudStack API request failed', getSafeApiErrorDetails(error)) +} diff --git a/ui/src/utils/plugins.js b/ui/src/utils/plugins.js index 729cef84d021..0ca292e92bf0 100644 --- a/ui/src/utils/plugins.js +++ b/ui/src/utils/plugins.js @@ -23,6 +23,7 @@ import eventBus from '@/config/eventBus' import store from '@/store' import { sourceToken } from '@/utils/request' import { toLocalDate, toLocaleDate } from '@/utils/date' +import { logApiError } from '@/utils/apiError' export const pollJobPlugin = { install (app) { @@ -217,7 +218,7 @@ export const pollJobPlugin = { export const notifierPlugin = { install (app) { app.config.globalProperties.$notifyError = function (error) { - console.log(error) + logApiError(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 2317aac04465..3c75d636f096 100644 --- a/ui/src/utils/request.js +++ b/ui/src/utils/request.js @@ -24,6 +24,7 @@ import notification from 'ant-design-vue/es/notification' import { CURRENT_PROJECT } from '@/store/mutation-types' import { i18n } from '@/locales' import store from '@/store' +import { logApiError } from '@/utils/apiError' let source const service = axios.create({ @@ -33,8 +34,8 @@ const service = axios.create({ const err = (error) => { const response = error.response let countNotify = store.getters.countNotify + logApiError(error) 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 5410a9b9502f..8c206ffb9d07 100644 --- a/ui/src/views/infra/AddObjectStorage.vue +++ b/ui/src/views/infra/AddObjectStorage.vue @@ -87,7 +87,7 @@ - +