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