Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
*/
public enum ApiErrorCode {

BAD_REQUEST(400),
UNAUTHORIZED(401),
UNAUTHORIZED2FA(511),
METHOD_NOT_ALLOWED(405),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,6 @@ public ResponseObject loginUser(HttpSession session, String username, String pas
boolean forgotPassword(UserAccount userAccount, Domain domain);

boolean resetPassword(UserAccount userAccount, String token, String password);

boolean isPostRequestsAndTimestampsEnforced();
}
26 changes: 25 additions & 1 deletion server/src/main/java/com/cloud/api/ApiServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
private static final String SANITIZATION_REGEX = "[\n\r]";

private static boolean encodeApiResponse = false;
private boolean isPostRequestsAndTimestampsEnforced = false;

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

Copilot AI Jun 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the configuration 'enforce.post.requests.and.timestamps' might change at runtime, consider updating isPostRequestsAndTimestampsEnforced dynamically rather than only at startup.

Suggested change
isPostRequestsAndTimestampsEnforced = EnforcePostRequestsAndTimestamps.value();
// Removed caching of isPostRequestsAndTimestampsEnforced to ensure dynamic updates.

Copilot uses AI. Check for mistakes.

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

@Override
public boolean isPostRequestsAndTimestampsEnforced() {
return isPostRequestsAndTimestampsEnforced;
}

private String getBaseAsyncResponse(final long jobId, final BaseAsyncCmd cmd) {
final AsyncJobResponse response = new AsyncJobResponse();

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

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

Collections.sort(parameterNames);

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

final Date now = new Date(System.currentTimeMillis());
final Date thresholdTime = new Date(now.getTime() + 15 * 60 * 1000);
if (expiresTS.before(now)) {
signature = signature.replaceAll(SANITIZATION_REGEX, "_");
apiKey = apiKey.replaceAll(SANITIZATION_REGEX, "_");
logger.debug("Request expired -- ignoring ...sig [{}], apiKey [{}].", signature, apiKey);
return false;
} else if (isPostRequestsAndTimestampsEnforced && expiresTS.after(thresholdTime)) {
signature = signature.replaceAll(SANITIZATION_REGEX, "_");
apiKey = apiKey.replaceAll(SANITIZATION_REGEX, "_");
logger.debug(String.format("Expiration parameter is set for too long -- ignoring ...sig [%s], apiKey [%s].", signature, apiKey));
return false;
}
} else if (isPostRequestsAndTimestampsEnforced) {
// Force expiration parameter
logger.debug("Signature Version must be 3, and should be along with the Expires parameter -- ignoring request.");
return false;
}

final TransactionLegacy txn = TransactionLegacy.open(TransactionLegacy.CLOUD_DB);
Expand Down Expand Up @@ -1648,6 +1671,7 @@ public String getConfigComponentName() {
@Override
public ConfigKey<?>[] getConfigKeys() {
return new ConfigKey<?>[] {
EnforcePostRequestsAndTimestamps,
IntegrationAPIPort,
ConcurrentSnapshotsThresholdPerHost,
EncodeApiResponse,
Expand Down
115 changes: 113 additions & 2 deletions server/src/main/java/com/cloud/api/ApiServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.Set;

import javax.inject.Inject;
import javax.servlet.ServletConfig;
Expand All @@ -46,6 +49,7 @@
import org.apache.cloudstack.context.CallContext;
import org.apache.cloudstack.managed.context.ManagedContext;
import org.apache.cloudstack.utils.consoleproxy.ConsoleAccessUtils;
import org.apache.commons.collections.MapUtils;

import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.LogManager;
Expand Down Expand Up @@ -78,6 +82,39 @@ 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 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"));
private static final HashSet<String> POST_REQUESTS_TO_DISABLE_LOGGING = new HashSet<>(Set.of(
"login",
"oauthlogin",
"createaccount",
"createuser",
"updateuser",
"forgotpassword",
"resetpassword",
"importrole",
"updaterolepermission",
"updateprojectrolepermission",
"createstoragepool",
"addhost",
"updatehostpassword",
"addcluster",
"addvmwaredc",
"configureoutofbandmanagement",
"uploadcustomcertificate",
"addciscovnmcresource",
"addnetscalerloadbalancer",
"createtungstenfabricprovider",
"addnsxcontroller",
"configtungstenfabricservice",
"createnetworkacl",
"updatenetworkaclitem",
"quotavalidateactivationrule",
"quotatariffupdate",
"listandswitchsamlaccount",
"uploadresourceicon"
));

@Inject
ApiServerService apiServer;
Expand Down Expand Up @@ -193,11 +230,24 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp

utf8Fixup(req, params);

final Object[] commandObj = params.get(ApiConstants.COMMAND);
final String command = commandObj == null ? null : (String) commandObj[0];

// logging the request start and end in management log for easy debugging
String reqStr = "";
String cleanQueryString = StringUtils.cleanString(req.getQueryString());
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)) {
String cleanParamsString = getCleanParamsString(reqParams);
if (org.apache.commons.lang3.StringUtils.isNotBlank(cleanParamsString)) {
reqStr += "\n" + cleanParamsString;
}
} else {
reqStr += " " + command;
}
}
LOGGER.debug("===START=== " + reqStr);
}

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

