securecookies

starlette-securecookies

GitHub Workflow Status PyPI - Downloads GitHub Buy a tree

Customizable middleware for adding automatic cookie encryption and decryption to Starlette applications.

Tested support on Python 3.7, 3.8, 3.9, and 3.10, 3.11 on macOS, Windows, and Linux.

How it works?

sequenceDiagram Browser->>+Middleware: Encrypted 'Cookie' headers Middleware->>+Application: Decrypted cookies Application->>-Middleware: Plaintext cookies Middleware->>-Browser: Encrypted 'Set-Cookie' headers Note over Application: *The Application may be your service<br />or any additional middleware.

For any incoming cookies:

  1. Requests sent from the client's browser to your application are intercepted by SecureCookiesMiddleware.
  2. All Cookie headers are filtered and parsed. Only cookies in the included_cookies and excluded_cookies parameters are parsed. All cookies are included by default.
  3. The cookies are decrypted. If a cookie cannot be decrypted, or is otherwise invalid, it is discarded by default (discard_invalid=True).
  4. Any included and valid encrypted cookies in the ASGI request scope are replaced by the decrypted ones.
  5. The request scope is passed to any future middleware, and eventually your application. Cookies can be read normally anywhere downstream.

For any outgoing cookies:

  1. Your application sets cookies with response.set_cookie, or by any other means of creating Set-Cookie headers.
  2. Other middleware run and add additional cookies, like SessionMiddleware.
  3. All responses returned by your application are intercepted by SecureCookiesMiddleware.
  4. Cookies in the included_cookies and not in the excluded_cookies parameters are re-encrypted, and their attributes (like "SameSite" and "HttpOnly") are overridden by any parameters set in SecureCookiesMiddleware.
  5. The cookies in the response are replaced by the re-encrypted cookies, and the response is eventually propagated to the client's browser.

Installation

$ pdm add starlette-securecookies
# or
$ python -m pip install --user starlette-securecookies

Usage

This is a Starlette-based middleware, so it can be used in any Starlette application or Starlette-based framework (like FastAPI).

For example,

from starlette.applications import Starlette
from starlette.middleware import Middleware

from securecookies import SecureCookiesMiddleware

middleware = [
    Middleware(
        SecureCookiesMiddleware, secrets=["SUPER SECRET SECRET"],
        # your other middleware
    )
]

app = Starlette(routes=..., middleware=middleware)

Note that if you're using another middleware that injects cookies into the response (such as SessionMiddleware), you have to make sure SecureCookiesMiddleware executes _after_ it so the cookie is present at encryption-time. Counter intuitively, in practice this means ensuring SecureCookiesMiddleware is _first_ in the list of middleware.

Extras

starlette-securecookies provides some extras that introduce or patch secure cookie functionality into existing tools. They all reside in the securecookies.extras module. Currently there is only one, but more are welcome by recommendation or Pull Request!

  • csrf.SecureCSRFMiddleware: Adds compatibility to the CSRF middleware provided by starlette_csrf. To use it, simply add it to your list of middleware (keep in mind the ordering). If you don't want to specify starlette_csrf as a direct dependency, you can also install it through the [csrf] package extra.

License

This software is licensed under the BSD 3-Clause License.

This package is Treeware. If you use it in production, consider buying the world a tree to thank me for my work. By contributing to my forest, you’ll be creating employment for local families and restoring wildlife habitats.

