import collections import base64 import binascii import hashlib import hmac import json from datetime import ( date, datetime, timedelta, ) import re import string import time import warnings from webob.compat import ( PY3, text_type, bytes_, text_, native_, string_types, ) from webob.util import strings_differ __all__ = ['Cookie', 'CookieProfile', 'SignedCookieProfile', 'SignedSerializer', 'JSONSerializer', 'Base64Serializer', 'make_cookie'] _marker = object() class RequestCookies(collections.MutableMapping): _cache_key = 'webob._parsed_cookies' def __init__(self, environ): self._environ = environ @property def _cache(self): env = self._environ header = env.get('HTTP_COOKIE', '') cache, cache_header = env.get(self._cache_key, ({}, None)) if cache_header == header: return cache d = lambda b: b.decode('utf8') cache = dict((d(k), d(v)) for k,v in parse_cookie(header)) env[self._cache_key] = (cache, header) return cache def _mutate_header(self, name, value): header = self._environ.get('HTTP_COOKIE') had_header = header is not None header = header or '' if PY3: # pragma: no cover header = header.encode('latin-1') bytes_name = bytes_(name, 'ascii') if value is None: replacement = None else: bytes_val = _value_quote(bytes_(value, 'utf-8')) replacement = bytes_name + b'=' + bytes_val matches = _rx_cookie.finditer(header) found = False for match in matches: start, end = match.span() match_name = match.group(1) if match_name == bytes_name: found = True if replacement is None: # remove value header = header[:start].rstrip(b' ;') + header[end:] else: # replace value header = header[:start] + replacement + header[end:] break else: if replacement is not None: if header: header += b'; ' + replacement else: header = replacement if header: self._environ['HTTP_COOKIE'] = native_(header, 'latin-1') elif had_header: self._environ['HTTP_COOKIE'] = '' return found def _valid_cookie_name(self, name): if not isinstance(name, string_types): raise TypeError(name, 'cookie name must be a string') if not isinstance(name, text_type): name = text_(name, 'utf-8') try: bytes_cookie_name = bytes_(name, 'ascii') except UnicodeEncodeError: raise TypeError('cookie name must be encodable to ascii') if not _valid_cookie_name(bytes_cookie_name): raise TypeError('cookie name must be valid according to RFC 6265') return name def __setitem__(self, name, value): name = self._valid_cookie_name(name) if not isinstance(value, string_types): raise ValueError(value, 'cookie value must be a string') if not isinstance(value, text_type): try: value = text_(value, 'utf-8') except UnicodeDecodeError: raise ValueError( value, 'cookie value must be utf-8 binary or unicode') self._mutate_header(name, value) def __getitem__(self, name): return self._cache[name] def get(self, name, default=None): return self._cache.get(name, default) def __delitem__(self, name): name = self._valid_cookie_name(name) found = self._mutate_header(name, None) if not found: raise KeyError(name) def keys(self): return self._cache.keys() def values(self): return self._cache.values() def items(self): return self._cache.items() if not PY3: def iterkeys(self): return self._cache.iterkeys() def itervalues(self): return self._cache.itervalues() def iteritems(self): return self._cache.iteritems() def __contains__(self, name): return name in self._cache def __iter__(self): return self._cache.__iter__() def __len__(self): return len(self._cache) def clear(self): self._environ['HTTP_COOKIE'] = '' def __repr__(self): return '' % (self._cache,) class Cookie(dict): def __init__(self, input=None): if input: self.load(input) def load(self, data): morsel = {} for key, val in _parse_cookie(data): if key.lower() in _c_keys: morsel[key] = val else: morsel = self.add(key, val) def add(self, key, val): if not isinstance(key, bytes): key = key.encode('ascii', 'replace') if not _valid_cookie_name(key): return {} r = Morsel(key, val) dict.__setitem__(self, key, r) return r __setitem__ = add def serialize(self, full=True): return '; '.join(m.serialize(full) for m in self.values()) def values(self): return [m for _, m in sorted(self.items())] __str__ = serialize def __repr__(self): return '<%s: [%s]>' % (self.__class__.__name__, ', '.join(map(repr, self.values()))) def _parse_cookie(data): if PY3: # pragma: no cover data = data.encode('latin-1') for key, val in _rx_cookie.findall(data): yield key, _unquote(val) def parse_cookie(data): """ Parse cookies ignoring anything except names and values """ return ((k,v) for k,v in _parse_cookie(data) if _valid_cookie_name(k)) def cookie_property(key, serialize=lambda v: v): def fset(self, v): self[key] = serialize(v) return property(lambda self: self[key], fset) def serialize_max_age(v): if isinstance(v, timedelta): v = str(v.seconds + v.days*24*60*60) elif isinstance(v, int): v = str(v) return bytes_(v) def serialize_cookie_date(v): if v is None: return None elif isinstance(v, bytes): return v elif isinstance(v, text_type): return v.encode('ascii') elif isinstance(v, int): v = timedelta(seconds=v) if isinstance(v, timedelta): v = datetime.utcnow() + v if isinstance(v, (datetime, date)): v = v.timetuple() r = time.strftime('%%s, %d-%%s-%Y %H:%M:%S GMT', v) return bytes_(r % (weekdays[v[6]], months[v[1]]), 'ascii') class Morsel(dict): __slots__ = ('name', 'value') def __init__(self, name, value): self.name = bytes_(name, encoding='ascii') self.value = bytes_(value, encoding='ascii') assert _valid_cookie_name(self.name) self.update(dict.fromkeys(_c_keys, None)) path = cookie_property(b'path') domain = cookie_property(b'domain') comment = cookie_property(b'comment') expires = cookie_property(b'expires', serialize_cookie_date) max_age = cookie_property(b'max-age', serialize_max_age) httponly = cookie_property(b'httponly', bool) secure = cookie_property(b'secure', bool) def __setitem__(self, k, v): k = bytes_(k.lower(), 'ascii') if k in _c_keys: dict.__setitem__(self, k, v) def serialize(self, full=True): result = [] add = result.append add(self.name + b'=' + _value_quote(self.value)) if full: for k in _c_valkeys: v = self[k] if v: info = _c_renames[k] name = info['name'] quoter = info['quoter'] add(name + b'=' + quoter(v)) expires = self[b'expires'] if expires: add(b'expires=' + expires) if self.secure: add(b'secure') if self.httponly: add(b'HttpOnly') return native_(b'; '.join(result), 'ascii') __str__ = serialize def __repr__(self): return '<%s: %s=%r>' % (self.__class__.__name__, native_(self.name), native_(self.value) ) # # parsing # _re_quoted = r'"(?:\\"|.)*?"' # any doublequoted string _legal_special_chars = "~!@#$%^&*()_+=-`.?|:/(){}<>'" _re_legal_char = r"[\w\d%s]" % re.escape(_legal_special_chars) _re_expires_val = r"\w{3},\s[\w\d-]{9,11}\s[\d:]{8}\sGMT" _re_cookie_str_key = r"(%s+?)" % _re_legal_char _re_cookie_str_equal = r"\s*=\s*" _re_unquoted_val = r"(?:%s|\\(?:[0-3][0-7][0-7]|.))*" % _re_legal_char _re_cookie_str_val = r"(%s|%s|%s)" % (_re_quoted, _re_expires_val, _re_unquoted_val) _re_cookie_str = _re_cookie_str_key + _re_cookie_str_equal + _re_cookie_str_val _rx_cookie = re.compile(bytes_(_re_cookie_str, 'ascii')) _rx_unquote = re.compile(bytes_(r'\\([0-3][0-7][0-7]|.)', 'ascii')) _bchr = (lambda i: bytes([i])) if PY3 else chr _ch_unquote_map = dict((bytes_('%03o' % i), _bchr(i)) for i in range(256) ) _ch_unquote_map.update((v, v) for v in list(_ch_unquote_map.values())) _b_dollar_sign = ord('$') if PY3 else '$' _b_quote_mark = ord('"') if PY3 else '"' def _unquote(v): #assert isinstance(v, bytes) if v and v[0] == v[-1] == _b_quote_mark: v = v[1:-1] return _rx_unquote.sub(_ch_unquote, v) def _ch_unquote(m): return _ch_unquote_map[m.group(1)] # # serializing # # these chars can be in cookie value see # http://tools.ietf.org/html/rfc6265#section-4.1.1 and # https://github.com/Pylons/webob/pull/104#issuecomment-28044314 # # ! (0x21), "#$%&'()*+" (0x25-0x2B), "-./0123456789:" (0x2D-0x3A), # "<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[" (0x3C-0x5B), # "]^_`abcdefghijklmnopqrstuvwxyz{|}~" (0x5D-0x7E) _allowed_special_chars = "!#$%&'()*+-./:<=>?@[]^_`{|}~" _allowed_cookie_chars = (string.ascii_letters + string.digits + _allowed_special_chars) _allowed_cookie_bytes = bytes_(_allowed_cookie_chars) # these are the characters accepted in cookie *names* # From http://tools.ietf.org/html/rfc2616#section-2.2: # token = 1* # separators = "(" | ")" | "<" | ">" | "@" # | "," | ";" | ":" | "\" | <"> # | "/" | "[" | "]" | "?" | "=" # | "{" | "}" | SP | HT # # CTL = # _valid_token_chars = string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~" _valid_token_bytes = bytes_(_valid_token_chars) # this is a map used to escape the values _escape_noop_chars = _allowed_cookie_chars + ' ' _escape_map = dict((chr(i), '\\%03o' % i) for i in range(256)) _escape_map.update(zip(_escape_noop_chars, _escape_noop_chars)) if PY3: # pragma: no cover # convert to {int -> bytes} _escape_map = dict( (ord(k), bytes_(v, 'ascii')) for k, v in _escape_map.items() ) _escape_char = _escape_map.__getitem__ weekdays = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun') months = (None, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec') # This is temporary, until we can remove this from _value_quote _should_raise = None def __warn_or_raise(text, warn_class, to_raise, raise_reason): if _should_raise: raise to_raise(raise_reason) else: warnings.warn(text, warn_class, stacklevel=2) def _value_quote(v): # This looks scary, but is simple. We remove all valid characters from the # string, if we end up with leftovers (string is longer than 0, we have # invalid characters in our value) leftovers = v.translate(None, _allowed_cookie_bytes) if leftovers: __warn_or_raise( "Cookie value contains invalid bytes: (%s). Future versions " "will raise ValueError upon encountering invalid bytes." % (leftovers,), RuntimeWarning, ValueError, 'Invalid characters in cookie value' ) #raise ValueError('Invalid characters in cookie value') return b'"' + b''.join(map(_escape_char, v)) + b'"' return v def _valid_cookie_name(key): return isinstance(key, bytes) and not ( key.translate(None, _valid_token_bytes) # Not explicitly required by RFC6265, may consider removing later: or key[0] == _b_dollar_sign or key.lower() in _c_keys ) def _path_quote(v): return b''.join(map(_escape_char, v)) _domain_quote = _path_quote _max_age_quote = _path_quote _c_renames = { b"path" : {'name':b"Path", 'quoter':_path_quote}, b"comment" : {'name':b"Comment", 'quoter':_value_quote}, b"domain" : {'name':b"Domain", 'quoter':_domain_quote}, b"max-age" : {'name':b"Max-Age", 'quoter':_max_age_quote}, } _c_valkeys = sorted(_c_renames) _c_keys = set(_c_renames) _c_keys.update([b'expires', b'secure', b'httponly']) def make_cookie(name, value, max_age=None, path='/', domain=None, secure=False, httponly=False, comment=None): """ Generate a cookie value. If ``value`` is None, generate a cookie value with an expiration date in the past""" # We are deleting the cookie, override max_age and expires if value is None: value = b'' # Note that the max-age value of zero is technically contraspec; # RFC6265 says that max-age cannot be zero. However, all browsers # appear to support this to mean "delete immediately". # http://www.timwilson.id.au/news-three-critical-problems-with-rfc6265.html max_age = 0 expires = 'Wed, 31-Dec-97 23:59:59 GMT' # Convert max_age to seconds elif isinstance(max_age, timedelta): max_age = (max_age.days * 60 * 60 * 24) + max_age.seconds expires = max_age else: expires = max_age morsel = Morsel(name, value) if domain is not None: morsel.domain = bytes_(domain) if path is not None: morsel.path = bytes_(path) if httponly: morsel.httponly = True if secure: morsel.secure = True if max_age is not None: morsel.max_age = max_age if expires is not None: morsel.expires = expires if comment is not None: morsel.comment = bytes_(comment) return morsel.serialize() class JSONSerializer(object): """ A serializer which uses `json.dumps`` and ``json.loads``""" def dumps(self, appstruct): return bytes_(json.dumps(appstruct), encoding='utf-8') def loads(self, bstruct): # NB: json.loads raises ValueError if no json object can be decoded # so we don't have to do it explicitly here. return json.loads(text_(bstruct, encoding='utf-8')) class Base64Serializer(object): """ A serializer which uses base64 to encode/decode data""" def __init__(self, serializer=None): if serializer is None: serializer = JSONSerializer() self.serializer = serializer def dumps(self, appstruct): """ Given an ``appstruct``, serialize and sign the data. Returns a bytestring. """ cstruct = self.serializer.dumps(appstruct) # will be bytes return base64.urlsafe_b64encode(cstruct) def loads(self, bstruct): """ Given a ``bstruct`` (a bytestring), verify the signature and then deserialize and return the deserialized value. A ``ValueError`` will be raised if the signature fails to validate. """ try: cstruct = base64.urlsafe_b64decode(bytes_(bstruct)) except (binascii.Error, TypeError) as e: raise ValueError('Badly formed base64 data: %s' % e) return self.serializer.loads(cstruct) class SignedSerializer(object): """ A helper to cryptographically sign arbitrary content using HMAC. The serializer accepts arbitrary functions for performing the actual serialization and deserialization. ``secret`` A string which is used to sign the cookie. The secret should be at least as long as the block size of the selected hash algorithm. For ``sha512`` this would mean a 128 bit (64 character) secret. ``salt`` A namespace to avoid collisions between different uses of a shared secret. ``hashalg`` The HMAC digest algorithm to use for signing. The algorithm must be supported by the :mod:`hashlib` library. Default: ``'sha512'``. ``serializer`` An object with two methods: `loads`` and ``dumps``. The ``loads`` method should accept bytes and return a Python object. The ``dumps`` method should accept a Python object and return bytes. A ``ValueError`` should be raised for malformed inputs. Default: ``None`, which will use a derivation of :func:`json.dumps` and ``json.loads``. """ def __init__(self, secret, salt, hashalg='sha512', serializer=None, ): self.salt = salt self.secret = secret self.hashalg = hashalg try: # bwcompat with webob <= 1.3.1, leave latin-1 as the default self.salted_secret = bytes_(salt or '') + bytes_(secret) except UnicodeEncodeError: self.salted_secret = ( bytes_(salt or '', 'utf-8') + bytes_(secret, 'utf-8')) self.digestmod = lambda string=b'': hashlib.new(self.hashalg, string) self.digest_size = self.digestmod().digest_size if serializer is None: serializer = JSONSerializer() self.serializer = serializer def dumps(self, appstruct): """ Given an ``appstruct``, serialize and sign the data. Returns a bytestring. """ cstruct = self.serializer.dumps(appstruct) # will be bytes sig = hmac.new(self.salted_secret, cstruct, self.digestmod).digest() return base64.urlsafe_b64encode(sig + cstruct).rstrip(b'=') def loads(self, bstruct): """ Given a ``bstruct`` (a bytestring), verify the signature and then deserialize and return the deserialized value. A ``ValueError`` will be raised if the signature fails to validate. """ try: b64padding = b'=' * (-len(bstruct) % 4) fstruct = base64.urlsafe_b64decode(bytes_(bstruct) + b64padding) except (binascii.Error, TypeError) as e: raise ValueError('Badly formed base64 data: %s' % e) cstruct = fstruct[self.digest_size:] expected_sig = fstruct[:self.digest_size] sig = hmac.new( self.salted_secret, bytes_(cstruct), self.digestmod).digest() if strings_differ(sig, expected_sig): raise ValueError('Invalid signature') return self.serializer.loads(cstruct) _default = object() class CookieProfile(object): """ A helper class that helps bring some sanity to the insanity that is cookie handling. The helper is capable of generating multiple cookies if necessary to support subdomains and parent domains. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. ``max_age`` The maximum age of the cookie used for sessioning (in seconds). Default: ``None`` (browser scope). ``secure`` The 'secure' flag of the session cookie. Default: ``False``. ``httponly`` Hide the cookie from Javascript by setting the 'HttpOnly' flag of the session cookie. Default: ``False``. ``path`` The path used for the session cookie. Default: ``'/'``. ``domains`` The domain(s) used for the session cookie. Default: ``None`` (no domain). Can be passed an iterable containing multiple domains, this will set multiple cookies one for each domain. ``serializer`` An object with two methods: ``loads`` and ``dumps``. The ``loads`` method should accept a bytestring and return a Python object. The ``dumps`` method should accept a Python object and return bytes. A ``ValueError`` should be raised for malformed inputs. Default: ``None``, which will use a derivation of :func:`json.dumps` and :func:`json.loads`. """ def __init__(self, cookie_name, secure=False, max_age=None, httponly=None, path='/', domains=None, serializer=None ): self.cookie_name = cookie_name self.secure = secure self.max_age = max_age self.httponly = httponly self.path = path self.domains = domains if serializer is None: serializer = Base64Serializer() self.serializer = serializer self.request = None def __call__(self, request): """ Bind a request to a copy of this instance and return it""" return self.bind(request) def bind(self, request): """ Bind a request to a copy of this instance and return it""" selfish = CookieProfile( self.cookie_name, self.secure, self.max_age, self.httponly, self.path, self.domains, self.serializer, ) selfish.request = request return selfish def get_value(self): """ Looks for a cookie by name in the currently bound request, and returns its value. If the cookie profile is not bound to a request, this method will raise a :exc:`ValueError`. Looks for the cookie in the cookies jar, and if it can find it it will attempt to deserialize it. Returns ``None`` if there is no cookie or if the value in the cookie cannot be successfully deserialized. """ if not self.request: raise ValueError('No request bound to cookie profile') cookie = self.request.cookies.get(self.cookie_name) if cookie is not None: try: return self.serializer.loads(bytes_(cookie)) except ValueError: return None def set_cookies(self, response, value, domains=_default, max_age=_default, path=_default, secure=_default, httponly=_default): """ Set the cookies on a response.""" cookies = self.get_headers( value, domains=domains, max_age=max_age, path=path, secure=secure, httponly=httponly ) response.headerlist.extend(cookies) return response def get_headers(self, value, domains=_default, max_age=_default, path=_default, secure=_default, httponly=_default): """ Retrieve raw headers for setting cookies. Returns a list of headers that should be set for the cookies to be correctly tracked. """ if value is None: max_age = 0 bstruct = None else: bstruct = self.serializer.dumps(value) return self._get_cookies( bstruct, domains=domains, max_age=max_age, path=path, secure=secure, httponly=httponly ) def _get_cookies(self, value, domains, max_age, path, secure, httponly): """Internal function This returns a list of cookies that are valid HTTP Headers. :environ: The request environment :value: The value to store in the cookie :domains: The domains, overrides any set in the CookieProfile :max_age: The max_age, overrides any set in the CookieProfile :path: The path, overrides any set in the CookieProfile :secure: Set this cookie to secure, overrides any set in CookieProfile :httponly: Set this cookie to HttpOnly, overrides any set in CookieProfile """ # If the user doesn't provide values, grab the defaults if domains is _default: domains = self.domains if max_age is _default: max_age = self.max_age if path is _default: path = self.path if secure is _default: secure = self.secure if httponly is _default: httponly = self.httponly # Length selected based upon http://browsercookielimits.x64.me if value is not None and len(value) > 4093: raise ValueError( 'Cookie value is too long to store (%s bytes)' % len(value) ) cookies = [] if not domains: cookievalue = make_cookie( self.cookie_name, value, path=path, max_age=max_age, httponly=httponly, secure=secure ) cookies.append(('Set-Cookie', cookievalue)) else: for domain in domains: cookievalue = make_cookie( self.cookie_name, value, path=path, domain=domain, max_age=max_age, httponly=httponly, secure=secure, ) cookies.append(('Set-Cookie', cookievalue)) return cookies class SignedCookieProfile(CookieProfile): """ A helper for generating cookies that are signed to prevent tampering. By default this will create a single cookie, given a value it will serialize it, then use HMAC to cryptographically sign the data. Finally the result is base64-encoded for transport. This way a remote user can not tamper with the value without uncovering the secret/salt used. ``secret`` A string which is used to sign the cookie. The secret should be at least as long as the block size of the selected hash algorithm. For ``sha512`` this would mean a 128 bit (64 character) secret. ``salt`` A namespace to avoid collisions between different uses of a shared secret. ``hashalg`` The HMAC digest algorithm to use for signing. The algorithm must be supported by the :mod:`hashlib` library. Default: ``'sha512'``. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. ``max_age`` The maximum age of the cookie used for sessioning (in seconds). Default: ``None`` (browser scope). ``secure`` The 'secure' flag of the session cookie. Default: ``False``. ``httponly`` Hide the cookie from Javascript by setting the 'HttpOnly' flag of the session cookie. Default: ``False``. ``path`` The path used for the session cookie. Default: ``'/'``. ``domains`` The domain(s) used for the session cookie. Default: ``None`` (no domain). Can be passed an iterable containing multiple domains, this will set multiple cookies one for each domain. ``serializer`` An object with two methods: `loads`` and ``dumps``. The ``loads`` method should accept bytes and return a Python object. The ``dumps`` method should accept a Python object and return bytes. A ``ValueError`` should be raised for malformed inputs. Default: ``None`, which will use a derivation of :func:`json.dumps` and ``json.loads``. """ def __init__(self, secret, salt, cookie_name, secure=False, max_age=None, httponly=False, path="/", domains=None, hashalg='sha512', serializer=None, ): self.secret = secret self.salt = salt self.hashalg = hashalg self.original_serializer = serializer signed_serializer = SignedSerializer( secret, salt, hashalg, serializer=self.original_serializer, ) CookieProfile.__init__( self, cookie_name, secure=secure, max_age=max_age, httponly=httponly, path=path, domains=domains, serializer=signed_serializer, ) def bind(self, request): """ Bind a request to a copy of this instance and return it""" selfish = SignedCookieProfile( self.secret, self.salt, self.cookie_name, self.secure, self.max_age, self.httponly, self.path, self.domains, self.hashalg, self.original_serializer, ) selfish.request = request return selfish