1# frozen_string_literal: true
2
3# This middleware sets the SameSite directive to None on all cookies.
4# It also adds the Secure directive if HTTPS is enabled.
5#
6# Chrome v80, rolled out in March 2020, treats any cookies without the
7# SameSite directive set as though they are SameSite=Lax
8# (https://www.chromestatus.com/feature/5088147346030592). This is a
9# breaking change from the previous default behavior, which was to treat
10# those cookies as SameSite=None.
11#
12# This middleware is needed until we upgrade to Rack v2.1.0+
13# (https://github.com/rack/rack/commit/c859bbf7b53cb59df1837612a8c330dfb4147392)
14# and a version of Rails that has native support
15# (https://github.com/rails/rails/commit/7ccaa125ba396d418aad1b217b63653d06044680).
16#
17module Gitlab
18  module Middleware
19    class SameSiteCookies
20      COOKIE_SEPARATOR = "\n"
21
22      def initialize(app)
23        @app = app
24      end
25
26      def call(env)
27        status, headers, body = @app.call(env)
28        result = [status, headers, body]
29
30        set_cookie = headers['Set-Cookie']&.strip
31
32        return result if set_cookie.blank? || !ssl?
33        return result if same_site_none_incompatible?(env['HTTP_USER_AGENT'])
34
35        cookies = set_cookie.split(COOKIE_SEPARATOR)
36
37        cookies.each do |cookie|
38          next if cookie.blank?
39
40          # Chrome will drop SameSite=None cookies without the Secure
41          # flag. If we remove this middleware, we may need to ensure
42          # that all cookies set this flag.
43          unless SECURE_REGEX.match?(cookie)
44            cookie << '; Secure'
45          end
46
47          unless SAME_SITE_REGEX.match?(cookie)
48            cookie << '; SameSite=None'
49          end
50        end
51
52        headers['Set-Cookie'] = cookies.join(COOKIE_SEPARATOR)
53
54        result
55      end
56
57      private
58
59      # Taken from https://www.chromium.org/updates/same-site/incompatible-clients
60      # We use RE2 instead of the browser gem for performance.
61      IOS_REGEX = RE2('\(iP.+; CPU .*OS (\d+)[_\d]*.*\) AppleWebKit\/')
62      MACOS_REGEX = RE2('\(Macintosh;.*Mac OS X (\d+)_(\d+)[_\d]*.*\) AppleWebKit\/')
63      SAFARI_REGEX = RE2('Version\/.* Safari\/')
64      CHROMIUM_REGEX = RE2('Chrom(e|ium)')
65      CHROMIUM_VERSION_REGEX = RE2('Chrom[^ \/]+\/(\d+)')
66      UC_BROWSER_REGEX = RE2('UCBrowser\/')
67      UC_BROWSER_VERSION_REGEX = RE2('UCBrowser\/(\d+)\.(\d+)\.(\d+)')
68
69      SECURE_REGEX = RE2(';\s*secure', case_sensitive: false)
70      SAME_SITE_REGEX = RE2(';\s*samesite=', case_sensitive: false)
71
72      def ssl?
73        Gitlab.config.gitlab.https
74      end
75
76      def same_site_none_incompatible?(user_agent)
77        return false if user_agent.blank?
78
79        has_webkit_same_site_bug?(user_agent) || drops_unrecognized_same_site_cookies?(user_agent)
80      end
81
82      def has_webkit_same_site_bug?(user_agent)
83        ios_version?(12, user_agent) ||
84          (macos_version?(10, 14, user_agent) && safari?(user_agent))
85      end
86
87      def drops_unrecognized_same_site_cookies?(user_agent)
88        if uc_browser?(user_agent)
89          return !uc_browser_version_at_least?(12, 13, 2, user_agent)
90        end
91
92        chromium_based?(user_agent) && chromium_version_between?(51, 66, user_agent)
93      end
94
95      def ios_version?(major, user_agent)
96        m = IOS_REGEX.match(user_agent)
97
98        return false if m.nil?
99
100        m[1].to_i == major
101      end
102
103      def macos_version?(major, minor, user_agent)
104        m = MACOS_REGEX.match(user_agent)
105
106        return false if m.nil?
107
108        m[1].to_i == major && m[2].to_i == minor
109      end
110
111      def safari?(user_agent)
112        SAFARI_REGEX.match?(user_agent)
113      end
114
115      def chromium_based?(user_agent)
116        CHROMIUM_REGEX.match?(user_agent)
117      end
118
119      def chromium_version_between?(from_major, to_major, user_agent)
120        m = CHROMIUM_VERSION_REGEX.match(user_agent)
121
122        return false if m.nil?
123
124        version = m[1].to_i
125        version >= from_major && version <= to_major
126      end
127
128      def uc_browser?(user_agent)
129        UC_BROWSER_REGEX.match?(user_agent)
130      end
131
132      def uc_browser_version_at_least?(major, minor, build, user_agent)
133        m = UC_BROWSER_VERSION_REGEX.match(user_agent)
134
135        return false if m.nil?
136
137        major_version = m[1].to_i
138        minor_version = m[2].to_i
139        build_version = m[3].to_i
140
141        return major_version > major if major_version != major
142        return minor_version > minor if minor_version != minor
143
144        build_version >= build
145      end
146    end
147  end
148end
149