SameSite cookie issue in Hybris

SameSite cookie attribute was introduced to improve protection from CSRF attacks by default (read more). 11 August 2020 Chrome changed default behaviour of cookies without SameSite attribute. Starting from that day such cookies would be processed with SameSite=Lax attribute, so cookies would not be sent by default for all third-party POST requests (request made from third-party service to hybris would be also affected).

For example, in case of HOP payment implementation with POST redirect from Payment Provider back to hybris - user will lose his session and see login screen instead of success payment message. Such behaviour encountered due to “JSESSIONID”, “acceleratorSecureGUID” and “storefrontRememberMe” cookies doesn’t specify SameSite attribute.

To fix such issue and allow browsers to send cookies in POST requests, must be added SameSite=None attribute to sesion and login related cookies. Adding it for all users can lead to more problems due to browsers can process SameSite=None differently:

  1. Ignore such cookies
    1. Such behaviour defined in 2019 SameSite Standard for cookies without Secured attribute.
      1. Would affect browsers based on Chrome 80+
    2. Such behaviour defined in April 2016 SameSite Standard
      1. Would affect browsers based on Chrome 51-66
  2. Treat it as SameSite=Strict
    1. Such behaviour defined in January 2016 SameSite Standard
      1. Would affect iOS 12 and MacOS 10.14 browsers
  3. Treat is as SameSite=None
    1. Modern browsers behaviour

More details on incompatible with SameSite=None clients can be found in chromium project. This article contains pseudocode, which can be used to identify incompatible browsers. That pseudocode would be reused for hybris fix, it is not perfect and Java implementation is not optimal from performance standpoint, but it is enough for current needs:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
package com.blog.core.util;

import org.apache.log4j.Logger;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.apache.log4j.Logger.getLogger;

public class SameSiteCookieUtils {

    private static final Logger LOG = getLogger(SameSiteCookieUtils.class);

    private static final Pattern CHROME_VERSION = Pattern.compile("Chrom[^ \\/]+\\/(\\d+)[\\.\\d]*");

    private static final Pattern UC_BROWSER_VERSION = Pattern.compile("UCBrowser\\/(\\d+)\\.(\\d+)\\.(\\d+)[\\.\\d]* ");

    private static final Pattern IOS_VERSION = Pattern.compile("\\(iP.+; CPU .*OS (\\d+)[_\\d]*.*\\) AppleWebKit\\/");
    private static final Pattern MACOS_VERSION = Pattern.compile("\\(Macintosh;.*Mac OS X (\\d+)_(\\d+)[_\\d]*.*\\) AppleWebKit\\/");
    private static final Pattern MAC_EMBEDDED_VERSION = Pattern.compile("^Mozilla\\/[\\.\\d]+ \\(Macintosh;.*Mac OS X [_\\d]+\\) AppleWebKit\\/[\\.\\d]+ \\(KHTML, like Gecko\\)$");

    private SameSiteCookieUtils() {
        // ! Util class must not be initialized
    }

    public static boolean shouldSendSameSiteNone(String useragent) {
        return !isSameSiteNoneIncompatible(useragent);
    }

    private static boolean isSameSiteNoneIncompatible(String useragent) {
        return hasWebKitSameSiteBug(useragent) || dropsUnrecognizedSameSiteCookies(useragent);
    }

    private static boolean hasWebKitSameSiteBug(String useragent) {
        return isIosVersion(12, useragent) || (isMacOsVersion(10, 14, useragent) && (isSafari(useragent) || isMacEmbeddedBrowser(useragent)));
    }

    private static boolean dropsUnrecognizedSameSiteCookies(String useragent) {
        if (isUcBrowser(useragent)) {
            return !isUcBrowserVersionAtLeast(12, 13, 2, useragent);
        }
        return isChromiumBased(useragent) && isChromiumVersionAtLeast(51, useragent) && !isChromiumVersionAtLeast(67, useragent);
    }

    private static boolean isIosVersion(int major, String useragent) {
        Matcher matcher = IOS_VERSION.matcher(useragent);
        if (matcher.find()) {
            String userAgentVersion = matcher.group(1);
            return userAgentVersion.equals(String.valueOf(major));
        }
        return false;
    }