1""".. include:: ../README.md"""
2
3from securecookies import extras
4
5from .middleware import BadArgumentError, SecureCookiesMiddleware
6
7__all__ = ["SecureCookiesMiddleware", "BadArgumentError", "extras"]
class SecureCookiesMiddleware(starlette.middleware.base.BaseHTTPMiddleware):
 26class SecureCookiesMiddleware(BaseHTTPMiddleware):
 27    """
 28    Provides automatic secure cookie support through symmetric Fernet encryption. As per
 29    the `cryptography` docs, security is achieved through:
 30
 31    - 128-bit key AES in CBC mode using PKCS7 padding for encryption.
 32    - HMAC using SHA256 for authentication.
 33    """
 34
 35    secrets: List[str]
 36    """
 37    A list of secrets to use when encrypting and decrypting secure cookies. Supplying
 38    multiple secrets enables the use of key rotation, whereby the first key is used for
 39    all encryption, including to re-encrypt cookies previously encrypted with any of
 40    the other list entries.
 41    """
 42
 43    def __init__(
 44        self,
 45        app: ASGIApp,
 46        secrets: List[str],
 47        discard_invalid: bool = True,
 48        cookie_path: Optional[str] = None,
 49        cookie_domain: Optional[str] = None,
 50        cookie_secure: Optional[bool] = None,
 51        cookie_httponly: Optional[bool] = None,
 52        cookie_samesite: Optional[Literal["strict", "lax", "none"]] = None,
 53        excluded_cookies: Optional[List[str]] = None,
 54        included_cookies: Optional[List[str]] = None,
 55    ) -> None:
 56        super().__init__(app)
 57        self.mfernet = MultiFernet(Fernet(s) for s in secrets)
 58        self.discard_invalid = discard_invalid
 59        """ Discard any invalid / unencrypted cookies present in the request."""
 60        self.cookie_path = cookie_path
 61        """ The Path field value to override on every cookie."""
 62        self.cookie_domain = cookie_domain
 63        """ The Domain field value to override on every cookie."""
 64        self.cookie_secure = cookie_secure
 65        """ The Secure field value to override on every cookie."""
 66        self.cookie_httponly = cookie_httponly
 67        """ The HttpOnly field value to override on every cookie."""
 68        self.cookie_samesite = cookie_samesite
 69        """ The SameStime field value to override on every cookie."""
 70        self.excluded_cookies = excluded_cookies
 71        """
 72        The list of cookies to ignore when decrypting and encrypting in
 73        the request --> response cycle. This option is mutually exclusive with
 74        `included_cookies`.
 75        """
 76        self.included_cookies = included_cookies
 77        """
 78        The list of cookies to decrypt and encrypt in the request --> response cycle.
 79        This option is mutually exclusive with `excluded_cookies`.
 80        """
 81
 82        if excluded_cookies and included_cookies:
 83            raise BadArgumentError(
 84                "It doesn't make sense to specify both excluded and included cookies."
 85                " Supply either `excluded_cookies` or `included_cookies` to restrict"
 86                " which cookies should be secure."
 87            )
 88
 89        if cookie_samesite:
 90            samesite = cookie_samesite.lower()
 91
 92            if samesite not in ["strict", "lax", "none"]:
 93                raise BadArgumentError(
 94                    "SameSite attribute must be either 'strict', 'lax' or 'none'"
 95                )
 96            elif samesite == "none" and not self.cookie_secure:
 97                warnings.warn(
 98                    "Insecure cookies with a SameSite='None' attribute may be rejected"
 99                    " on newer browser versions (draft-ietf-httpbis-rfc6265bis). See"
100                    " https://caniuse.com/same-site-cookie-attribute for compat notes."
101                )
102
103    def set_header(self, request: Request, header: str, value: str) -> None:
104        """
105        Adds the given Header to the available Request headers. If the Header already
106        exists, its value is overwritten.
107        """
108        hkey = header.encode("latin-1")
109        request.scope["headers"] = [
110            *(h for h in request.scope["headers"] if h[0] != hkey),
111            (hkey, value.encode("latin-1")),
112        ]
113
114    def decrypt(self, value: str) -> str:
115        """Decrypt the given value using any of the configured secrets."""
116        return self.mfernet.decrypt(value.encode()).decode()
117
118    def encrypt(self, value: str) -> str:
119        """Encrypt the given value using the first configured secret."""
120        return self.mfernet.encrypt(value.encode()).decode()
121
122    def should_process_cookie(self, cookie: str) -> bool:
123        """Determines if the cookie should be included for processing."""
124        return (
125            (not self.included_cookies and not self.excluded_cookies)
126            or (self.included_cookies is not None and cookie in self.included_cookies)
127            or (
128                self.excluded_cookies is not None
129                and cookie not in self.excluded_cookies
130            )
131        )
132
133    async def dispatch(
134        self, request: Request, call_next: RequestResponseEndpoint
135    ) -> Response:
136        if len(request.cookies):
137            cookies: SimpleCookie[str] = SimpleCookie()
138            for cookie, value in request.cookies.items():
139                if self.should_process_cookie(cookie):
140                    try:
141                        # try to decrypt the cookie and pass it along
142                        cookies[cookie] = self.decrypt(value)
143                    except InvalidToken:
144                        # delete invalid or unencrypted cookies unless disabled
145                        if not self.discard_invalid:
146                            cookies[cookie] = value
147
148            # serialize and set the decrypted cookies to the asgi scope headers
149            # we have to modify the scope directly because request objects are not
150            # passed between ASGI applications / middleware.
151            self.set_header(
152                request, "cookie", cookies.output(header="", sep=";").strip()
153            )
154
155        # propagate the modified request
156        response: Response = await call_next(request)
157
158        # Extract the cookie headers to be mutated
159        cookie_headers = response.headers.getlist("set-cookie")
160        del response.headers["set-cookie"]
161
162        for cookie_header in cookie_headers:
163            ncookie: SimpleCookie[str] = SimpleCookie(cookie_header)
164            key = next(iter(ncookie.keys()))
165
166            if self.should_process_cookie(key):
167                ncookie[key] = self.encrypt(ncookie[key].value)
168
169                # Mutate the cookie based on middleware defaults (if provided)
170                if self.cookie_path is not None:
171                    ncookie[key]["path"] = self.cookie_path
172
173                if self.cookie_domain is not None:
174                    ncookie[key]["domain"] = self.cookie_domain
175
176                if self.cookie_secure is not None:
177                    ncookie[key]["secure"] = self.cookie_secure
178
179                if self.cookie_httponly is not None:
180                    ncookie[key]["httponly"] = self.cookie_httponly
181
182                if self.cookie_samesite is not None:
183                    ncookie[key]["samesite"] = self.cookie_samesite
184
185            response.headers.append(
186                "set-cookie", ncookie.output(header="", sep=";").strip()
187            )
188
189        return response

