Skip to content

Commit 76b997e

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

File tree

261 files changed

+1593
-1483
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

+1593
-1483
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
@@ -209,6 +209,7 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
209209
private static final String SANITIZATION_REGEX = "[\n\r]";
210210

211211
private static boolean encodeApiResponse = false;
212+
private boolean isPostRequestsAndTimestampsEnforced = false;
212213

213214
/**
214215
* Non-printable ASCII characters - numbers 0 to 31 and 127 decimal
@@ -298,6 +299,13 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
298299
, "Session cookie is marked as secure if this is enabled. Secure cookies only work when HTTPS is used."
299300
, false
300301
, ConfigKey.Scope.Global);
302+
static final ConfigKey<Boolean> EnforcePostRequestsAndTimestamps = new ConfigKey<>(ConfigKey.CATEGORY_ADVANCED
303+
, Boolean.class
304+
, "enforce.post.requests.and.timestamps"
305+
, "false"
306+
, "Enable/Disable whether the ApiServer should only accept POST requests for state-changing APIs and requests with timestamps."
307+
, false
308+
, ConfigKey.Scope.Global);
301309
private static final ConfigKey<String> JSONDefaultContentType = new ConfigKey<> (ConfigKey.CATEGORY_ADVANCED
302310
, String.class
303311
, "json.content.type"
@@ -476,6 +484,7 @@ protected void setupIntegrationPortListener(Integer apiPort) {
476484
public boolean start() {
477485
Security.addProvider(new BouncyCastleProvider());
478486
Integer apiPort = IntegrationAPIPort.value(); // api port, null by default
487+
isPostRequestsAndTimestampsEnforced = EnforcePostRequestsAndTimestamps.value();
479488

480489
final Long snapshotLimit = ConcurrentSnapshotsThresholdPerHost.value();
481490
if (snapshotLimit == null || snapshotLimit <= 0) {
@@ -755,6 +764,11 @@ public String handleRequest(final Map params, final String responseType, final S
755764
return response;
756765
}
757766

767+
@Override
768+
public boolean isPostRequestsAndTimestampsEnforced() {
769+
return isPostRequestsAndTimestampsEnforced;
770+
}
771+
758772
private String getBaseAsyncResponse(final long jobId, final BaseAsyncCmd cmd) {
759773
final AsyncJobResponse response = new AsyncJobResponse();
760774

@@ -1002,7 +1016,6 @@ public boolean verifyRequest(final Map<String, Object[]> requestParameters, fina
10021016

10031017
// put the name in a list that we'll sort later
10041018
final List<String> parameterNames = new ArrayList<>(requestParameters.keySet());
1005-
10061019
Collections.sort(parameterNames);
10071020

10081021
String signatureVersion = null;
@@ -1054,12 +1067,22 @@ public boolean verifyRequest(final Map<String, Object[]> requestParameters, fina
10541067
}
10551068

10561069
final Date now = new Date(System.currentTimeMillis());
1070+
final Date thresholdTime = new Date(now.getTime() + 15 * 60 * 1000);
10571071
if (expiresTS.before(now)) {
10581072
signature = signature.replaceAll(SANITIZATION_REGEX, "_");
10591073
apiKey = apiKey.replaceAll(SANITIZATION_REGEX, "_");
10601074
logger.debug("Request expired -- ignoring ...sig [{}], apiKey [{}].", signature, apiKey);
10611075
return false;
1076+
} else if (isPostRequestsAndTimestampsEnforced && expiresTS.after(thresholdTime)) {
1077+
signature = signature.replaceAll(SANITIZATION_REGEX, "_");
1078+
apiKey = apiKey.replaceAll(SANITIZATION_REGEX, "_");
1079+
logger.debug(String.format("Expiration parameter is set for too long -- ignoring ...sig [%s], apiKey [%s].", signature, apiKey));
1080+
return false;
10621081
}
1082+
} else if (isPostRequestsAndTimestampsEnforced) {
1083+
// Force expiration parameter
1084+
logger.debug("Signature Version must be 3, and should be along with the Expires parameter -- ignoring request.");
1085+
return false;
10631086
}
10641087

10651088
final TransactionLegacy txn = TransactionLegacy.open(TransactionLegacy.CLOUD_DB);
@@ -1751,6 +1774,7 @@ public String getConfigComponentName() {
17511774
@Override
17521775
public ConfigKey<?>[] getConfigKeys() {
17531776
return new ConfigKey<?>[] {
1777+
EnforcePostRequestsAndTimestamps,
17541778
IntegrationAPIPort,
17551779
ConcurrentSnapshotsThresholdPerHost,
17561780
EncodeApiResponse,

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

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,13 @@
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;
2728
import java.util.Collections;
29+
import java.util.regex.Pattern;
30+
import java.util.Set;
31+
2832
import javax.inject.Inject;
2933
import javax.servlet.ServletConfig;
3034
import javax.servlet.ServletException;
@@ -46,6 +50,7 @@
4650
import org.apache.cloudstack.context.CallContext;
4751
import org.apache.cloudstack.managed.context.ManagedContext;
4852
import org.apache.cloudstack.utils.consoleproxy.ConsoleAccessUtils;
53+
import org.apache.commons.collections.MapUtils;
4954

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

82120
@Inject
83121
ApiServerService apiServer;
@@ -193,11 +231,24 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp
193231

194232
utf8Fixup(req, params);
195233

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

@@ -213,8 +264,6 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp
213264
responseType = (String)responseTypeParam[0];
214265
}
215266

216-
final Object[] commandObj = params.get(ApiConstants.COMMAND);
217-
final String command = commandObj == null ? null : (String) commandObj[0];
218267
final Object[] userObj = params.get(ApiConstants.USERNAME);
219268
String username = userObj == null ? null : (String)userObj[0];
220269
if (LOGGER.isTraceEnabled()) {
@@ -317,6 +366,19 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp
317366
}
318367
}
319368

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

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

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

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)