Introduction
I recently stumbled over the following code in our codebase:
// Security flaw!
@Cacheable("permissionsOnContracts")
public boolean isAllowedToRead(ContractId contractId) {
return expensiveOperationOrRemoteCall(contractId);
}
The intent of the Cacheable-Annotation was obviously to reduce calls of #expensiveOperationOrRemoteCall, which makes sense in a way, especially when #isAllowedToRead is called often. Note that the #expensiveOperationOrRemoteCall determines the calling user internally by itself (eg. by using the SecurityContext, an OAuth2-Token, …).
It also introduces a severe security problem.
The Problem
But there is one little thing missing: the Cacheable-Annotation simply stores the result ignoring the user context. And the outcome of #isAllowedToRead is – of course – highly dependent on whether or not the current user is allowed to read a given contract. Which means, if an outcome of #isAllowedToRead is cached for a given ContractId, the result is applied to every call that follows using the same ContractId.
Example: User A is allowed to read the Contract with id “123”, User B is not.
- User A calls #isAllowedToRead(ContractId.of(“123”))
- The cache holds no entry for “123” so the method will be executed
- #isAllowedToRead returns “true”, this is cached by @Cacheable
- Now User B calls #isAllowedToRead(ContractId.of(“123”))
- The cache holds an entry for “123” (“true”) and returns it (#isAllowedToRead will not be executed)
In effect the system supplied an incorrect access control outcome for User B.
Solution 1: No caching
Simply don’t use general caching on methods handling access control concerns (make sure that this aligns with the system’s specification on performance etc.).
Solution 2: Add a UserId parameter
We can change the code as follows:
@Cacheable("permissionsOnContracts")
public boolean isAllowedToRead(ContractId contractId, UserId caller) {
return expensiveOperationOrRemoteCall(contractId);
}
Now the (auto-generated) CacheKey will use the contractId and the caller’s id, from which point, the Cache will implicitly respect the user context.
Minor drawbacks: Every method invoking #isAllowedToRead has to determine the caller’s id (correctly!) and using ‘null’ (or some constant) will break the access control again. Furthermore the caller’s id isn’t used inside #isAllowedToRead, so there is always the risk, that it will be removed in the future (refactoring, code clean up, …).
Solution 3: AuthenticationAwareKeyGenerator
We implement a custom KeyGenerator, which is aware of the user context:
public class AuthenticationAwareKeyGenerator extends SimpleKeyGenerator {
// note: make sure that ":" isn't a valid char in the userId
// nor part of the result of generateKey
@Override
public Object generate(Object target, Method method, Object... params) {
UserId userId = someWayToGetTheUserId(); // eg. by using Spring's SecurityContext
return userId.value() + ":" + generateKey(params);
}
}
Now we can create a Bean in one of our Config-Classes, e.g.:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public AuthenticationAwareKeyGenerator authenticationAwareKeyGenerator() {
return new AuthenticationAwareKeyGenerator();
}
}
(Maybe we can skip this step by using @Component on the class AuthenticationAwareKeyGenerator itself – I didn’t try it out)
The last step is to add the custom KeyGenerator to our method:
@Cacheable(value="permissionsOnContracts", keyGenerator="authenticationAwareKeyGenerator")
public void boolean isAllowedToRead(ContractId contractId) {
return expensiveOperationOrRemoteCall(contractId);
}
Done.