Provides automatic secure cookie support through symmetric Fernet encryption. As per the cryptography docs, security is achieved through:

  • 128-bit key AES in CBC mode using PKCS7 padding for encryption.
  • HMAC using SHA256 for authentication.
SecureCookiesMiddleware( app: Callable[[MutableMapping[str, Any], Callable[[], Awaitable[MutableMapping[str, Any]]], Callable[[MutableMapping[str, Any]], Awaitable[NoneType]]], Awaitable[NoneType]], secrets: List[str], discard_invalid: bool = True, cookie_path: Union[str, NoneType] = None, cookie_domain: Union[str, NoneType] = None, cookie_secure: Union[bool, NoneType] = None, cookie_httponly: Union[bool, NoneType] = None, cookie_samesite: Union[typing_extensions.Literal['strict', 'lax', 'none'], NoneType] = None, excluded_cookies: Union[List[str], NoneType] = None, included_cookies: Union[List[str], NoneType] = None)
 43    def __init__(
 44        self,
 45        app: ASGIApp,
 46        secrets: List[str],
 47        discard_invalid: bool = True,
 48        cookie_path: Optional[str] = None,
 49        cookie_domain: Optional[str] = None,
 50        cookie_secure: Optional[bool] = None,
 51        cookie_httponly: Optional[bool] = None,
 52        cookie_samesite: Optional[Literal["strict", "lax", "none"]] = None,
 53        excluded_cookies: Optional[List[str]] = None,
 54        included_cookies: Optional[List[str]] = None,
 55    ) -> None:
 56        super().__init__(app)
 57        self.mfernet = MultiFernet(Fernet(s) for s in secrets)
 58        self.discard_invalid = discard_invalid
 59        """ Discard any invalid / unencrypted cookies present in the request."""
 60        self.cookie_path = cookie_path
 61        """ The Path field value to override on every cookie."""
 62        self.cookie_domain = cookie_domain
 63        """ The Domain field value to override on every cookie."""
 64        self.cookie_secure = cookie_secure
 65        """ The Secure field value to override on every cookie."""
 66        self.cookie_httponly = cookie_httponly
 67        """ The HttpOnly field value to override on every cookie."""
 68        self.cookie_samesite = cookie_samesite
 69        """ The SameStime field value to override on every cookie."""
 70        self.excluded_cookies = excluded_cookies
 71        """
 72        The list of cookies to ignore when decrypting and encrypting in
 73        the request --> response cycle. This option is mutually exclusive with
 74        `included_cookies`.
 75        """
 76        self.included_cookies = included_cookies
 77        """
 78        The list of cookies to decrypt and encrypt in the request --> response cycle.
 79        This option is mutually exclusive with `excluded_cookies`.
 80        """
 81
 82        if excluded_cookies and included_cookies:
 83            raise BadArgumentError(
 84                "It doesn't make sense to specify both excluded and included cookies."
 85                " Supply either `excluded_cookies` or `included_cookies` to restrict"
 86                " which cookies should be secure."
 87            )
 88
 89        if cookie_samesite:
 90            samesite = cookie_samesite.lower()
 91
 92            if samesite not in ["strict", "lax", "none"]:
 93                raise BadArgumentError(
 94                    "SameSite attribute must be either 'strict', 'lax' or 'none'"
 95                )
 96            elif samesite == "none" and not self.cookie_secure:
 97                warnings.warn(
 98                    "Insecure cookies with a SameSite='None' attribute may be rejected"
 99                    " on newer browser versions (draft-ietf-httpbis-rfc6265bis). See"
100                    " https://caniuse.com/same-site-cookie-attribute for compat notes."
101                )
secrets: List[str]

