Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"), README, CLI strings, and branch (coven → hack-house). Gitea repo renamed cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest + golden-vector test still green, binary is now `hack-house`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
608 lines
21 KiB
Python
608 lines
21 KiB
Python
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
|