Bowe Strickland
2018-10-27 6e49871feaa1a60549206cf5512c9fb7f3d5fd56
commit | author | age
a2c7c7 1 import uuid
MW 2
7c0f09 3 from webob.cookies import CookieProfile
a2c7c7 4 from zope.interface import implementer
7c0f09 5
MW 6
0c29cf 7 from pyramid.compat import bytes_, urlparse, text_
MM 8 from pyramid.exceptions import BadCSRFOrigin, BadCSRFToken
fe0d22 9 from pyramid.interfaces import ICSRFStoragePolicy
a2c7c7 10 from pyramid.settings import aslist
0c29cf 11 from pyramid.util import SimpleSerializer, is_same_domain, strings_differ
a2c7c7 12
MW 13
fe0d22 14 @implementer(ICSRFStoragePolicy)
682a9b 15 class LegacySessionCSRFStoragePolicy(object):
MM 16     """ A CSRF storage policy that defers control of CSRF storage to the
17     session.
18
19     This policy maintains compatibility with legacy ISession implementations
20     that know how to manage CSRF tokens themselves via
21     ``ISession.new_csrf_token`` and ``ISession.get_csrf_token``.
a2c7c7 22
MW 23     Note that using this CSRF implementation requires that
24     a :term:`session factory` is configured.
25
682a9b 26     .. versionadded:: 1.9
MM 27
a2c7c7 28     """
0c29cf 29
a2c7c7 30     def new_csrf_token(self, request):
MW 31         """ Sets a new CSRF token into the session and returns it. """
32         return request.session.new_csrf_token()
33
34     def get_csrf_token(self, request):
682a9b 35         """ Returns the currently active CSRF token from the session,
MM 36         generating a new one if needed."""
a2c7c7 37         return request.session.get_csrf_token()
MW 38
3f14d6 39     def check_csrf_token(self, request, supplied_token):
MM 40         """ Returns ``True`` if the ``supplied_token`` is valid."""
41         expected_token = self.get_csrf_token(request)
42         return not strings_differ(
0c29cf 43             bytes_(expected_token), bytes_(supplied_token)
MM 44         )
3f14d6 45
682a9b 46
MM 47 @implementer(ICSRFStoragePolicy)
48 class SessionCSRFStoragePolicy(object):
49     """ A CSRF storage policy that persists the CSRF token in the session.
50
51     Note that using this CSRF implementation requires that
52     a :term:`session factory` is configured.
53
54     ``key``
55
56         The session key where the CSRF token will be stored.
57         Default: `_csrft_`.
58
59     .. versionadded:: 1.9
60
61     """
0c29cf 62
682a9b 63     _token_factory = staticmethod(lambda: text_(uuid.uuid4().hex))
MM 64
65     def __init__(self, key='_csrft_'):
66         self.key = key
67
68     def new_csrf_token(self, request):
69         """ Sets a new CSRF token into the session and returns it. """
70         token = self._token_factory()
71         request.session[self.key] = token
72         return token
73
74     def get_csrf_token(self, request):
75         """ Returns the currently active CSRF token from the session,
76         generating a new one if needed."""
77         token = request.session.get(self.key, None)
78         if not token:
79             token = self.new_csrf_token(request)
80         return token
3f14d6 81
MM 82     def check_csrf_token(self, request, supplied_token):
83         """ Returns ``True`` if the ``supplied_token`` is valid."""
84         expected_token = self.get_csrf_token(request)
85         return not strings_differ(
0c29cf 86             bytes_(expected_token), bytes_(supplied_token)
MM 87         )
682a9b 88
a2c7c7 89
fe0d22 90 @implementer(ICSRFStoragePolicy)
7c0f09 91 class CookieCSRFStoragePolicy(object):
a2c7c7 92     """ An alternative CSRF implementation that stores its information in
MW 93     unauthenticated cookies, known as the 'Double Submit Cookie' method in the
682a9b 94     `OWASP CSRF guidelines <https://www.owasp.org/index.php/
MM 95     Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#
96     Double_Submit_Cookie>`_. This gives some additional flexibility with
97     regards to scaling as the tokens can be generated and verified by a
98     front-end server.
313c25 99
682a9b 100     .. versionadded:: 1.9
MM 101
87771a 102     .. versionchanged: 1.10
CM 103
104        Added the ``samesite`` option and made the default ``'Lax'``.
105
a2c7c7 106     """
0c29cf 107
682a9b 108     _token_factory = staticmethod(lambda: text_(uuid.uuid4().hex))
313c25 109
0c29cf 110     def __init__(
MM 111         self,
112         cookie_name='csrf_token',
113         secure=False,
114         httponly=False,
115         domain=None,
116         max_age=None,
117         path='/',
118         samesite='Lax',
119     ):
52fde9 120         serializer = SimpleSerializer()
7c0f09 121         self.cookie_profile = CookieProfile(
MW 122             cookie_name=cookie_name,
123             secure=secure,
124             max_age=max_age,
125             httponly=httponly,
126             path=path,
682a9b 127             domains=[domain],
87771a 128             serializer=serializer,
CM 129             samesite=samesite,
7c0f09 130         )
682a9b 131         self.cookie_name = cookie_name
a2c7c7 132
MW 133     def new_csrf_token(self, request):
134         """ Sets a new CSRF token into the request and returns it. """
682a9b 135         token = self._token_factory()
MM 136         request.cookies[self.cookie_name] = token
0c29cf 137
a2c7c7 138         def set_cookie(request, response):
0c29cf 139             self.cookie_profile.set_cookies(response, token)
MM 140
a2c7c7 141         request.add_response_callback(set_cookie)
MW 142         return token
143
144     def get_csrf_token(self, request):
145         """ Returns the currently active CSRF token by checking the cookies
146         sent with the current request."""
7c0f09 147         bound_cookies = self.cookie_profile.bind(request)
MW 148         token = bound_cookies.get_value()
a2c7c7 149         if not token:
MW 150             token = self.new_csrf_token(request)
151         return token
152
3f14d6 153     def check_csrf_token(self, request, supplied_token):
MM 154         """ Returns ``True`` if the ``supplied_token`` is valid."""
155         expected_token = self.get_csrf_token(request)
156         return not strings_differ(
0c29cf 157             bytes_(expected_token), bytes_(supplied_token)
MM 158         )
3f14d6 159
a2c7c7 160
MW 161 def get_csrf_token(request):
162     """ Get the currently active CSRF token for the request passed, generating
163     a new one using ``new_csrf_token(request)`` if one does not exist. This
164     calls the equivalent method in the chosen CSRF protection implementation.
165
2ded2f 166     .. versionadded :: 1.9
3f14d6 167
a2c7c7 168     """
MW 169     registry = request.registry
fe0d22 170     csrf = registry.getUtility(ICSRFStoragePolicy)
313c25 171     return csrf.get_csrf_token(request)
a2c7c7 172
MW 173
174 def new_csrf_token(request):
175     """ Generate a new CSRF token for the request passed and persist it in an
176     implementation defined manner. This calls the equivalent method in the
177     chosen CSRF protection implementation.
178
2ded2f 179     .. versionadded :: 1.9
3f14d6 180
a2c7c7 181     """
MW 182     registry = request.registry
fe0d22 183     csrf = registry.getUtility(ICSRFStoragePolicy)
313c25 184     return csrf.new_csrf_token(request)
a2c7c7 185
MW 186
0c29cf 187 def check_csrf_token(
MM 188     request, token='csrf_token', header='X-CSRF-Token', raises=True
189 ):
313c25 190     """ Check the CSRF token returned by the
682a9b 191     :class:`pyramid.interfaces.ICSRFStoragePolicy` implementation against the
MM 192     value in ``request.POST.get(token)`` (if a POST request) or
313c25 193     ``request.headers.get(header)``. If a ``token`` keyword is not supplied to
JC 194     this function, the string ``csrf_token`` will be used to look up the token
195     in ``request.POST``. If a ``header`` keyword is not supplied to this
196     function, the string ``X-CSRF-Token`` will be used to look up the token in
197     ``request.headers``.
a2c7c7 198
3f14d6 199     If the value supplied by post or by header cannot be verified by the
MM 200     :class:`pyramid.interfaces.ICSRFStoragePolicy`, and ``raises`` is
682a9b 201     ``True``, this function will raise an
MM 202     :exc:`pyramid.exceptions.BadCSRFToken` exception. If the values differ
203     and ``raises`` is ``False``, this function will return ``False``.  If the
204     CSRF check is successful, this function will return ``True``
205     unconditionally.
a2c7c7 206
MW 207     See :ref:`auto_csrf_checking` for information about how to secure your
208     application automatically against CSRF attacks.
209
210     .. versionadded:: 1.4a2
211
212     .. versionchanged:: 1.7a1
213        A CSRF token passed in the query string of the request is no longer
214        considered valid. It must be passed in either the request body or
215        a header.
216
2ded2f 217     .. versionchanged:: 1.9
3f14d6 218        Moved from :mod:`pyramid.session` to :mod:`pyramid.csrf` and updated
MM 219        to use the configured :class:`pyramid.interfaces.ICSRFStoragePolicy` to
220        verify the CSRF token.
221
a2c7c7 222     """
MW 223     supplied_token = ""
224     # We first check the headers for a csrf token, as that is significantly
225     # cheaper than checking the POST body
226     if header is not None:
227         supplied_token = request.headers.get(header, "")
228
229     # If this is a POST/PUT/etc request, then we'll check the body to see if it
230     # has a token. We explicitly use request.POST here because CSRF tokens
231     # should never appear in an URL as doing so is a security issue. We also
232     # explicitly check for request.POST here as we do not support sending form
233     # encoded data over anything but a request.POST.
234     if supplied_token == "" and token is not None:
235         supplied_token = request.POST.get(token, "")
236
3f14d6 237     policy = request.registry.getUtility(ICSRFStoragePolicy)
MM 238     if not policy.check_csrf_token(request, text_(supplied_token)):
a2c7c7 239         if raises:
MW 240             raise BadCSRFToken('check_csrf_token(): Invalid token')
241         return False
242     return True
243
244
245 def check_csrf_origin(request, trusted_origins=None, raises=True):
246     """
2ded2f 247     Check the ``Origin`` of the request to see if it is a cross site request or
a2c7c7 248     not.
MW 249
a54bc1 250     If the value supplied by the ``Origin`` or ``Referer`` header isn't one of
MM 251     the trusted origins and ``raises`` is ``True``, this function will raise a
2ded2f 252     :exc:`pyramid.exceptions.BadCSRFOrigin` exception, but if ``raises`` is
MW 253     ``False``, this function will return ``False`` instead. If the CSRF origin
a2c7c7 254     checks are successful this function will return ``True`` unconditionally.
MW 255
256     Additional trusted origins may be added by passing a list of domain (and
69828b 257     ports if non-standard like ``['example.com', 'dev.example.com:8080']``) in
a2c7c7 258     with the ``trusted_origins`` parameter. If ``trusted_origins`` is ``None``
MW 259     (the default) this list of additional domains will be pulled from the
260     ``pyramid.csrf_trusted_origins`` setting.
261
2ded2f 262     Note that this function will do nothing if ``request.scheme`` is not
MW 263     ``https``.
a2c7c7 264
MW 265     .. versionadded:: 1.7
266
2ded2f 267     .. versionchanged:: 1.9
MW 268        Moved from :mod:`pyramid.session` to :mod:`pyramid.csrf`
3f14d6 269
a2c7c7 270     """
0c29cf 271
a2c7c7 272     def _fail(reason):
MW 273         if raises:
274             raise BadCSRFOrigin(reason)
275         else:
276             return False
277
278     if request.scheme == "https":
279         # Suppose user visits http://example.com/
280         # An active network attacker (man-in-the-middle, MITM) sends a
281         # POST form that targets https://example.com/detonate-bomb/ and
282         # submits it via JavaScript.
283         #
284         # The attacker will need to provide a CSRF cookie and token, but
285         # that's no problem for a MITM when we cannot make any assumptions
286         # about what kind of session storage is being used. So the MITM can
287         # circumvent the CSRF protection. This is true for any HTTP connection,
288         # but anyone using HTTPS expects better! For this reason, for
289         # https://example.com/ we need additional protection that treats
290         # http://example.com/ as completely untrusted. Under HTTPS,
291         # Barth et al. found that the Referer header is missing for
292         # same-domain requests in only about 0.2% of cases or less, so
293         # we can use strict Referer checking.
294
295         # Determine the origin of this request
296         origin = request.headers.get("Origin")
297         if origin is None:
298             origin = request.referrer
299
300         # Fail if we were not able to locate an origin at all
301         if not origin:
302             return _fail("Origin checking failed - no Origin or Referer.")
303
304         # Parse our origin so we we can extract the required information from
305         # it.
306         originp = urlparse.urlparse(origin)
307
308         # Ensure that our Referer is also secure.
309         if originp.scheme != "https":
310             return _fail(
311                 "Referer checking failed - Referer is insecure while host is "
312                 "secure."
313             )
314
315         # Determine which origins we trust, which by default will include the
316         # current origin.
317         if trusted_origins is None:
318             trusted_origins = aslist(
319                 request.registry.settings.get(
0c29cf 320                     "pyramid.csrf_trusted_origins", []
MM 321                 )
a2c7c7 322             )
MW 323
324         if request.host_port not in set(["80", "443"]):
325             trusted_origins.append("{0.domain}:{0.host_port}".format(request))
326         else:
327             trusted_origins.append(request.domain)
328
329         # Actually check to see if the request's origin matches any of our
330         # trusted origins.
0c29cf 331         if not any(
MM 332             is_same_domain(originp.netloc, host) for host in trusted_origins
333         ):
a2c7c7 334             reason = (
MW 335                 "Referer checking failed - {0} does not match any trusted "
336                 "origins."
337             )
338             return _fail(reason.format(origin))
339
340     return True