final Object[] commandObj = params.get(ApiConstants.COMMAND);
final String command = commandObj == null ? null : (String) commandObj[0];
final Object[] userObj = params.get(ApiConstants.USERNAME);
String username = userObj == null ? null : (String)userObj[0];
if (LOGGER.isTraceEnabled()) {
Expand Down Expand Up @@ -317,6 +365,19 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp
}
}

if (apiServer.isPostRequestsAndTimestampsEnforced() && !isStateChangingCommandUsingPOST(command, req.getMethod(), params)) {
String errorText = String.format("State changing command %s needs to be sent using POST request", command);
if (command.equalsIgnoreCase("updateConfiguration") && params.containsKey("name")) {
errorText = String.format("Changes for configuration %s needs to be sent using POST request", params.get("name")[0]);
}
auditTrailSb.append(" " + HttpServletResponse.SC_BAD_REQUEST + " " + errorText);
final String serializedResponse =
apiServer.getSerializedApiError(new ServerApiException(ApiErrorCode.BAD_REQUEST, errorText), params,
responseType);
HttpUtils.writeHttpResponse(resp, serializedResponse, HttpServletResponse.SC_BAD_REQUEST, responseType, ApiServer.JSONcontentType.value());
return;
}

Long userId = null;
if (!isNew) {
userId = (Long)session.getAttribute("userid");
Expand Down Expand Up @@ -407,6 +468,15 @@ private boolean checkIfAuthenticatorIsOf2FA(String command) {
return verify2FA;
}

private boolean isStateChangingCommandUsingPOST(String command, String method, Map<String, Object[]> params) {
if (command == null || (!GET_REQUEST_COMMANDS.matcher(command.toLowerCase()).matches() && !GET_REQUEST_COMMANDS_LIST.contains(command.toLowerCase())
&& !command.equalsIgnoreCase("updateConfiguration") && !method.equals("POST"))) {
return false;
}
return !command.equalsIgnoreCase("updateConfiguration") || method.equals("POST") || (params.containsKey("name")
&& params.get("name")[0].toString().equalsIgnoreCase(ApiServer.EnforcePostRequestsAndTimestamps.key()));
Comment on lines +472 to +477

Copilot AI Jun 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider refactoring the conditional logic in isStateChangingCommandUsingPOST for better readability, and add inline comments to explain the complex conditions.

Suggested change
if (command == null || (!GET_REQUEST_COMMANDS.matcher(command.toLowerCase()).matches() && !GET_REQUEST_COMMANDS_LIST.contains(command.toLowerCase())
&& !command.equalsIgnoreCase("updateConfiguration") && !method.equals("POST"))) {
return false;
}
return !command.equalsIgnoreCase("updateConfiguration") || method.equals("POST") || (params.containsKey("name")
&& params.get("name")[0].toString().equalsIgnoreCase(ApiServer.EnforcePostRequestsAndTimestamps.key()));
// Check if the command is null
if (command == null) {
return false;
}
// Convert the command to lowercase for case-insensitive comparison
String commandLowerCase = command.toLowerCase();
// Condition 1: Check if the command matches GET_REQUEST_COMMANDS or is in GET_REQUEST_COMMANDS_LIST
boolean isGetRequestCommand = GET_REQUEST_COMMANDS.matcher(commandLowerCase).matches() || GET_REQUEST_COMMANDS_LIST.contains(commandLowerCase);
// Condition 2: Check if the command is "updateConfiguration" and the method is not "POST"
boolean isUpdateConfigWithNonPost = command.equalsIgnoreCase("updateConfiguration") && !method.equals("POST");
// If neither condition 1 nor condition 2 is satisfied, return false
if (!isGetRequestCommand && !isUpdateConfigWithNonPost) {
return false;
}
// Condition 3: Check if the command is "updateConfiguration" and the method is "POST"
boolean isUpdateConfigWithPost = command.equalsIgnoreCase("updateConfiguration") && method.equals("POST");
// Condition 4: Check if the "name" parameter exists and matches the required key
boolean isEnforcePostRequest = params.containsKey("name") &&
params.get("name")[0].toString().equalsIgnoreCase(ApiServer.EnforcePostRequestsAndTimestamps.key());
// Return true if either condition 3 or condition 4 is satisfied
return isUpdateConfigWithPost || isEnforcePostRequest;

Copilot uses AI. Check for mistakes.
}

protected boolean skip2FAcheckForAPIs(String command) {
boolean skip2FAcheck = false;

Expand Down Expand Up @@ -644,4 +714,45 @@ private static String getCorrectIPAddress(String ip) {
}
return null;
}

private String getCleanParamsString(Map<String, String[]> reqParams) {
if (MapUtils.isEmpty(reqParams)) {
return "";
}

StringBuilder cleanParamsString = new StringBuilder();
for (Map.Entry<String, String[]> reqParam : reqParams.entrySet()) {
if (org.apache.commons.lang3.StringUtils.isBlank(reqParam.getKey())) {
continue;
}

cleanParamsString.append(reqParam.getKey());
cleanParamsString.append("=");

if (reqParam.getKey().toLowerCase().contains("password")
|| reqParam.getKey().toLowerCase().contains("privatekey")
|| reqParam.getKey().toLowerCase().contains("accesskey")
|| reqParam.getKey().toLowerCase().contains("secretkey")) {
cleanParamsString.append("\n");
continue;
}

if (reqParam.getValue() == null || reqParam.getValue().length == 0) {
cleanParamsString.append("\n");
continue;
}

for (String param : reqParam.getValue()) {
if (org.apache.commons.lang3.StringUtils.isBlank(param)) {
continue;
}
String cleanParamString = StringUtils.cleanString(param.trim());
cleanParamsString.append(cleanParamString);
cleanParamsString.append(" ");
}
cleanParamsString.append("\n");
}

return cleanParamsString.toString();
}
}
44 changes: 29 additions & 15 deletions ui/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,10 @@ import {
ACCESS_TOKEN
} from '@/store/mutation-types'

