Skip to content

Commit 787f261

Browse files
committed
Fix NPE on getUserKeys when accessed via the integration API port
Requests sent to the integration API port (integration.api.port, 8096 by default) are not signature-checked and run as the system user. Clients such as the cs CLI still send an apikey and signature parameter, so getAccessingApiKey() picked up an API key that was never used to authenticate the request. Looking that key up returned null and getUserKeys, listUserKeys, registerUserKeys and listApis failed with a NullPointerException. Only treat the request's API key as the accessing key pair when it maps to a key pair owned by the calling user. For the system user (integration API port) a mismatching key is ignored and the caller's role permissions apply, as for session-authenticated requests. For any other caller a mismatching key is rejected with a PermissionDeniedException, since the request cannot have been authenticated through it. Also guard the key pair lookups against null so an unknown key can no longer cause a NullPointerException.
1 parent 036493f commit 787f261

2 files changed

Lines changed: 138 additions & 7 deletions

File tree

server/src/main/java/com/cloud/user/AccountManagerImpl.java

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3315,15 +3315,14 @@ public Pair<Boolean, Map<String, String>> getKeys(GetUserKeysCmd cmd) {
33153315
verifyCallerPrivilegeForUserOrAccountOperations(user);
33163316

33173317
String accessingApiKey = getAccessingApiKey(cmd);
3318-
ApiKeyPair keyPair;
3318+
ApiKeyPair keyPair = null;
33193319
if (accessingApiKey != null) {
33203320
ApiKeyPair accessingKeyPair = apiKeyPairService.findByApiKey(accessingApiKey);
3321-
if (userId == accessingKeyPair.getUserId()) {
3322-
keyPair = apiKeyPairService.findByApiKey(accessingApiKey);
3323-
} else {
3324-
keyPair = _accountService.getLatestUserKeyPair(userId);
3321+
if (accessingKeyPair != null && userId == accessingKeyPair.getUserId()) {
3322+
keyPair = accessingKeyPair;
33253323
}
3326-
} else {
3324+
}
3325+
if (keyPair == null) {
33273326
keyPair = _accountService.getLatestUserKeyPair(userId);
33283327
}
33293328

@@ -3436,6 +3435,10 @@ private Boolean isAccessingKeypairSuperset(ApiKeyPair accessedKeyPair, BaseCmd c
34363435
return Boolean.TRUE;
34373436
}
34383437
ApiKeyPair accessingKeyPair = apiKeyPairService.findByApiKey(apiKey);
3438+
if (accessingKeyPair == null) {
3439+
logger.info("Unable to find the API key pair used to access the API; therefore, its permissions cannot be verified.");
3440+
return Boolean.FALSE;
3441+
}
34393442
return isApiKeySupersetOfPermission(new ArrayList<>(getAllKeypairPermissions(accessingKeyPair.getApiKey())), new ArrayList<>(getAllKeypairPermissions(accessedKeyPair.getApiKey())));
34403443
}
34413444

@@ -3454,7 +3457,7 @@ public String getAccessingApiKey(BaseCmd cmd) {
34543457
String apiKey = requestPayload.entrySet().stream()
34553458
.filter(e -> ApiConstants.API_KEY.equalsIgnoreCase(e.getKey()))
34563459
.map(Map.Entry::getValue).findFirst().orElse(null);
3457-
if (apiKey != null) {
3460+
if (apiKey != null && isApiKeyOwnedByCallingUser(apiKey)) {
34583461
logger.info("Request's API key is [{}].", apiKey);
34593462
return apiKey;
34603463
}
@@ -3467,6 +3470,35 @@ public String getAccessingApiKey(BaseCmd cmd) {
34673470
return null;
34683471
}
34693472

3473+
/**
3474+
* Checks whether the API key present in the request belongs to the calling user. When a request is authenticated through
3475+
* an API key pair, the calling user is always the owner of that key pair, so a request whose API key does not map to a
3476+
* key pair of the calling user was not authenticated through it.
3477+
* <p>
3478+
* Requests sent to the integration API port (see {@code integration.api.port}) are not signature-checked and run under the
3479+
* system user, so any API key and signature they carry are ignored and the caller's role permissions apply instead.
3480+
* For any other caller such a request is rejected: on the regular API port the signature is only verified when there is no
3481+
* authenticated session, so a mismatching API key can only be the result of a tampered request, and honoring it would
3482+
* derive permissions from a key pair that the caller did not prove ownership of.
3483+
*
3484+
* @throws PermissionDeniedException if the calling user is not the system user and the API key does not belong to them.
3485+
*/
3486+
protected boolean isApiKeyOwnedByCallingUser(String apiKey) {
3487+
long callingUserId = CallContext.current().getCallingUserId();
3488+
ApiKeyPair keyPair = apiKeyPairService.findByApiKey(apiKey);
3489+
if (keyPair != null && Long.valueOf(callingUserId).equals(keyPair.getUserId())) {
3490+
return true;
3491+
}
3492+
String keyPairDescription = keyPair == null ? "an API key that does not map to any API key pair" :
3493+
String.format("API key pair [%s] which belongs to user with ID [%s]", keyPair.getUuid(), keyPair.getUserId());
3494+
if (callingUserId == User.UID_SYSTEM) {
3495+
logger.debug("Request made by the system user (e.g. through the integration API port) contains {}; ignoring it.", keyPairDescription);
3496+
return false;
3497+
}
3498+
logger.warn("Request made by user with ID [{}] contains {}; rejecting the request.", callingUserId, keyPairDescription);
3499+
throw new PermissionDeniedException("The API key present in the request does not belong to the calling user.");
3500+
}
3501+
34703502
private Boolean isApiKeySupersetOfPermission(List<RolePermissionEntity> baseKeyPairPermissions, List<RolePermissionEntity> comparedPermissions) {
34713503
Map<String, RolePermissionEntity> apiNameToBaseKeyPermissions = roleService.getRoleRulesAndPermissions(baseKeyPairPermissions);
34723504

@@ -3727,6 +3759,9 @@ public List<RolePermissionEntity> getAllKeypairPermissions(String apiKey) {
37273759
throw new InvalidParameterValueException("API key not present in the request's URL and, thus, unable to fetch API key rules.");
37283760
}
37293761
ApiKeyPair apiKeyPair = keyPairManager.findByApiKey(apiKey);
3762+
if (apiKeyPair == null) {
3763+
throw new InvalidParameterValueException("Unable to find an API key pair matching the API key present in the request's URL and, thus, unable to fetch API key rules.");
3764+
}
37303765
Account account = _accountDao.findById(apiKeyPair.getAccountId());
37313766
List<ApiKeyPairPermission> keyPairPermissions = keyPairManager.findAllPermissionsByKeyPairId(apiKeyPair.getId(), account.getRoleId());
37323767
return new ArrayList<>(keyPairPermissions);

server/src/test/java/com/cloud/user/AccountManagerImplTest.java

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2289,4 +2289,100 @@ public void testCheckRoleEscalationMultipleCheckersAppliedSequentially() throws
22892289

22902290
accountManagerImpl.checkRoleEscalation(caller, requested);
22912291
}
2292+
2293+
private Map<String, String> buildSignedRequestParams(String apiKey) {
2294+
Map<String, String> params = new HashMap<>();
2295+
params.put(ApiConstants.API_KEY, apiKey);
2296+
params.put(ApiConstants.SIGNATURE, "signature");
2297+
return params;
2298+
}
2299+
2300+
@Test
2301+
public void getAccessingApiKeyTestReturnsApiKeyWhenKeyPairBelongsToCallingUser() {
2302+
Mockito.when(callingUser.getId()).thenReturn(111L);
2303+
CallContext.register(callingUser, callingAccount);
2304+
Mockito.when(_getkeyscmd.getFullUrlParams()).thenReturn(buildSignedRequestParams("api-key"));
2305+
Mockito.when(apiKeyPairVOMock.getUserId()).thenReturn(111L);
2306+
Mockito.when(apiKeyPairService.findByApiKey("api-key")).thenReturn(apiKeyPairVOMock);
2307+
2308+
Assert.assertEquals("api-key", accountManagerImpl.getAccessingApiKey(_getkeyscmd));
2309+
}
2310+
2311+
@Test(expected = PermissionDeniedException.class)
2312+
public void getAccessingApiKeyTestThrowsWhenKeyPairBelongsToAnotherUser() {
2313+
Mockito.when(callingUser.getId()).thenReturn(111L);
2314+
CallContext.register(callingUser, callingAccount);
2315+
Mockito.when(_getkeyscmd.getFullUrlParams()).thenReturn(buildSignedRequestParams("api-key"));
2316+
Mockito.when(apiKeyPairVOMock.getUserId()).thenReturn(222L);
2317+
Mockito.when(apiKeyPairService.findByApiKey("api-key")).thenReturn(apiKeyPairVOMock);
2318+
2319+
accountManagerImpl.getAccessingApiKey(_getkeyscmd);
2320+
}
2321+
2322+
@Test(expected = PermissionDeniedException.class)
2323+
public void getAccessingApiKeyTestThrowsWhenKeyPairDoesNotExist() {
2324+
Mockito.when(callingUser.getId()).thenReturn(111L);
2325+
CallContext.register(callingUser, callingAccount);
2326+
Mockito.when(_getkeyscmd.getFullUrlParams()).thenReturn(buildSignedRequestParams("dummy"));
2327+
Mockito.when(apiKeyPairService.findByApiKey("dummy")).thenReturn(null);
2328+
2329+
accountManagerImpl.getAccessingApiKey(_getkeyscmd);
2330+
}
2331+
2332+
@Test
2333+
public void getAccessingApiKeyTestReturnsNullWhenKeyPairBelongsToAnotherUserAndCallerIsSystemUser() {
2334+
Mockito.when(callingUser.getId()).thenReturn(User.UID_SYSTEM);
2335+
CallContext.register(callingUser, callingAccount);
2336+
Mockito.when(_getkeyscmd.getFullUrlParams()).thenReturn(buildSignedRequestParams("api-key"));
2337+
Mockito.when(apiKeyPairVOMock.getUserId()).thenReturn(222L);
2338+
Mockito.when(apiKeyPairService.findByApiKey("api-key")).thenReturn(apiKeyPairVOMock);
2339+
2340+
Assert.assertNull(accountManagerImpl.getAccessingApiKey(_getkeyscmd));
2341+
}
2342+
2343+
@Test
2344+
public void getAccessingApiKeyTestReturnsNullWhenKeyPairDoesNotExistAndCallerIsSystemUser() {
2345+
Mockito.when(callingUser.getId()).thenReturn(User.UID_SYSTEM);
2346+
CallContext.register(callingUser, callingAccount);
2347+
Mockito.when(_getkeyscmd.getFullUrlParams()).thenReturn(buildSignedRequestParams("dummy"));
2348+
Mockito.when(apiKeyPairService.findByApiKey("dummy")).thenReturn(null);
2349+
2350+
Assert.assertNull(accountManagerImpl.getAccessingApiKey(_getkeyscmd));
2351+
}
2352+
2353+
@Test
2354+
public void getAccessingApiKeyTestReturnsNullWhenRequestIsNotSigned() {
2355+
Map<String, String> params = new HashMap<>();
2356+
params.put(ApiConstants.API_KEY, "api-key");
2357+
Mockito.when(_getkeyscmd.getFullUrlParams()).thenReturn(params);
2358+
2359+
Assert.assertNull(accountManagerImpl.getAccessingApiKey(_getkeyscmd));
2360+
Mockito.verify(apiKeyPairService, Mockito.never()).findByApiKey(Mockito.anyString());
2361+
}
2362+
2363+
@Test
2364+
public void getKeysTestReturnsLatestKeyPairWhenRequestApiKeyIsNotVerified() {
2365+
// Requests through the integration API port run as the system user and are not signature-checked, so the
2366+
// API key and signature they may carry must not be used to derive permissions.
2367+
Mockito.when(callingUser.getId()).thenReturn(User.UID_SYSTEM);
2368+
CallContext.register(callingUser, callingAccount);
2369+
long userId = 2L;
2370+
Mockito.when(_getkeyscmd.getId()).thenReturn(userId);
2371+
Mockito.when(_getkeyscmd.getFullUrlParams()).thenReturn(buildSignedRequestParams("dummy"));
2372+
Mockito.doReturn(userVoMock).when(accountManagerImpl).getActiveUser(userId);
2373+
Mockito.when(userVoMock.getApiKeyAccess()).thenReturn(Boolean.TRUE);
2374+
Mockito.when(_accountDao.findByIdIncludingRemoved(accountMockId)).thenReturn(callingAccount);
2375+
Mockito.doNothing().when(accountManagerImpl).checkAccess(Mockito.any(User.class), Mockito.any(ControlledEntity.class));
2376+
Mockito.doNothing().when(accountManagerImpl).verifyCallerPrivilegeForUserOrAccountOperations(Mockito.any(User.class));
2377+
Mockito.when(apiKeyPairService.findByApiKey("dummy")).thenReturn(null);
2378+
Mockito.when(apiKeyPairVOMock.getApiKey()).thenReturn("latest-api-key");
2379+
Mockito.when(apiKeyPairVOMock.getSecretKey()).thenReturn("latest-secret-key");
2380+
Mockito.when(_accountService.getLatestUserKeyPair(userId)).thenReturn(apiKeyPairVOMock);
2381+
2382+
Pair<Boolean, Map<String, String>> result = accountManagerImpl.getKeys(_getkeyscmd);
2383+
2384+
Assert.assertTrue(result.first());
2385+
Assert.assertEquals("latest-api-key", result.second().get("apikey"));
2386+
Assert.assertEquals("latest-secret-key", result.second().get("secretkey"));
2387+
}
22922388
}

0 commit comments

Comments
 (0)