    private static boolean isMacOsVersion(int major, int minor, String useragent) {
        Matcher matcher = MACOS_VERSION.matcher(useragent);
        if (matcher.find()) {
            try {
                String macOsMajorVersion = matcher.group(1);
                String macOsMinorVersion = matcher.group(2);
                return Integer.parseInt(macOsMajorVersion) == major && Integer.parseInt(macOsMinorVersion) == minor;
            } catch (NumberFormatException e) {
                LOG.warn(e.toString());
                if (LOG.isDebugEnabled()) {
                    LOG.debug("Exception:", e);
                }
            }
        }
        return false;
    }

    private static boolean isSafari(String useragent) {
        return useragent.contains("Safari");
    }

    private static boolean isMacEmbeddedBrowser(String useragent) {
        Matcher matcher = MAC_EMBEDDED_VERSION.matcher(useragent);
        return matcher.find();
    }

    private static boolean isChromiumBased(String useragent) {
        return useragent.contains("Chrome") || useragent.contains("Chromium");
    }

    private static boolean isChromiumVersionAtLeast(int major, String useragent) {
        Matcher matcher = CHROME_VERSION.matcher(useragent);
        if (matcher.find()) {
            try {
                String chromeVersion = matcher.group(1);
                return Integer.parseInt(chromeVersion) >= major;
            } catch (NumberFormatException e) {
                LOG.warn(e.toString());
                if (LOG.isDebugEnabled()) {
                    LOG.debug("Exception:", e);
                }
            }
        }
        return false;
    }

    private static boolean isUcBrowser(String useragent) {
        return useragent.contains("UCBrowser");
    }


    private static boolean isUcBrowserVersionAtLeast(int major, int minor, int build, String useragent) {
        Matcher matcher = UC_BROWSER_VERSION.matcher(useragent);
        if (matcher.find()) {
            try {
                int ucMajorVersion = Integer.parseInt(matcher.group(1));
                if (ucMajorVersion != major) {
                    return ucMajorVersion > major;
                }
                int ucMinorVersion = Integer.parseInt(matcher.group(2));
                if (ucMinorVersion != minor) {
                    return ucMinorVersion > minor;
                }
                int ucBuildVersion = Integer.parseInt(matcher.group(3));
                return ucBuildVersion >= build;
            } catch (NumberFormatException e) {
                LOG.warn(e.toString());
                if (LOG.isDebugEnabled()) {
                    LOG.debug("Exception:", e);
                }
            }
        }
        return false;
    }

}

Adding of SameSite attribute into cookies is not so trivial task, so it would be wrapped with one more utils class(code could be improved to dynamically get cookie names instead of using hardcoded values):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.blog.core.util;

import com.google.common.net.HttpHeaders;
import com.blog.core.util.SameSiteCookieUtils;
import org.apache.commons.collections4.CollectionUtils;

import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Optional;

public class SameSiteCookieAttributeAppenderUtils {

    private SameSiteCookieAttributeAppenderUtils() {
        // ! Util class must not be initialized
    }

    private static final List<String> COOKIES_WITH_FORCE_SAME_SITE_NONE = Arrays.asList("JSESSIONID", "acceleratorSecureGUID", "blogSsoLoginRememberMe");

    public static void addSameSiteAttribute(HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
        if (isNotCommittedResponse(servletResponse)) {
            Collection<String> headers = servletResponse.getHeaders(HttpHeaders.SET_COOKIE);
            if (CollectionUtils.isNotEmpty(headers)) {
                String userAgent = servletRequest.getHeader(HttpHeaders.USER_AGENT);
                for (String sameSiteCookie : COOKIES_WITH_FORCE_SAME_SITE_NONE) {
                    addSameSiteNone(sameSiteCookie, servletResponse, userAgent);
                }
            }
        }
    }

    private static void addSameSiteNone(String sameSiteCookie, HttpServletResponse servletResponse, String userAgent) {
        Collection<String> headers = servletResponse.getHeaders(HttpHeaders.SET_COOKIE);

        // Check if exists session set cookie header
        Optional<String> sessionCookieWithoutSameSite = headers.stream()
                .filter(cookie -> cookie.startsWith(sameSiteCookie) && !cookie.contains("SameSite"))
                .findAny();

        if (sessionCookieWithoutSameSite.isPresent() && SameSiteCookieUtils.shouldSendSameSiteNone(userAgent)) {
            // Replace all set cookie headers with 1 new session + sameSite header
            servletResponse.setHeader(HttpHeaders.SET_COOKIE, sessionCookieWithoutSameSite.get() + ";Secure ;SameSite=None");

            // Re-add all other set cookie headers
            headers.stream()
                    .filter(cookie -> !cookie.startsWith(sameSiteCookie))
                    .forEach(cookie -> servletResponse.addHeader(HttpHeaders.SET_COOKIE, cookie));
        }
    }

