from __future__ import annotations import re import string from datetime import datetime from typing import TYPE_CHECKING, Literal, cast from sanic.exceptions import ServerError if TYPE_CHECKING: from sanic.compat import Header SameSite = ( Literal["Strict"] | Literal["Lax"] | Literal["None"] | Literal["strict"] | Literal["lax"] | Literal["none"] ) DEFAULT_MAX_AGE = 0 SAMESITE_VALUES = ("strict", "lax", "none") LEGAL_CHARS = string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~:" UNESCAPED_CHARS = LEGAL_CHARS + " ()/<=>?@[]{}" TRANSLATOR = {ch: f"\\{ch:03o}" for ch in bytes(range(32)) + b'";\\\x7f'} def _quote(str): # no cov r"""Quote a string for use in a cookie header. If the string does not need to be double-quoted, then just return the string. Otherwise, surround the string in doublequotes and quote (with a \) special characters. """ if str is None or _is_legal_key(str): return str else: return f'"{str.translate(TRANSLATOR)}"' _is_legal_key = re.compile("[%s]+" % re.escape(LEGAL_CHARS)).fullmatch class CookieJar: """A container to manipulate cookies. CookieJar dynamically writes headers as cookies are added and removed It gets around the limitation of one header per name by using the MultiHeader class to provide a unique key that encodes to Set-Cookie. Args: headers (Header): The headers object to write cookies to. """ HEADER_KEY = "Set-Cookie" def __init__(self, headers: Header): self.headers = headers def __len__(self): # no cov return len(self.cookies) @property def cookies(self) -> list[Cookie]: """A list of cookies in the CookieJar. Returns: List[Cookie]: A list of cookies in the CookieJar. """ return self.headers.getall(self.HEADER_KEY, []) def get_cookie( self, key: str, path: str = "/", domain: str | None = None, host_prefix: bool = False, secure_prefix: bool = False, ) -> Cookie | None: """Fetch a cookie from the CookieJar. Args: key (str): The key of the cookie to fetch. path (str, optional): The path of the cookie. Defaults to `"/"`. domain (Optional[str], optional): The domain of the cookie. Defaults to `None`. host_prefix (bool, optional): Whether to add __Host- as a prefix to the key. This requires that path="/", domain=None, and secure=True. Defaults to `False`. secure_prefix (bool, optional): Whether to add __Secure- as a prefix to the key. This requires that secure=True. Defaults to `False`. Returns: Optional[Cookie]: The cookie if it exists, otherwise `None`. """ # noqa: E501 for cookie in self.cookies: if ( cookie.key == Cookie.make_key(key, host_prefix, secure_prefix) and cookie.path == path and cookie.domain == domain ): return cookie return None def has_cookie( self, key: str, path: str = "/", domain: str | None = None, host_prefix: bool = False, secure_prefix: bool = False, ) -> bool: """Check if a cookie exists in the CookieJar. Args: key (str): The key of the cookie to check. path (str, optional): The path of the cookie. Defaults to `"/"`. domain (Optional[str], optional): The domain of the cookie. Defaults to `None`. host_prefix (bool, optional): Whether to add __Host- as a prefix to the key. This requires that path="/", domain=None, and secure=True. Defaults to `False`. secure_prefix (bool, optional): Whether to add __Secure- as a prefix to the key. This requires that secure=True. Defaults to `False`. Returns: bool: Whether the cookie exists. """ # noqa: E501 for cookie in self.cookies: if ( cookie.key == Cookie.make_key(key, host_prefix, secure_prefix) and cookie.path == path and cookie.domain == domain ): return True return False def add_cookie( self, key: str, value: str, *, path: str = "/", domain: str | None = None, secure: bool = True, max_age: int | None = None, expires: datetime | None = None, httponly: bool = False, samesite: SameSite | None = "Lax", partitioned: bool = False, comment: str | None = None, host_prefix: bool = False, secure_prefix: bool = False, ) -> Cookie: """Add a cookie to the CookieJar. Args: key (str): Key of the cookie. value (str): Value of the cookie. path (str, optional): Path of the cookie. Defaults to "/". domain (Optional[str], optional): Domain of the cookie. Defaults to None. secure (bool, optional): Whether to set it as a secure cookie. Defaults to True. max_age (Optional[int], optional): Max age of the cookie in seconds; if set to 0 a browser should delete it. Defaults to None. expires (Optional[datetime], optional): When the cookie expires; if set to None browsers should set it as a session cookie. Defaults to None. httponly (bool, optional): Whether to set it as HTTP only. Defaults to False. samesite (Optional[SameSite], optional): How to set the samesite property, should be strict, lax, or none (case insensitive). Defaults to "Lax". partitioned (bool, optional): Whether to set it as partitioned. Defaults to False. comment (Optional[str], optional): A cookie comment. Defaults to None. host_prefix (bool, optional): Whether to add __Host- as a prefix to the key. This requires that path="/", domain=None, and secure=True. Defaults to False. secure_prefix (bool, optional): Whether to add __Secure- as a prefix to the key. This requires that secure=True. Defaults to False. Returns: Cookie: The instance of the created cookie. Raises: ServerError: If host_prefix is set without secure=True. ServerError: If host_prefix is set without path="/" and domain=None. ServerError: If host_prefix is set with domain. ServerError: If secure_prefix is set without secure=True. ServerError: If partitioned is set without host_prefix=True. Examples: Basic usage ```python cookie = add_cookie('name', 'value') ``` Adding a cookie with a custom path and domain ```python cookie = add_cookie('name', 'value', path='/custom', domain='example.com') ``` Adding a secure, HTTP-only cookie with a comment ```python cookie = add_cookie('name', 'value', secure=True, httponly=True, comment='My Cookie') ``` Adding a cookie with a max age of 60 seconds ```python cookie = add_cookie('name', 'value', max_age=60) ``` """ # noqa: E501 cookie = Cookie( key, value, path=path, expires=expires, comment=comment, domain=domain, max_age=max_age, secure=secure, httponly=httponly, samesite=samesite, partitioned=partitioned, host_prefix=host_prefix, secure_prefix=secure_prefix, ) self.headers.add(self.HEADER_KEY, cookie) return cookie def delete_cookie( self, key: str, *, path: str = "/", domain: str | None = None, secure: bool = True, host_prefix: bool = False, secure_prefix: bool = False, ) -> None: """ Delete a cookie This will effectively set it as Max-Age: 0, which a browser should interpret it to mean: "delete the cookie". Since it is a browser/client implementation, your results may vary depending upon which client is being used. :param key: The key to be deleted :type key: str :param path: Path of the cookie, defaults to None :type path: Optional[str], optional :param domain: Domain of the cookie, defaults to None :type domain: Optional[str], optional :param secure: Whether to delete a secure cookie. Defaults to True. :param secure: bool :param host_prefix: Whether to add __Host- as a prefix to the key. This requires that path="/", domain=None, and secure=True, defaults to False :type host_prefix: bool :param secure_prefix: Whether to add __Secure- as a prefix to the key. This requires that secure=True, defaults to False :type secure_prefix: bool """ if host_prefix and not (secure and path == "/" and domain is None): raise ServerError( "Cannot set host_prefix on a cookie without " "path='/', domain=None, and secure=True" ) if secure_prefix and not secure: raise ServerError( "Cannot set secure_prefix on a cookie without secure=True" ) cookies: list[Cookie] = self.headers.popall(self.HEADER_KEY, []) existing_cookie = None for cookie in cookies: if ( cookie.key != Cookie.make_key(key, host_prefix, secure_prefix) or cookie.path != path or cookie.domain != domain ): self.headers.add(self.HEADER_KEY, cookie) elif existing_cookie is None: existing_cookie = cookie if existing_cookie is not None: # Use all the same values as the cookie to be deleted # except value="" and max_age=0 self.add_cookie( key=key, value="", path=existing_cookie.path, domain=existing_cookie.domain, secure=existing_cookie.secure, max_age=0, httponly=existing_cookie.httponly, partitioned=existing_cookie.partitioned, samesite=existing_cookie.samesite, host_prefix=host_prefix, secure_prefix=secure_prefix, ) else: self.add_cookie( key=key, value="", path=path, domain=domain, secure=secure, max_age=0, samesite=None, host_prefix=host_prefix, secure_prefix=secure_prefix, ) class Cookie: """A representation of a HTTP cookie, providing an interface to manipulate cookie attributes intended for a response. This class is a simplified representation of a cookie, similar to the Morsel SimpleCookie in Python's standard library. It allows the manipulation of various cookie attributes including path, domain, security settings, and others. Several "smart defaults" are provided to make it easier to create cookies that are secure by default. These include: - Setting the `secure` flag to `True` by default - Setting the `samesite` flag to `Lax` by default Args: key (str): The key (name) of the cookie. value (str): The value of the cookie. path (str, optional): The path for the cookie. Defaults to "/". domain (Optional[str], optional): The domain for the cookie. Defaults to `None`. secure (bool, optional): Whether the cookie is secure. Defaults to `True`. max_age (Optional[int], optional): The maximum age of the cookie in seconds. Defaults to `None`. expires (Optional[datetime], optional): The expiration date of the cookie. Defaults to `None`. httponly (bool, optional): HttpOnly flag for the cookie. Defaults to `False`. samesite (Optional[SameSite], optional): The SameSite attribute for the cookie. Defaults to `"Lax"`. partitioned (bool, optional): Whether the cookie is partitioned. Defaults to `False`. comment (Optional[str], optional): A comment for the cookie. Defaults to `None`. host_prefix (bool, optional): Whether to use the host prefix. Defaults to `False`. secure_prefix (bool, optional): Whether to use the secure prefix. Defaults to `False`. """ # noqa: E501 HOST_PREFIX = "__Host-" SECURE_PREFIX = "__Secure-" __slots__ = ( "key", "value", "_path", "_comment", "_domain", "_secure", "_httponly", "_partitioned", "_expires", "_max_age", "_samesite", ) _keys = { "path": "Path", "comment": "Comment", "domain": "Domain", "max-age": "Max-Age", "expires": "expires", "samesite": "SameSite", # "version": "Version", "secure": "Secure", "httponly": "HttpOnly", "partitioned": "Partitioned", } _flags = {"secure", "httponly", "partitioned"} def __init__( self, key: str, value: str, *, path: str = "/", domain: str | None = None, secure: bool = True, max_age: int | None = None, expires: datetime | None = None, httponly: bool = False, samesite: SameSite | None = "Lax", partitioned: bool = False, comment: str | None = None, host_prefix: bool = False, secure_prefix: bool = False, ): if key in self._keys: raise KeyError("Cookie name is a reserved word") if not _is_legal_key(key): raise KeyError("Cookie key contains illegal characters") if host_prefix: if not secure: raise ServerError( "Cannot set host_prefix on a cookie without secure=True" ) if path != "/": raise ServerError( "Cannot set host_prefix on a cookie unless path='/'" ) if domain: raise ServerError( "Cannot set host_prefix on a cookie with a defined domain" ) elif secure_prefix and not secure: raise ServerError( "Cannot set secure_prefix on a cookie without secure=True" ) if partitioned and not host_prefix: # This is technically possible, but it is not advisable so we will # take a stand and say "don't shoot yourself in the foot" raise ServerError( "Cannot create a partitioned cookie without " "also setting host_prefix=True" ) self.key = self.make_key(key, host_prefix, secure_prefix) self.value = value self._path = path self._comment = comment self._domain = domain self._secure = secure self._httponly = httponly self._partitioned = partitioned self._expires: datetime | None = None self._max_age: int | None = None self._samesite: SameSite | None = None if expires is not None: self.expires = expires if max_age is not None: self.max_age = max_age if samesite is not None: self.samesite = samesite def __str__(self): """Format as a Set-Cookie header value.""" output = ["{}={}".format(self.key, _quote(self.value))] ordered_keys = list(self._keys.keys()) for key in sorted( self._keys.keys(), key=lambda k: ordered_keys.index(k) ): value = getattr(self, key.replace("-", "_")) if value is not None and value is not False: if key == "max-age": try: output.append("%s=%d" % (self._keys[key], value)) except TypeError: output.append("{}={}".format(self._keys[key], value)) elif key == "expires": output.append( "%s=%s" % ( self._keys[key], value.strftime("%a, %d-%b-%Y %T GMT"), ) ) elif key in self._flags: output.append(self._keys[key]) else: output.append("{}={}".format(self._keys[key], value)) return "; ".join(output) @property def path(self) -> str: # no cov """The path of the cookie. Defaults to `"/"`.""" return self._path @path.setter def path(self, value: str) -> None: # no cov self._path = value @property def expires(self) -> datetime | None: # no cov """The expiration date of the cookie. Defaults to `None`.""" return self._expires @expires.setter def expires(self, value: datetime) -> None: # no cov if not isinstance(value, datetime): raise TypeError("Cookie 'expires' property must be a datetime") self._expires = value @property def comment(self) -> str | None: # no cov """A comment for the cookie. Defaults to `None`.""" return self._comment @comment.setter def comment(self, value: str) -> None: # no cov self._comment = value @property def domain(self) -> str | None: # no cov """The domain of the cookie. Defaults to `None`.""" return self._domain @domain.setter def domain(self, value: str) -> None: # no cov self._domain = value @property def max_age(self) -> int | None: # no cov """The maximum age of the cookie in seconds. Defaults to `None`.""" return self._max_age @max_age.setter def max_age(self, value: int) -> None: # no cov if not str(value).isdigit(): raise ValueError("Cookie max-age must be an integer") self._max_age = value @property def secure(self) -> bool: # no cov """Whether the cookie is secure. Defaults to `True`.""" return self._secure @secure.setter def secure(self, value: bool) -> None: # no cov self._secure = value @property def httponly(self) -> bool: # no cov """Whether the cookie is HTTP only. Defaults to `False`.""" return self._httponly @httponly.setter def httponly(self, value: bool) -> None: # no cov self._httponly = value @property def samesite(self) -> SameSite | None: # no cov """The SameSite attribute for the cookie. Defaults to `"Lax"`.""" return self._samesite @samesite.setter def samesite(self, value: SameSite) -> None: # no cov if value.lower() not in SAMESITE_VALUES: raise TypeError( "Cookie 'samesite' property must " f"be one of: {','.join(SAMESITE_VALUES)}" ) self._samesite = cast(SameSite, value.title()) @property def partitioned(self) -> bool: # no cov """Whether the cookie is partitioned. Defaults to `False`.""" return self._partitioned @partitioned.setter def partitioned(self, value: bool) -> None: # no cov self._partitioned = value @classmethod def make_key( cls, key: str, host_prefix: bool = False, secure_prefix: bool = False ) -> str: """Create a cookie key with the appropriate prefix. Cookies can have one ow two prefixes. The first is `__Host-` which requires that the cookie be set with `path="/", domain=None, and secure=True`. The second is `__Secure-` which requires that `secure=True`. They cannot be combined. Args: key (str): The key (name) of the cookie. host_prefix (bool, optional): Whether to add __Host- as a prefix to the key. This requires that path="/", domain=None, and secure=True. Defaults to `False`. secure_prefix (bool, optional): Whether to add __Secure- as a prefix to the key. This requires that secure=True. Defaults to `False`. Raises: ServerError: If both host_prefix and secure_prefix are set. Returns: str: The key with the appropriate prefix. """ # noqa: E501 if host_prefix and secure_prefix: raise ServerError( "Both host_prefix and secure_prefix were requested. " "A cookie should have only one prefix." ) elif host_prefix: key = cls.HOST_PREFIX + key elif secure_prefix: key = cls.SECURE_PREFIX + key return key