A list of secrets to use when encrypting and decrypting secure cookies. Supplying multiple secrets enables the use of key rotation, whereby the first key is used for all encryption, including to re-encrypt cookies previously encrypted with any of the other list entries.

discard_invalid

Discard any invalid / unencrypted cookies present in the request.

cookie_path

The Path field value to override on every cookie.

cookie_domain

The Domain field value to override on every cookie.

cookie_secure

The Secure field value to override on every cookie.

cookie_httponly

The HttpOnly field value to override on every cookie.

cookie_samesite

The SameStime field value to override on every cookie.

excluded_cookies

The list of cookies to ignore when decrypting and encrypting in the request --> response cycle. This option is mutually exclusive with included_cookies.

included_cookies

The list of cookies to decrypt and encrypt in the request --> response cycle. This option is mutually exclusive with excluded_cookies.

def set_header( self, request: starlette.requests.Request, header: str, value: str) -> None:
103    def set_header(self, request: Request, header: str, value: str) -> None:
104        """
105        Adds the given Header to the available Request headers. If the Header already
106        exists, its value is overwritten.
107        """
108        hkey = header.encode("latin-1")
109        request.scope["headers"] = [
110            *(h for h in request.scope["headers"] if h[0] != hkey),
111            (hkey, value.encode("latin-1")),
112        ]

Adds the given Header to the available Request headers. If the Header already exists, its value is overwritten.

def decrypt(self, value: str) -> str:
114    def decrypt(self, value: str) -> str:
115        """Decrypt the given value using any of the configured secrets."""
116        return self.mfernet.decrypt(value.encode()).decode()

Decrypt the given value using any of the configured secrets.

def encrypt(self, value: str) -> str:
118    def encrypt(self, value: str) -> str:
119        """Encrypt the given value using the first configured secret."""
120        return self.mfernet.encrypt(value.encode()).decode()

Encrypt the given value using the first configured secret.

async def dispatch( self, request: starlette.requests.Request, call_next: Callable[[starlette.requests.Request], Awaitable[starlette.responses.Response]]) -> starlette.responses.Response:
133    async def dispatch(
134        self, request: Request, call_next: RequestResponseEndpoint
135    ) -> Response:
136        if len(request.cookies):
137            cookies: SimpleCookie[str] = SimpleCookie()
138            for cookie, value in request.cookies.items():
139                if self.should_process_cookie(cookie):
140                    try:
141                        # try to decrypt the cookie and pass it along
142                        cookies[cookie] = self.decrypt(value)
143                    except InvalidToken:
144                        # delete invalid or unencrypted cookies unless disabled
145                        if not self.discard_invalid:
146                            cookies[cookie] = value
147
148            # serialize and set the decrypted cookies to the asgi scope headers
149            # we have to modify the scope directly because request objects are not
150            # passed between ASGI applications / middleware.
151            self.set_header(
152                request, "cookie", cookies.output(header="", sep=";").strip()
153            )
154
155        # propagate the modified request
156        response: Response = await call_next(request)
157
158        # Extract the cookie headers to be mutated
159        cookie_headers = response.headers.getlist("set-cookie")
160        del response.headers["set-cookie"]
161
162        for cookie_header in cookie_headers:
163            ncookie: SimpleCookie[str] = SimpleCookie(cookie_header)
164            key = next(iter(ncookie.keys()))
165
166            if self.should_process_cookie(key):
167                ncookie[key] = self.encrypt(ncookie[key].value)
168
169                # Mutate the cookie based on middleware defaults (if provided)
170                if self.cookie_path is not None:
171                    ncookie[key]["path"] = self.cookie_path
172
173                if self.cookie_domain is not None:
174                    ncookie[key]["domain"] = self.cookie_domain
175
176                if self.cookie_secure is not None:
177                    ncookie[key]["secure"] = self.cookie_secure
178
179                if self.cookie_httponly is not None:
180                    ncookie[key]["httponly"] = self.cookie_httponly
181
182                if self.cookie_samesite is not None:
183                    ncookie[key]["samesite"] = self.cookie_samesite
184
185            response.headers.append(
186                "set-cookie", ncookie.output(header="", sep=";").strip()
187            )
188
189        return response
class BadArgumentError(builtins.Exception):
19class BadArgumentError(Exception):
20    """
21    Generic error arguments with invalid values, or combinations of conflicting
22    arguments.
23    """

Generic error arguments with invalid values, or combinations of conflicting arguments.

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback