CSRF est souvent appelée "l'attaque sur l'inconscient" car la victime, n'effectuant aucune opération dangereuse, ne se rend compte de rien. Cet article montrera quel moyen de protection on utilisera dans notre exemple d'application Spring.
Se protéger contre CSRF sous Spring
Une autre faille de sécurité potentielle concerne CSRF. L'attaque est expliquée plus en détails dans l'article de mon blog consacré aux façons de protéger une application web contre les attaques CSRF.
L'idée est de créer un outil qui va construire un token unique par utilisateur dans toutes les actions qu'il peut effectuer (modification du compte, emprunt d'un livre...). Le token unique sera basé sur 4 éléments : 2 chaînes de caractères qui serviront à "hasher" le token, 1 intention (action que l'utilisateur veut effectuer) et 1 sel (unique par utilisateur, servira à encoder les 3 éléments précédents). Regardons ensemble dans le code :
<beans:bean id="CSRFProtector" name="CSRFProtector" class="library.security.CSRFProtector"> <beans:property name="expirationTime"><beans:value type="long">1800000</beans:value></beans:property> <beans:property name="hashStart" value="9F.fdsoN33!!!" /> <eans:property name="hashEnd" value="Nofds$POE*dso.e" /> <beans:property name="passwordEncoder" ref="passwordEncoder" /> </beans:bean>
Et la classe Java :
public class CSRFProtector { final Logger logger = LoggerFactory.getLogger(CSRFProtector.class); public final String TOKEN_KEY = "token"; public final String TOKEN_SALT_KEY = "localToken"; public final String TOKEN_VALIDITY_KEY = "localValidityToken"; private final int tokenLength = 30; private long expirationTime; private String hashStart; private String hashEnd; private String intention; private String alphaChain = "abcdefghijklmnopqrstuvwxyz0123456789"; private PasswordEncoder passwordEncoder; public void setExpirationTime(long expirationTime) { this.expirationTime = expirationTime; } public void setHashStart(String hashStart) { this.hashStart = hashStart; } public void setHashEnd(String hashEnd) { this.hashEnd = hashEnd; } public void setIntention(String intention) { logger.info("Setting intention " + intention); this.intention = intention; } public void setPasswordEncoder (PasswordEncoder passwordEncoder) { this.passwordEncoder = passwordEncoder; } public long getExpirationTime() { return expirationTime; } public String getHashStart() { return hashStart; } public String getHashEnd() { return hashEnd; } public String getIntention() { return intention; } public PasswordEncoder getPasswordEncoder() { return passwordEncoder; } public String constructToken(HttpSession session) throws Exception { if (session == null) throw new Exception("Session can't be null"); if (!checkSessionTokenValidity(session)) setLocalToken(session); Map<String, String> tokens = new HashMap<<String, String>(); if (session.getAttribute(TOKEN_KEY) != null) tokens = (Map<String, String>) session.getAttribute(TOKEN_KEY); if (tokens.containsKey(intention) && tokens.get(intention) != null) return tokens.get(intention); String token = passwordEncoder.encodePassword(hashStart+intention+hashEnd, session.getAttribute(TOKEN_SALT_KEY)); tokens.put(intention, token); session.setAttribute(TOKEN_KEY, tokens); logger.info("Tokens map is " + tokens); return token; } public boolean compareTokens(String requestToken, HttpSession session) throws Exception { if (session == null) throw new Exception("Session can't be null"); if (!checkSessionTokenValidity(session)) setLocalToken(session); String userToken = constructToken(session); logger.info("=> Comparing requestToken ("+requestToken+") with userToken("+userToken+")"); return requestToken.equals(userToken); } public void setLocalToken(HttpSession session) { String token = ""; Random ran = new Random(); byte[] r = new byte[tokenLength]; ran.nextBytes(r); for (int i = 0; i < tokenLength; i++) { token += (alphaChain.charAt(r[i] & 0x1f)); } logger.info("=> Generated token " + token); session.setAttribute(TOKEN_VALIDITY_KEY, (new Date().getTime() + expirationTime)); logger.info("=> Expiration time " + session.getAttribute(TOKEN_VALIDITY_KEY)); session.setAttribute(TOKEN_SALT_KEY, token); } public boolean checkSessionTokenValidity(HttpSession session) { return (session.getAttribute(TOKEN_VALIDITY_KEY) != null && (new Date().getTime() < (Long) session.getAttribute(TOKEN_VALIDITY_KEY))); } public String getCSRFToken(HttpSession session, String intention) { if (this.intention == null) setIntention(intention); Map<String, String> tokens = (Map<String, String>) session.getAttribute(TOKEN_KEY); return tokens.get(intention); } }
Comment ça fonctionne dans une implémentation ? Pour le constater, on regardera le formulaire d'enregistrement :
@Controller public class SubscriberController extends FrontendController { public String register(@ModelAttribute("subscriber") Subscriber subscriber, Model layout, RedirectAttributes redAtt, HttpServletRequest request) { // only code using CSRFProtector's instance is displayed try { csrfProtector.setIntention("register"); subscriber.setToken(csrfProtector.constructToken(request.getSession())); subscriber.setAction(csrfProtector.getIntention()); logger.info("Generated token " + subscriber.getToken()); } catch(Exception e) { logger.error("An exception occured on creating CSRF token", e); } } // ... }
On voit que la sécurisation est banale. Il s'agit juste d'un simple jeu des setters et des getters. La validation est beaucoup plus intéressante. Pour l'effectuer on crée une annotation pouvant être définie au niveau de la classe (@CSRFConstraint). Elle utilise le CSRFConstraintValidator pour valider le token transmis dans la requête avec celui qui est correct. Cette classe de validation implémente l'interface ConstraintValidator. On y injecte deux classes : HttpServletRequest et CSRFProtector. Cette première servira à récupérer la session de l'utilisateur. L'autre s'occupera de générer le token correct. Voici ses éléments regroupés :
// Subscriber.java @CSRFConstraint(message = "{error.csrf.invalid}", groups = {SubscriberPasswordCheck.class, SubscriberAccountCheck.class, SubscriberRegisterCheck.class, SubscriberAvatarCheck.class}) public class Subscriber extends ParentEntity implements Serializable { // ... } // CSRFConstraint.java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Constraint(validatedBy=CSRFConstraintValidator.class) @Documented public @interface CSRFConstraint { String message() default "Invalid request"; Class>[] groups() default {}; Class extends Payload>[] payload() default {}; } // CSRFConstraintValidator.java public class CSRFConstraintValidator implements ConstraintValidator{ final Logger logger = LoggerFactory.getLogger(CSRFConstraintValidator.class); @Autowired private HttpServletRequest request; @Autowired private CSRFProtector csrfProtector; public void initialize(CSRFConstraint constraintAnnotation) {} public void setRequest(HttpServletRequest request) { this.request = request; } public void setCSRFProtector(CSRFProtector csrfProtector) { this.csrfProtector = csrfProtector; } public boolean isValid(Object entity, ConstraintValidatorContext context) { if (request == null || csrfProtector == null || entity == null) return false; HttpSession session = request.getSession(); if (session == null || !csrfProtector.checkSessionTokenValidity(session)) return false; String requestToken = ""; String csrfIntention = ""; try { requestToken = BeanUtils.getProperty(entity, "token"); csrfIntention = BeanUtils.getProperty(entity, "action"); } catch (Exception e) { logger.error("An exception occured on getting entity properties", e); return false; } String userToken = ""; try { csrfProtector.setIntention(csrfIntention); userToken = csrfProtector.constructToken(session); } catch (Exception e) { logger.info("An exception occured on generating CSRF token", e); return false; } logger.info("=> Comparing requestToken ("+requestToken+") with userToken("+userToken+")"); return requestToken.equals(userToken); } }
Une autre solution, consistant à implémenter l'interface org.springframework.web.servlet.support.RequestDataValueProcessor, sera abordée dans une version améliorée du système.