export function api (command, args = {}, method = 'GET', data = {}) {

@shwstppr shwstppr Jul 1, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sureshanaparti wouldn't it be easier to refactor this method itself to enforce use of POST/GET based on configuration/API type? We won't have to modify each caller in that case. Current change will cause merge conflicts for almost all UI related active PRs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will check it @shwstppr

@sureshanaparti sureshanaparti Jul 1, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shwstppr As discussed offline, the api method is refactored to smaller, separate functions to handle GET & POST calls. These new calls also indicate the underlying method (GET / POST) used for the API call. Also, it's better to have POST method calls for all non-listing, state changing APIs from UI, so keeping these changes as it is. Any merge conflicts / issues, we(or the authors)'ll resolve them.

let params = {}
export function getAPI (command, args = {}) {
args.command = command
args.response = 'json'

if (data) {
params = new URLSearchParams()
Object.entries(data).forEach(([key, value]) => {
params.append(key, value)
})
}

const sessionkey = vueProps.$localStorage.get(ACCESS_TOKEN) || Cookies.get('sessionkey')
if (sessionkey) {
args.sessionkey = sessionkey
Expand All @@ -45,8 +37,30 @@ export function api (command, args = {}, method = 'GET', data = {}) {
...args
},
url: '/',
method,
data: params || {}
method: 'GET'
})
}

export function postAPI (command, data = {}) {
const params = new URLSearchParams()
params.append('command', command)
params.append('response', 'json')
if (data) {
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.append(key, value)
}
})
}

const sessionkey = vueProps.$localStorage.get(ACCESS_TOKEN) || Cookies.get('sessionkey')
if (sessionkey) {
params.append('sessionkey', sessionkey)
}
return axios({
url: '/',
method: 'POST',
data: params
})
}

Expand All @@ -56,7 +70,7 @@ export function login (arg) {
}

// Logout before login is called to purge any duplicate sessionkey cookies
api('logout')
postAPI('logout')

const params = new URLSearchParams()
params.append('command', 'login')
Expand All @@ -66,7 +80,7 @@ export function login (arg) {
params.append('response', 'json')
return axios({
url: '/',
method: 'post',
method: 'POST',
data: params,
headers: {
'content-type': 'application/x-www-form-urlencoded'
Expand All @@ -77,7 +91,7 @@ export function login (arg) {
export function logout () {
message.destroy()
notification.destroy()
return api('logout')
return postAPI('logout')
}

export function oauthlogin (arg) {
Expand All @@ -86,7 +100,7 @@ export function oauthlogin (arg) {
}

// Logout before login is called to purge any duplicate sessionkey cookies
api('logout')
postAPI('logout')

const params = new URLSearchParams()
params.append('command', 'oauthlogin')
Expand Down
6 changes: 3 additions & 3 deletions ui/src/components/header/SamlDomainSwitcher.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@

<script>
import store from '@/store'
import { api } from '@/api'
import { postAPI } from '@/api'
import _ from 'lodash'

export default {
Expand All @@ -73,7 +73,7 @@ export default {
const samlAccounts = []
const getNextPage = () => {
this.loading = true
api('listAndSwitchSamlAccount', { details: 'min', page: page, pageSize: 500 }).then(json => {
postAPI('listAndSwitchSamlAccount', { details: 'min', page: page, pageSize: 500 }).then(json => {
if (json && json.listandswitchsamlaccountresponse && json.listandswitchsamlaccountresponse.samluseraccount) {
samlAccounts.push(...json.listandswitchsamlaccountresponse.samluseraccount)
}
Expand Down Expand Up @@ -102,7 +102,7 @@ export default {
},
changeAccount (index) {
const account = this.samlAccounts[index]
api('listAndSwitchSamlAccount', {}, 'POST', {
postAPI('listAndSwitchSamlAccount', {
userid: account.userId,
domainid: account.domainId
}).then(response => {
Expand Down
Loading