    private static boolean isNotCommittedResponse(ServletResponse servletResponse) {
        return !servletResponse.isCommitted();
    }

}

Cookies could be added in spring filters and in controllers. To handle adding of cookies in spring filters would be created another filter, which is always executed after processing of whole filter chain. For that we will add SameSite attribute postprocessing in finally block:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.blog.storefront.filters;

import com.blog.core.util.SameSiteCookieAttributeAppenderUtils;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class SameSiteCookiePostprocessFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        try {
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            if (servletResponse instanceof HttpServletResponse && servletRequest instanceof HttpServletRequest) {
                SameSiteCookieAttributeAppenderUtils.addSameSiteAttribute((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse);
            }
        }
    }
}

Bean must be placed before any other filter bean(spring-filter-config.xml) to ensure that SameSiteCookiePostprocessFilter finally block would be executed after execution of all other filters:

1
2
3
4
5
6
7
8
9

<util:list id="defaultStorefrontTenantDefaultFilterChainList">

    <!-- Must be placed before any filter, so on finally we can postprocess all changes of below filters. -->
    <ref bean="sameSiteCookiePostprocessFilter"/>

    ...

</util:list>

To postprocess cookies, which were added in controllers after spring filter chain, HandlerInterceptor would be used:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package com.blog.storefront.interceptors;

import com.blog.core.util.SameSiteCookieAttributeAppenderUtils;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class SameSiteCookieHandlerInterceptorAdapter extends HandlerInterceptorAdapter {

    @Override
    public void postHandle(HttpServletRequest servletRequest, HttpServletResponse servletResponse, Object handler, ModelAndView modelAndView) {
        SameSiteCookieAttributeAppenderUtils.addSameSiteAttribute(servletRequest, servletResponse);
    }

}

Bean must be injected into spring mvc interceptors list(spring-mvc-config.xml):

1
2
3
4
5
6
7
<!-- SameSite cookie Handler Interceptor -->
<bean id="sameSiteCookieHandlerInterceptorAdapter"
      class="com.blog.storefront.interceptors.SameSiteCookieHandlerInterceptorAdapter"/>

<mvc:interceptors>
<ref bean="sameSiteCookieHandlerInterceptorAdapter"/>
</mvc:interceptors>

Filter and controller implementations are not enough due to Spring Security on success login will set login cookies and redirect user without processing of all filter chain. It means, that we should extend AuthenticationSuccessHandler with postprocessing of cookies. To avoid code duplication we will inject previously implemented filter into AuthenticationSuccessHandler.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.blog.storefront.security;

import com.blog.core.util.SameSiteCookieAttributeAppenderUtils;
import de.hybris.platform.acceleratorstorefrontcommons.security.GUIDCookieStrategy;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;


public class GUIDAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private GUIDCookieStrategy guidCookieStrategy;
    private AuthenticationSuccessHandler authenticationSuccessHandler;

    @Override
    public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response,
                                        final Authentication authentication) throws IOException, ServletException {
        getGuidCookieStrategy().setCookie(request, response);

        // ! onAuthenticationSuccess will commit response, so we won't be able to change it, that`s why we should execute filter before it.
        SameSiteCookieAttributeAppenderUtils.addSameSiteAttribute(request, response);

        getAuthenticationSuccessHandler().onAuthenticationSuccess(request, response, authentication);
    }

    protected GUIDCookieStrategy getGuidCookieStrategy() {
        return guidCookieStrategy;
    }

    @Required
    public void setGuidCookieStrategy(final GUIDCookieStrategy guidCookieStrategy) {
        this.guidCookieStrategy = guidCookieStrategy;
    }

    protected AuthenticationSuccessHandler getAuthenticationSuccessHandler() {
        return authenticationSuccessHandler;
    }

    @Required
    public void setAuthenticationSuccessHandler(final AuthenticationSuccessHandler authenticationSuccessHandler) {
        this.authenticationSuccessHandler = authenticationSuccessHandler;
    }

}
comments powered by Disqus