Skip to content

Commit d4712cd

Browse files
committed
UI: prevent credential exposure in object storage errors
1 parent fdb1c14 commit d4712cd

8 files changed

Lines changed: 281 additions & 19 deletions

File tree

server/src/main/java/com/cloud/api/ApiServlet.java

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public class ApiServlet extends HttpServlet {
8888
private static final Pattern GET_REQUEST_COMMANDS = Pattern.compile("^(get|list|query|find)(\\w+)+$");
8989
private static final HashSet<String> GET_REQUEST_COMMANDS_LIST = new HashSet<>(Set.of("isaccountallowedtocreateofferingswithtags",
9090
"readyforshutdown", "cloudianisenabled", "quotabalance", "quotasummary", "quotatarifflist", "quotaisenabled", "quotastatement", "verifyoauthcodeandgetuser"));
91-
private static final HashSet<String> POST_REQUESTS_TO_DISABLE_LOGGING = new HashSet<>(Set.of(
91+
private static final HashSet<String> REQUESTS_TO_DISABLE_PARAMETER_LOGGING = new HashSet<>(Set.of(
9292
"login",
9393
"oauthlogin",
9494
"createaccount",
@@ -197,11 +197,11 @@ public void run() {
197197
});
198198
}
199199

200-
private void checkSingleQueryParameterValue(Map<String, String[]> params) {
200+
protected void checkSingleQueryParameterValue(Map<String, String[]> params) {
201201
params.forEach((k, v) -> {
202202
if (v.length > 1) {
203-
String message = String.format("Query parameter '%s' has multiple values %s. Only the last value will be respected." +
204-
"It is advised to pass only a single parameter", k, Arrays.toString(v));
203+
String message = String.format("Query parameter '%s' has %d values. Only the last value will be respected. " +
204+
"It is advised to pass only a single parameter", saveLogString(k), v.length);
205205
LOGGER.warn(message);
206206
}
207207
});
@@ -238,17 +238,15 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp
238238

239239
// logging the request start and end in management log for easy debugging
240240
String reqStr = "";
241-
String cleanQueryString = StringUtils.cleanString(req.getQueryString());
241+
String cleanQueryString = getCleanQueryString(command, req.getQueryString(), reqParams);
242242
if (LOGGER.isDebugEnabled()) {
243243
reqStr = auditTrailSb.toString() + " " + cleanQueryString;
244244
if (req.getMethod().equalsIgnoreCase("POST") && org.apache.commons.lang3.StringUtils.isNotBlank(command)) {
245-
if (shouldLogPostRequestParameters(command, reqParams)) {
245+
if (shouldLogRequestParameters(command, reqParams)) {
246246
String cleanParamsString = getCleanParamsString(reqParams);
247247
if (org.apache.commons.lang3.StringUtils.isNotBlank(cleanParamsString)) {
248248
reqStr += "\n" + cleanParamsString;
249249
}
250-
} else {
251-
reqStr += " " + command;
252250
}
253251
}
254252
LOGGER.debug("===START=== " + reqStr);
@@ -773,7 +771,16 @@ private String getCleanParamsString(Map<String, String[]> reqParams) {
773771
return cleanParamsString.toString();
774772
}
775773

776-
protected boolean shouldLogPostRequestParameters(String command, Map<String, String[]> reqParams) {
777-
return !POST_REQUESTS_TO_DISABLE_LOGGING.contains(command.toLowerCase()) && !reqParams.containsKey(ApiConstants.USER_DATA);
774+
protected boolean shouldLogRequestParameters(String command, Map<String, String[]> reqParams) {
775+
return (org.apache.commons.lang3.StringUtils.isBlank(command)
776+
|| !REQUESTS_TO_DISABLE_PARAMETER_LOGGING.contains(command.toLowerCase(java.util.Locale.ROOT)))
777+
&& !reqParams.containsKey(ApiConstants.USER_DATA);
778+
}
779+
780+
protected String getCleanQueryString(String command, String queryString, Map<String, String[]> reqParams) {
781+
if (!shouldLogRequestParameters(command, reqParams)) {
782+
return org.apache.commons.lang3.StringUtils.isBlank(command) ? "" : "command=" + saveLogString(command);
783+
}
784+
return StringUtils.cleanString(queryString);
778785
}
779786
}

server/src/test/java/com/cloud/api/ApiServletTest.java

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@
3434
import org.apache.cloudstack.api.command.admin.config.ListCfgsByCmd;
3535
import org.apache.cloudstack.framework.config.ConfigKey;
3636
import org.apache.cloudstack.framework.config.impl.ConfigDepotImpl;
37+
import org.apache.logging.log4j.Logger;
3738
import org.junit.After;
3839
import org.junit.Assert;
3940
import org.junit.Before;
4041
import org.junit.Test;
4142
import org.junit.runner.RunWith;
43+
import org.mockito.ArgumentCaptor;
4244
import org.mockito.Mock;
4345
import org.mockito.Mockito;
4446
import org.mockito.junit.MockitoJUnitRunner;
@@ -463,26 +465,81 @@ public void testVerify2FAWhenExpectedCommandIsNotCalled() throws UnknownHostExce
463465
}
464466

465467
@Test
466-
public void shouldNotLogPostRequestParametersForAddObjectStoragePool() {
467-
boolean result = servlet.shouldLogPostRequestParameters("addObjectStoragePool", new HashMap<>());
468+
public void shouldNotLogRequestParametersForAddObjectStoragePool() {
469+
boolean result = servlet.shouldLogRequestParameters("addObjectStoragePool", new HashMap<>());
468470

469471
Assert.assertFalse(result);
470472
}
471473

472474
@Test
473-
public void shouldLogPostRequestParametersForCommandWithoutSensitiveParameters() {
474-
boolean result = servlet.shouldLogPostRequestParameters("listZones", new HashMap<>());
475+
public void shouldLogRequestParametersForCommandWithoutSensitiveParameters() {
476+
boolean result = servlet.shouldLogRequestParameters("listZones", new HashMap<>());
475477

476478
Assert.assertTrue(result);
477479
}
478480

479481
@Test
480-
public void shouldNotLogPostRequestParametersContainingUserData() {
482+
public void shouldNotLogRequestParametersContainingUserData() {
481483
Map<String, String[]> params = new HashMap<>();
482484
params.put(ApiConstants.USER_DATA, new String[] {"sensitive-user-data"});
483485

484-
boolean result = servlet.shouldLogPostRequestParameters("deployVirtualMachine", params);
486+
boolean result = servlet.shouldLogRequestParameters("deployVirtualMachine", params);
485487

486488
Assert.assertFalse(result);
487489
}
490+
491+
@Test
492+
public void shouldReplaceQueryStringContainingUserDataWithCommandName() {
493+
Map<String, String[]> params = new HashMap<>();
494+
params.put(ApiConstants.USER_DATA, new String[] {"SYNTHETIC_USER_DATA"});
495+
String queryString = "command=deployVirtualMachine&userdata=SYNTHETIC_USER_DATA";
496+
497+
String result = servlet.getCleanQueryString("deployVirtualMachine", queryString, params);
498+
499+
Assert.assertEquals("command=deployVirtualMachine", result);
500+
Assert.assertFalse(result.contains("SYNTHETIC_USER_DATA"));
501+
}
502+
503+
@Test
504+
public void shouldReplaceSensitiveQueryStringWithCommandName() {
505+
Map<String, String[]> params = new HashMap<>();
506+
String queryString = "command=addObjectStoragePool&details%5B1%5D.value=SYNTHETIC_SECRET_KEY";
507+
508+
String result = servlet.getCleanQueryString("addObjectStoragePool", queryString, params);
509+
510+
Assert.assertEquals("command=addObjectStoragePool", result);
511+
Assert.assertFalse(result.contains("SYNTHETIC_SECRET_KEY"));
512+
}
513+
514+
@Test
515+
public void shouldKeepOrdinaryQueryString() {
516+
Map<String, String[]> params = new HashMap<>();
517+
String queryString = "command=listZones&response=json";
518+
519+
String result = servlet.getCleanQueryString("listZones", queryString, params);
520+
521+
Assert.assertEquals(queryString, result);
522+
}
523+
524+
@Test
525+
public void shouldLogDuplicateParameterNameAndCountWithoutValues() {
526+
Logger originalLogger = ApiServlet.LOGGER;
527+
Logger logger = Mockito.mock(Logger.class);
528+
ApiServlet.LOGGER = logger;
529+
Map<String, String[]> params = new HashMap<>();
530+
params.put("details[1].value", new String[] {"SYNTHETIC_SECRET_ONE", "SYNTHETIC_SECRET_TWO"});
531+
532+
try {
533+
servlet.checkSingleQueryParameterValue(params);
534+
535+
ArgumentCaptor<String> message = ArgumentCaptor.forClass(String.class);
536+
Mockito.verify(logger).warn(message.capture());
537+
Assert.assertTrue(message.getValue().contains("details[1].value"));
538+
Assert.assertTrue(message.getValue().contains("2 values"));
539+
Assert.assertFalse(message.getValue().contains("SYNTHETIC_SECRET_ONE"));
540+
Assert.assertFalse(message.getValue().contains("SYNTHETIC_SECRET_TWO"));
541+
} finally {
542+
ApiServlet.LOGGER = originalLogger;
543+
}
544+
}
488545
}

ui/src/utils/apiError.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
function cleanLogValue (value) {
19+
if (typeof value !== 'string') {
20+
return value
21+
}
22+
return value.replace(/[\n\r\t]/g, '_').slice(0, 256)
23+
}
24+
25+
function getCommand (config) {
26+
if (config?.params?.command) {
27+
return config.params.command
28+
}
29+
30+
const data = config?.data
31+
if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) {
32+
return data.get('command')
33+
}
34+
if (typeof data === 'string') {
35+
return new URLSearchParams(data).get('command')
36+
}
37+
}
38+
39+
export function getSafeApiErrorDetails (error) {
40+
const response = error?.response
41+
const config = response?.config || error?.config
42+
const method = cleanLogValue(config?.method)
43+
44+
return {
45+
name: cleanLogValue(error?.name),
46+
code: cleanLogValue(error?.code),
47+
status: response?.status,
48+
statusText: cleanLogValue(response?.statusText),
49+
method: typeof method === 'string' ? method.toUpperCase() : method,
50+
command: cleanLogValue(getCommand(config))
51+
}
52+
}
53+
54+
export function logApiError (error) {
55+
console.error('CloudStack API request failed', getSafeApiErrorDetails(error))
56+
}

ui/src/utils/plugins.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import eventBus from '@/config/eventBus'
2323
import store from '@/store'
2424
import { sourceToken } from '@/utils/request'
2525
import { toLocalDate, toLocaleDate } from '@/utils/date'
26+
import { logApiError } from '@/utils/apiError'
2627

2728
export const pollJobPlugin = {
2829
install (app) {
@@ -217,7 +218,7 @@ export const pollJobPlugin = {
217218
export const notifierPlugin = {
218219
install (app) {
219220
app.config.globalProperties.$notifyError = function (error) {
220-
console.log(error)
221+
logApiError(error)
221222
var msg = i18n.global.t('message.request.failed')
222223
var desc = ''
223224
if (error && error.response) {

ui/src/utils/request.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import notification from 'ant-design-vue/es/notification'
2424
import { CURRENT_PROJECT } from '@/store/mutation-types'
2525
import { i18n } from '@/locales'
2626
import store from '@/store'
27+
import { logApiError } from '@/utils/apiError'
2728

2829
let source
2930
const service = axios.create({
@@ -33,8 +34,8 @@ const service = axios.create({
3334
const err = (error) => {
3435
const response = error.response
3536
let countNotify = store.getters.countNotify
37+
logApiError(error)
3638
if (response) {
37-
console.log(response)
3839
if (response.status === 403) {
3940
const data = response.data
4041
countNotify++

ui/src/views/infra/AddObjectStorage.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
<a-input v-model:value="form.accessKey" />
8888
</a-form-item>
8989
<a-form-item name="secretKey" ref="secretKey" :label="$t('label.secret.key')">
90-
<a-input v-model:value="form.secretKey" />
90+
<a-input-password v-model:value="form.secretKey" autocomplete="off" />
9191
</a-form-item>
9292
<a-form-item name="size" ref="size">
9393
<template #label>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
import { getSafeApiErrorDetails, logApiError } from '@/utils/apiError'
19+
20+
describe('API error logging', () => {
21+
const accessKey = 'SYNTHETIC_ACCESS_KEY'
22+
const secretKey = 'SYNTHETIC_SECRET_KEY'
23+
const sessionKey = 'SYNTHETIC_SESSION_KEY'
24+
25+
function createAxiosError (data) {
26+
return {
27+
name: 'Error',
28+
code: 'ERR_BAD_RESPONSE',
29+
isAxiosError: true,
30+
response: {
31+
status: 500,
32+
statusText: 'Internal Server Error',
33+
data: {
34+
errorresponse: {
35+
errortext: secretKey
36+
}
37+
},
38+
config: {
39+
method: 'post',
40+
url: '/',
41+
data
42+
}
43+
}
44+
}
45+
}
46+
47+
it('keeps useful POST failure metadata without request or response secrets', () => {
48+
const data = new URLSearchParams()
49+
data.append('command', 'addObjectStoragePool')
50+
data.append('details[0].value', accessKey)
51+
data.append('details[1].value', secretKey)
52+
data.append('sessionkey', sessionKey)
53+
const error = createAxiosError(data.toString())
54+
55+
const details = getSafeApiErrorDetails(error)
56+
const serializedDetails = JSON.stringify(details)
57+
58+
expect(details).toEqual({
59+
name: 'Error',
60+
code: 'ERR_BAD_RESPONSE',
61+
status: 500,
62+
statusText: 'Internal Server Error',
63+
method: 'POST',
64+
command: 'addObjectStoragePool'
65+
})
66+
expect(serializedDetails).not.toContain(accessKey)
67+
expect(serializedDetails).not.toContain(secretKey)
68+
expect(serializedDetails).not.toContain(sessionKey)
69+
})
70+
71+
it('logs only the safe failure summary', () => {
72+
const data = new URLSearchParams()
73+
data.append('command', 'addObjectStoragePool')
74+
data.append('details[1].value', secretKey)
75+
const error = createAxiosError(data.toString())
76+
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {})
77+
78+
try {
79+
logApiError(error)
80+
81+
expect(consoleError).toHaveBeenCalledWith('CloudStack API request failed', {
82+
name: 'Error',
83+
code: 'ERR_BAD_RESPONSE',
84+
status: 500,
85+
statusText: 'Internal Server Error',
86+
method: 'POST',
87+
command: 'addObjectStoragePool'
88+
})
89+
expect(JSON.stringify(consoleError.mock.calls)).not.toContain(secretKey)
90+
} finally {
91+
consoleError.mockRestore()
92+
}
93+
})
94+
95+
it('does not copy GET parameters into the safe failure summary', () => {
96+
const error = createAxiosError()
97+
error.response.config.method = 'get'
98+
error.response.config.params = {
99+
command: 'addObjectStoragePool',
100+
accesskey: accessKey,
101+
secretkey: secretKey,
102+
sessionkey: sessionKey
103+
}
104+
105+
const serializedDetails = JSON.stringify(getSafeApiErrorDetails(error))
106+
107+
expect(serializedDetails).toContain('addObjectStoragePool')
108+
expect(serializedDetails).not.toContain(accessKey)
109+
expect(serializedDetails).not.toContain(secretKey)
110+
expect(serializedDetails).not.toContain(sessionKey)
111+
})
112+
})

0 commit comments

Comments
 (0)