Skip to content

Commit 749ddb9

Browse files
sureshanapartiKevin Li
andauthored
Support ApiServer to enforce POST requests for state changing APIs and requests with timestamps (#10899)
Co-authored-by: Kevin Li <[email protected]>
1 parent d5fd3ec commit 749ddb9

File tree

261 files changed

+1581
-1478
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

261 files changed

+1581
-1478
lines changed

api/src/main/java/org/apache/cloudstack/api/ApiErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323
public enum ApiErrorCode {
2424

25+
BAD_REQUEST(400),
2526
UNAUTHORIZED(401),
2627
UNAUTHORIZED2FA(511),
2728
METHOD_NOT_ALLOWED(405),

api/src/main/java/org/apache/cloudstack/api/ApiServerService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,6 @@ public ResponseObject loginUser(HttpSession session, String username, String pas
4848
boolean forgotPassword(UserAccount userAccount, Domain domain);
4949

5050
boolean resetPassword(UserAccount userAccount, String token, String password);
51+
52+
boolean isPostRequestsAndTimestampsEnforced();
5153
}

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
201201
private static final String SANITIZATION_REGEX = "[\n\r]";
202202

203203
private static boolean encodeApiResponse = false;
204+
private boolean isPostRequestsAndTimestampsEnforced = false;
204205

205206
/**
206207
* Non-printable ASCII characters - numbers 0 to 31 and 127 decimal
@@ -284,6 +285,13 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
284285
, "Session cookie is marked as secure if this is enabled. Secure cookies only work when HTTPS is used."
285286
, false
286287
, ConfigKey.Scope.Global);
288+
static final ConfigKey<Boolean> EnforcePostRequestsAndTimestamps = new ConfigKey<>(ConfigKey.CATEGORY_ADVANCED
289+
, Boolean.class
290+
, "enforce.post.requests.and.timestamps"
291+
, "false"
292+
, "Enable/Disable whether the ApiServer should only accept POST requests for state-changing APIs and requests with timestamps."
293+
, false
294+
, ConfigKey.Scope.Global);
287295
private static final ConfigKey<String> JSONDefaultContentType = new ConfigKey<> (ConfigKey.CATEGORY_ADVANCED
288296
, String.class
289297
, "json.content.type"
@@ -441,6 +449,7 @@ protected void setupIntegrationPortListener(Integer apiPort) {
441449
public boolean start() {
442450
Security.addProvider(new BouncyCastleProvider());
443451
Integer apiPort = IntegrationAPIPort.value(); // api port, null by default
452+
isPostRequestsAndTimestampsEnforced = EnforcePostRequestsAndTimestamps.value();
444453

445454
final Long snapshotLimit = ConcurrentSnapshotsThresholdPerHost.value();
446455
if (snapshotLimit == null || snapshotLimit <= 0) {
@@ -720,6 +729,11 @@ public String handleRequest(final Map params, final String responseType, final S
720729
return response;
721730
}
722731

732+
@Override
733+
public boolean isPostRequestsAndTimestampsEnforced() {
734+
return isPostRequestsAndTimestampsEnforced;
735+
}
736+
723737
private String getBaseAsyncResponse(final long jobId, final BaseAsyncCmd cmd) {
724738
final AsyncJobResponse response = new AsyncJobResponse();
725739

@@ -967,7 +981,6 @@ public boolean verifyRequest(final Map<String, Object[]> requestParameters, fina
967981

968982
// put the name in a list that we'll sort later
969983
final List<String> parameterNames = new ArrayList<>(requestParameters.keySet());
970-
971984
Collections.sort(parameterNames);
972985

973986
String signatureVersion = null;
@@ -1019,12 +1032,22 @@ public boolean verifyRequest(final Map<String, Object[]> requestParameters, fina
10191032
}
10201033

10211034
final Date now = new Date(System.currentTimeMillis());
1035+
final Date thresholdTime = new Date(now.getTime() + 15 * 60 * 1000);
10221036
if (expiresTS.before(now)) {
10231037
signature = signature.replaceAll(SANITIZATION_REGEX, "_");
10241038
apiKey = apiKey.replaceAll(SANITIZATION_REGEX, "_");
10251039
logger.debug("Request expired -- ignoring ...sig [{}], apiKey [{}].", signature, apiKey);
10261040
return false;
1041+
} else if (isPostRequestsAndTimestampsEnforced && expiresTS.after(thresholdTime)) {
1042+
signature = signature.replaceAll(SANITIZATION_REGEX, "_");
1043+
apiKey = apiKey.replaceAll(SANITIZATION_REGEX, "_");
1044+
logger.debug(String.format("Expiration parameter is set for too long -- ignoring ...sig [%s], apiKey [%s].", signature, apiKey));
1045+
return false;
10271046
}
1047+
} else if (isPostRequestsAndTimestampsEnforced) {
1048+
// Force expiration parameter
1049+
logger.debug("Signature Version must be 3, and should be along with the Expires parameter -- ignoring request.");
1050+
return false;
10281051
}
10291052

10301053
final TransactionLegacy txn = TransactionLegacy.open(TransactionLegacy.CLOUD_DB);
@@ -1648,6 +1671,7 @@ public String getConfigComponentName() {
16481671
@Override
16491672
public ConfigKey<?>[] getConfigKeys() {
16501673
return new ConfigKey<?>[] {
1674+
EnforcePostRequestsAndTimestamps,
16511675
IntegrationAPIPort,
16521676
ConcurrentSnapshotsThresholdPerHost,
16531677
EncodeApiResponse,

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

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@
2222
import java.net.UnknownHostException;
2323
import java.util.Arrays;
2424
import java.util.HashMap;
25+
import java.util.HashSet;
2526
import java.util.List;
2627
import java.util.Map;
28+
import java.util.regex.Pattern;
29+
import java.util.Set;
2730

2831
import javax.inject.Inject;
2932
import javax.servlet.ServletConfig;
@@ -46,6 +49,7 @@
4649
import org.apache.cloudstack.context.CallContext;
4750
import org.apache.cloudstack.managed.context.ManagedContext;
4851
import org.apache.cloudstack.utils.consoleproxy.ConsoleAccessUtils;
52+
import org.apache.commons.collections.MapUtils;
4953

5054
import org.apache.logging.log4j.Logger;
5155
import org.apache.logging.log4j.LogManager;
@@ -78,6 +82,39 @@ public class ApiServlet extends HttpServlet {
7882
private static final Logger ACCESSLOGGER = LogManager.getLogger("apiserver." + ApiServlet.class.getName());
7983
private static final String REPLACEMENT = "_";
8084
private static final String LOGGER_REPLACEMENTS = "[\n\r\t]";
85+
private static final Pattern GET_REQUEST_COMMANDS = Pattern.compile("^(get|list|query|find)(\\w+)+$");
86+
private static final HashSet<String> GET_REQUEST_COMMANDS_LIST = new HashSet<>(Set.of("isaccountallowedtocreateofferingswithtags",
87+
"readyforshutdown", "cloudianisenabled", "quotabalance", "quotasummary", "quotatarifflist", "quotaisenabled", "quotastatement", "verifyoauthcodeandgetuser"));
88+
private static final HashSet<String> POST_REQUESTS_TO_DISABLE_LOGGING = new HashSet<>(Set.of(
89+
"login",
90+
"oauthlogin",
91+
"createaccount",
92+
"createuser",
93+
"updateuser",
94+
"forgotpassword",
95+
"resetpassword",
96+
"importrole",
97+
"updaterolepermission",
98+
"updateprojectrolepermission",
99+
"createstoragepool",
100+
"addhost",
101+
"updatehostpassword",
102+
"addcluster",
103+
"addvmwaredc",
104+
"configureoutofbandmanagement",
105+
"uploadcustomcertificate",
106+
"addciscovnmcresource",
107+
"addnetscalerloadbalancer",
108+
"createtungstenfabricprovider",
109+
"addnsxcontroller",
110+
"configtungstenfabricservice",
111+
"createnetworkacl",
112+
"updatenetworkaclitem",
113+
"quotavalidateactivationrule",
114+
"quotatariffupdate",
115+
"listandswitchsamlaccount",
116+
"uploadresourceicon"
117+
));
81118

82119
@Inject
83120
ApiServerService apiServer;
@@ -193,11 +230,24 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp
193230

194231
utf8Fixup(req, params);
195232

233+
final Object[] commandObj = params.get(ApiConstants.COMMAND);
234+
final String command = commandObj == null ? null : (String) commandObj[0];
235+
196236
// logging the request start and end in management log for easy debugging
197237
String reqStr = "";
198238
String cleanQueryString = StringUtils.cleanString(req.getQueryString());
199239
if (LOGGER.isDebugEnabled()) {
200240
reqStr = auditTrailSb.toString() + " " + cleanQueryString;
241+
if (req.getMethod().equalsIgnoreCase("POST") && org.apache.commons.lang3.StringUtils.isNotBlank(command)) {
242+
if (!POST_REQUESTS_TO_DISABLE_LOGGING.contains(command.toLowerCase()) && !reqParams.containsKey(ApiConstants.USER_DATA)) {
243+
String cleanParamsString = getCleanParamsString(reqParams);
244+
if (org.apache.commons.lang3.StringUtils.isNotBlank(cleanParamsString)) {
245+
reqStr += "\n" + cleanParamsString;
246+
}
247+
} else {
248+
reqStr += " " + command;
249+
}
250+
}
201251
LOGGER.debug("===START=== " + reqStr);
202252
}
203253

@@ -213,8 +263,6 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp
213263
responseType = (String)responseTypeParam[0];
214264
}
215265

216-
final Object[] commandObj = params.get(ApiConstants.COMMAND);
217-
final String command = commandObj == null ? null : (String) commandObj[0];
218266
final Object[] userObj = params.get(ApiConstants.USERNAME);
219267
String username = userObj == null ? null : (String)userObj[0];
220268
if (LOGGER.isTraceEnabled()) {
@@ -317,6 +365,19 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp
317365
}
318366
}
319367

368+
if (apiServer.isPostRequestsAndTimestampsEnforced() && !isStateChangingCommandUsingPOST(command, req.getMethod(), params)) {
369+
String errorText = String.format("State changing command %s needs to be sent using POST request", command);
370+
if (command.equalsIgnoreCase("updateConfiguration") && params.containsKey("name")) {
371+
errorText = String.format("Changes for configuration %s needs to be sent using POST request", params.get("name")[0]);
372+
}
373+
auditTrailSb.append(" " + HttpServletResponse.SC_BAD_REQUEST + " " + errorText);
374+
final String serializedResponse =
375+
apiServer.getSerializedApiError(new ServerApiException(ApiErrorCode.BAD_REQUEST, errorText), params,
376+
responseType);
377+
HttpUtils.writeHttpResponse(resp, serializedResponse, HttpServletResponse.SC_BAD_REQUEST, responseType, ApiServer.JSONcontentType.value());
378+
return;
379+
}
380+
320381
Long userId = null;
321382
if (!isNew) {
322383
userId = (Long)session.getAttribute("userid");
@@ -407,6 +468,15 @@ private boolean checkIfAuthenticatorIsOf2FA(String command) {
407468
return verify2FA;
408469
}
409470

471+
private boolean isStateChangingCommandUsingPOST(String command, String method, Map<String, Object[]> params) {
472+
if (command == null || (!GET_REQUEST_COMMANDS.matcher(command.toLowerCase()).matches() && !GET_REQUEST_COMMANDS_LIST.contains(command.toLowerCase())
473+
&& !command.equalsIgnoreCase("updateConfiguration") && !method.equals("POST"))) {
474+
return false;
475+
}
476+
return !command.equalsIgnoreCase("updateConfiguration") || method.equals("POST") || (params.containsKey("name")
477+
&& params.get("name")[0].toString().equalsIgnoreCase(ApiServer.EnforcePostRequestsAndTimestamps.key()));
478+
}
479+
410480
protected boolean skip2FAcheckForAPIs(String command) {
411481
boolean skip2FAcheck = false;
412482

@@ -644,4 +714,45 @@ private static String getCorrectIPAddress(String ip) {
644714
}
645715
return null;
646716
}
717+
718+
private String getCleanParamsString(Map<String, String[]> reqParams) {
719+
if (MapUtils.isEmpty(reqParams)) {
720+
return "";
721+
}
722+
723+
StringBuilder cleanParamsString = new StringBuilder();
724+
for (Map.Entry<String, String[]> reqParam : reqParams.entrySet()) {
725+
if (org.apache.commons.lang3.StringUtils.isBlank(reqParam.getKey())) {
726+
continue;
727+
}
728+
729+
cleanParamsString.append(reqParam.getKey());
730+
cleanParamsString.append("=");
731+
732+
if (reqParam.getKey().toLowerCase().contains("password")
733+
|| reqParam.getKey().toLowerCase().contains("privatekey")
734+
|| reqParam.getKey().toLowerCase().contains("accesskey")
735+
|| reqParam.getKey().toLowerCase().contains("secretkey")) {
736+
cleanParamsString.append("\n");
737+
continue;
738+
}
739+
740+
if (reqParam.getValue() == null || reqParam.getValue().length == 0) {
741+
cleanParamsString.append("\n");
742+
continue;
743+
}
744+
745+
for (String param : reqParam.getValue()) {
746+
if (org.apache.commons.lang3.StringUtils.isBlank(param)) {
747+
continue;
748+
}
749+
String cleanParamString = StringUtils.cleanString(param.trim());
750+
cleanParamsString.append(cleanParamString);
751+
cleanParamsString.append(" ");
752+
}
753+
cleanParamsString.append("\n");
754+
}
755+
756+
return cleanParamsString.toString();
757+
}
647758
}

ui/src/api/index.js

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,10 @@ import {
2323
ACCESS_TOKEN
2424
} from '@/store/mutation-types'
2525

26-
export function api (command, args = {}, method = 'GET', data = {}) {
27-
let params = {}
26+
export function getAPI (command, args = {}) {
2827
args.command = command
2928
args.response = 'json'
3029

31-
if (data) {
32-
params = new URLSearchParams()
33-
Object.entries(data).forEach(([key, value]) => {
34-
params.append(key, value)
35-
})
36-
}
37-
3830
const sessionkey = vueProps.$localStorage.get(ACCESS_TOKEN) || Cookies.get('sessionkey')
3931
if (sessionkey) {
4032
args.sessionkey = sessionkey
@@ -45,8 +37,30 @@ export function api (command, args = {}, method = 'GET', data = {}) {
4537
...args
4638
},
4739
url: '/',
48-
method,
49-
data: params || {}
40+
method: 'GET'
41+
})
42+
}
43+
44+
export function postAPI (command, data = {}) {
45+
const params = new URLSearchParams()
46+
params.append('command', command)
47+
params.append('response', 'json')
48+
if (data) {
49+
Object.entries(data).forEach(([key, value]) => {
50+
if (value !== undefined && value !== null && value !== '') {
51+
params.append(key, value)
52+
}
53+
})
54+
}
55+
56+
const sessionkey = vueProps.$localStorage.get(ACCESS_TOKEN) || Cookies.get('sessionkey')
57+
if (sessionkey) {
58+
params.append('sessionkey', sessionkey)
59+
}
60+
return axios({
61+
url: '/',
62+
method: 'POST',
63+
data: params
5064
})
5165
}
5266

@@ -56,7 +70,7 @@ export function login (arg) {
5670
}
5771

5872
// Logout before login is called to purge any duplicate sessionkey cookies
59-
api('logout')
73+
postAPI('logout')
6074

6175
const params = new URLSearchParams()
6276
params.append('command', 'login')
@@ -66,7 +80,7 @@ export function login (arg) {
6680
params.append('response', 'json')
6781
return axios({
6882
url: '/',
69-
method: 'post',
83+
method: 'POST',
7084
data: params,
7185
headers: {
7286
'content-type': 'application/x-www-form-urlencoded'
@@ -77,7 +91,7 @@ export function login (arg) {
7791
export function logout () {
7892
message.destroy()
7993
notification.destroy()
80-
return api('logout')
94+
return postAPI('logout')
8195
}
8296

8397
export function oauthlogin (arg) {
@@ -86,7 +100,7 @@ export function oauthlogin (arg) {
86100
}
87101

88102
// Logout before login is called to purge any duplicate sessionkey cookies
89-
api('logout')
103+
postAPI('logout')
90104

91105
const params = new URLSearchParams()
92106
params.append('command', 'oauthlogin')

ui/src/components/header/SamlDomainSwitcher.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151

5252
<script>
5353
import store from '@/store'
54-
import { api } from '@/api'
54+
import { postAPI } from '@/api'
5555
import _ from 'lodash'
5656
5757
export default {
@@ -73,7 +73,7 @@ export default {
7373
const samlAccounts = []
7474
const getNextPage = () => {
7575
this.loading = true
76-
api('listAndSwitchSamlAccount', { details: 'min', page: page, pageSize: 500 }).then(json => {
76+
postAPI('listAndSwitchSamlAccount', { details: 'min', page: page, pageSize: 500 }).then(json => {
7777
if (json && json.listandswitchsamlaccountresponse && json.listandswitchsamlaccountresponse.samluseraccount) {
7878
samlAccounts.push(...json.listandswitchsamlaccountresponse.samluseraccount)
7979
}
@@ -102,7 +102,7 @@ export default {
102102
},
103103
changeAccount (index) {
104104
const account = this.samlAccounts[index]
105-
api('listAndSwitchSamlAccount', {}, 'POST', {
105+
postAPI('listAndSwitchSamlAccount', {
106106
userid: account.userId,
107107
domainid: account.domainId
108108
}).then(response => {

0 commit comments

Comments
 (0)