pax_global_header 0000666 0000000 0000000 00000000064 14715516742 0014526 g ustar 00root root 0000000 0000000 52 comment=e23af3215539317707c60266e1b5122a77f026ca asgi-csrf-0.11/ 0000775 0000000 0000000 00000000000 14715516742 0013325 5 ustar 00root root 0000000 0000000 asgi-csrf-0.11/.github/ 0000775 0000000 0000000 00000000000 14715516742 0014665 5 ustar 00root root 0000000 0000000 asgi-csrf-0.11/.github/workflows/ 0000775 0000000 0000000 00000000000 14715516742 0016722 5 ustar 00root root 0000000 0000000 asgi-csrf-0.11/.github/workflows/publish.yml 0000664 0000000 0000000 00000003117 14715516742 0021115 0 ustar 00root root 0000000 0000000 name: Publish Python Package on: release: types: [created] permissions: contents: read jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip cache-dependency-path: pyproject.toml - name: Install dependencies run: | pip install '.[test]' - name: Run tests run: | python -m pytest build: runs-on: ubuntu-latest needs: [test] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip cache-dependency-path: pyproject.toml - name: Install dependencies run: | pip install setuptools wheel build - name: Build run: | python -m build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: name: python-packages path: dist/ publish: name: Publish to PyPI runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') needs: [build] environment: release permissions: id-token: write steps: - name: Download distribution packages uses: actions/download-artifact@v4 with: name: python-packages path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 asgi-csrf-0.11/.github/workflows/test.yml 0000664 0000000 0000000 00000002136 14715516742 0020426 0 ustar 00root root 0000000 0000000 name: Test on: [push, pull_request] permissions: contents: read jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip cache-dependency-path: pyproject.toml - name: Install dependencies run: | pip install '.[test]' - name: Run tests run: | pytest --cov-fail-under=99 --cov asgi_csrf - name: Upload coverage to codecov.io run: bash <(curl -s https://codecov.io/bash) if: always() env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Build package to upload as artifact run: | pip install setuptools wheel build python -m build - name: Store the distribution packages if: matrix.python-version == '3.12' uses: actions/upload-artifact@v4 with: name: python-packages path: dist/ asgi-csrf-0.11/.gitignore 0000664 0000000 0000000 00000000141 14715516742 0015311 0 ustar 00root root 0000000 0000000 .venv __pycache__/ *.py[cod] *$py.class venv .eggs .pytest_cache *.egg-info .DS_Store dist build asgi-csrf-0.11/LICENSE 0000664 0000000 0000000 00000026135 14715516742 0014341 0 ustar 00root root 0000000 0000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. asgi-csrf-0.11/README.md 0000664 0000000 0000000 00000016432 14715516742 0014612 0 ustar 00root root 0000000 0000000 # asgi-csrf [](https://pypi.org/project/asgi-csrf/) [](https://github.com/simonw/asgi-csrf/releases) [](https://codecov.io/gh/simonw/asgi-csrf) [](https://github.com/simonw/asgi-csrf/blob/main/LICENSE) ASGI middleware for protecting against CSRF attacks ## Installation pip install asgi-csrf ## Background See the [OWASP guide to Cross Site Request Forgery (CSRF)](https://owasp.org/www-community/attacks/csrf) and their [Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html). This middleware implements the [Double Submit Cookie pattern](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie), where a cookie is set that is then compared to a `csrftoken` hidden form field or a `x-csrftoken` HTTP header. ## Usage Decorate your ASGI application like this: ```python from asgi_csrf import asgi_csrf from .my_asgi_app import app app = asgi_csrf(app, signing_secret="secret-goes-here") ``` The middleware will set a `csrftoken` cookie, if one is missing. The value of that token will be made available to your ASGI application through the `scope["csrftoken"]` function. Your application code should include that value as a hidden form field in any POST forms: ```html
``` Note that `request.scope["csrftoken"]()` is a function that returns a string. Calling that function also lets the middleware know that the cookie should be set by that page, if the user does not already have that cookie. If the cookie needs to be set, the middleware will add a `Vary: Cookie` header to the response to ensure it is not incorrectly cached by any CDNs or intermediary proxies. The middleware will return a 403 forbidden error for any POST requests that do not include the matching `csrftoken` - either in the POST data or in a `x-csrftoken` HTTP header (useful for JavaScript `fetch()` calls). The `signing_secret` is used to sign the tokens, to protect against subdomain vulnerabilities. If you do not pass in an explicit `signing_secret` parameter, the middleware will look for a `ASGI_CSRF_SECRET` environment variable. If it cannot find that environment variable, it will generate a random secret which will persist for the lifetime of the server. This means that if you do not configure a specific secret your user's `csrftoken` cookies will become invalid every time the server restarts! You should configure a secret. ## Always setting the cookie if it is not already set By default this middleware only sets the `csrftoken` cookie if the user encounters a page that needs it - due to that page calling the `request.scope["csrftoken"]()` function, for example to populate a hidden field in a form. If you would like the middleware to set that cookie for any incoming request that does not already provide the cookie, you can use the `always_set_cookie=True` argument: ```python app = asgi_csrf(app, signing_secret="secret-goes-here", always_set_cookie=True) ``` ## Configuring the cookie The middleware can be configured with several options to control how the CSRF cookie is set: ```python app = asgi_csrf( app, signing_secret="secret-goes-here", cookie_name="csrftoken", cookie_path="/", cookie_domain=None, cookie_secure=False, cookie_samesite="Lax" ) ``` - `cookie_name`: The name of the cookie to set. Defaults to `"csrftoken"`. - `cookie_path`: The path for which the cookie is valid. Defaults to `"/"`, meaning the cookie is valid for the entire domain. - `cookie_domain`: The domain for which the cookie is valid. Defaults to `None`, which means the cookie will only be valid for the current domain. - `cookie_secure`: If set to `True`, the cookie will only be sent over HTTPS connections. Defaults to `False`. - `cookie_samesite`: Controls how the cookie is sent with cross-site requests. Can be set to `"Strict"`, `"Lax"`, or `"None"`. Defaults to `"Lax"`. ## Other cases that skip CSRF protection If the request includes an `Authorization: Bearer ...` header, commonly used by OAuth and JWT authentication, the request will not be required to include a CSRF token. This is because browsers cannot send those headers in a context that can be abused. If the request has no cookies at all it will be allowed through, since CSRF protection is only necessary for requests from authenticated users. ### always_protect If you have paths that should always be protected even without cookies - your login form for example (to avoid [login CSRF](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#login-csrf) attacks) you can protect those paths by passing them as the ``always_protect`` parameter: ```python app = asgi_csrf( app, signing_secret="secret-goes-here", always_protect={"/login"} ) ``` ### skip_if_scope There may be situations in which you want to opt-out of CSRF protection even for authenticated POST requests - this is often the case for web APIs for example. The `skip_if_scope=` parameter can be used to provide a callback function which is passed an ASGI scope and returns `True` if CSRF protection should be skipped for that request. This example skips CSRF protection for any incoming request where the request path starts with `/api/`: ```python def skip_api_paths(scope) return scope["path"].startswith("/api/") app = asgi_csrf( app, signing_secret="secret-goes-here", skip_if_scope=skip_api_paths ) ``` ## Custom errors with send_csrf_failed By default, when a CSRF token is missing or invalid, the middleware will return a 403 Forbidden response page with a short error message. You can customize this behavior by passing a `send_csrf_failed` function to the middleware. This function should accept the ASGI `scope` and `send` functions, and the `message_id` of the error that occurred. The `message_id` will be an integer representing an item from the `asgi_csrf.Errors` enum. This example shows how you could customize the error message based on that `message_id`: ```python async def custom_csrf_failed(scope, send, message_id): assert scope["type"] == "http" await send( { "type": "http.response.start", "status": 403, "headers": [[b"content-type", b"text/html; charset=utf-8"]], } ) await send( { "type": "http.response.body", "body": { Errors.FORM_URLENCODED_MISMATCH: "custom form-urlencoded error", Errors.MULTIPART_MISMATCH: "custom multipart error", Errors.FILE_BEFORE_TOKEN: "custom file before token error", Errors.UNKNOWN_CONTENT_TYPE: "custom unknown content type error", } .get(message_id, "") .encode("utf-8"), } ) app = asgi_csrf( app, signing_secret="secret-goes-here", send_csrf_failed=custom_csrf_failed ) ``` asgi-csrf-0.11/asgi_csrf.py 0000664 0000000 0000000 00000032205 14715516742 0015641 0 ustar 00root root 0000000 0000000 from http.cookies import SimpleCookie from enum import Enum import fnmatch from functools import wraps from python_multipart import FormParser import os from urllib.parse import parse_qsl from itsdangerous.url_safe import URLSafeSerializer from itsdangerous import BadSignature import secrets DEFAULT_COOKIE_NAME = "csrftoken" DEFAULT_COOKIE_PATH = "/" DEFAULT_COOKIE_DOMAIN = None DEFAULT_COOKIE_SECURE = False DEFAULT_COOKIE_SAMESITE = "Lax" DEFAULT_FORM_INPUT = "csrftoken" DEFAULT_HTTP_HEADER = "x-csrftoken" DEFAULT_SIGNING_NAMESPACE = "csrftoken" SCOPE_KEY = "csrftoken" ENV_SECRET = "ASGI_CSRF_SECRET" class Errors(Enum): FORM_URLENCODED_MISMATCH = 1 MULTIPART_MISMATCH = 2 FILE_BEFORE_TOKEN = 3 UNKNOWN_CONTENT_TYPE = 4 error_messages = { Errors.FORM_URLENCODED_MISMATCH: "form-urlencoded POST field did not match cookie", Errors.MULTIPART_MISMATCH: "multipart/form-data POST field did not match cookie", Errors.FILE_BEFORE_TOKEN: "File encountered before csrftoken - make sure csrftoken is first in the HTML", Errors.UNKNOWN_CONTENT_TYPE: "Unknown content-type", } def asgi_csrf_decorator( cookie_name=DEFAULT_COOKIE_NAME, http_header=DEFAULT_HTTP_HEADER, form_input=DEFAULT_FORM_INPUT, signing_secret=None, signing_namespace=DEFAULT_SIGNING_NAMESPACE, always_protect=None, always_set_cookie=False, skip_if_scope=None, cookie_path=DEFAULT_COOKIE_PATH, cookie_domain=DEFAULT_COOKIE_DOMAIN, cookie_secure=DEFAULT_COOKIE_SECURE, cookie_samesite=DEFAULT_COOKIE_SAMESITE, send_csrf_failed=None, ): send_csrf_failed = send_csrf_failed or default_send_csrf_failed if signing_secret is None: signing_secret = os.environ.get(ENV_SECRET, None) if signing_secret is None: signing_secret = make_secret(128) signer = URLSafeSerializer(signing_secret) def _asgi_csrf_decorator(app): @wraps(app) async def app_wrapped_with_csrf(scope, receive, send): if scope["type"] != "http": await app(scope, receive, send) return cookies = cookies_from_scope(scope) csrftoken = None has_csrftoken_cookie = False should_set_cookie = False page_needs_vary_header = False if cookie_name in cookies: try: csrftoken = cookies.get(cookie_name, "") signer.loads(csrftoken, signing_namespace) except BadSignature: csrftoken = "" else: has_csrftoken_cookie = True else: if always_set_cookie: should_set_cookie = True if not has_csrftoken_cookie: csrftoken = signer.dumps(make_secret(16), signing_namespace) def get_csrftoken(): nonlocal should_set_cookie nonlocal page_needs_vary_header page_needs_vary_header = True if not has_csrftoken_cookie: should_set_cookie = True return csrftoken scope = {**scope, **{SCOPE_KEY: get_csrftoken}} async def wrapped_send(event): if event["type"] == "http.response.start": original_headers = event.get("headers") or [] new_headers = [] if page_needs_vary_header: # Loop through original headers, modify or add "vary" found_vary = False for key, value in original_headers: if key == b"vary": found_vary = True vary_bits = [v.strip() for v in value.split(b",")] if b"Cookie" not in vary_bits: vary_bits.append(b"Cookie") value = b", ".join(vary_bits) new_headers.append((key, value)) if not found_vary: new_headers.append((b"vary", b"Cookie")) else: new_headers = original_headers if should_set_cookie: cookie_attrs = [ "{}={}".format(cookie_name, csrftoken), "Path={}".format(cookie_path), "SameSite={}".format(cookie_samesite), ] if cookie_domain is not None: cookie_attrs.append("Domain={}".format(cookie_domain)) if cookie_secure: cookie_attrs.append("Secure") new_headers.append( ( b"set-cookie", "; ".join(cookie_attrs).encode("utf-8"), ) ) event = { "type": "http.response.start", "status": event["status"], "headers": new_headers, } await send(event) # Apply to anything that isn't GET, HEAD, OPTIONS, TRACE (like Django does) if scope["method"] in {"GET", "HEAD", "OPTIONS", "TRACE"}: await app(scope, receive, wrapped_send) else: # Check for CSRF token in various places headers = dict(scope.get("headers" or [])) if secrets.compare_digest( headers.get(http_header.encode("latin-1"), b"").decode("latin-1"), csrftoken, ): # x-csrftoken header matches await app(scope, receive, wrapped_send) return # If no cookies, skip check UNLESS path is in always_protect if not headers.get(b"cookie"): if always_protect is None or scope["path"] not in always_protect: await app(scope, receive, wrapped_send) return # Skip CSRF if skip_if_scope tells us to if skip_if_scope and skip_if_scope(scope): await app(scope, receive, wrapped_send) return # Authorization: Bearer skips CSRF check if ( headers.get(b"authorization", b"") .decode("latin-1") .startswith("Bearer ") ): await app(scope, receive, wrapped_send) return # We need to look for it in the POST body content_type = headers.get(b"content-type", b"").split(b";", 1)[0] if content_type == b"application/x-www-form-urlencoded": # Consume entire POST body and check for csrftoken field post_data, replay_receive = await _parse_form_urlencoded(receive) if secrets.compare_digest(post_data.get(form_input, ""), csrftoken): # All is good! Forward on the request and replay the body await app(scope, replay_receive, wrapped_send) return else: await send_csrf_failed( scope, wrapped_send, Errors.FORM_URLENCODED_MISMATCH ) return elif content_type == b"multipart/form-data": # Consume non-file items until we see a csrftoken # If we see a file item first, it's an error boundary = headers.get(b"content-type").split(b"; boundary=")[1] assert boundary is not None, "missing 'boundary' header: {}".format( repr(headers) ) # Consume enough POST body to find the csrftoken, or error if form seen first try: ( csrftoken_from_body, replay_receive, ) = await _parse_multipart_form_data(boundary, receive) if not secrets.compare_digest( csrftoken_from_body or "", csrftoken ): await send_csrf_failed( scope, wrapped_send, Errors.MULTIPART_MISMATCH, ) return except FileBeforeToken: await send_csrf_failed( scope, wrapped_send, Errors.FILE_BEFORE_TOKEN, ) return # Now replay the body await app(scope, replay_receive, wrapped_send) return else: await send_csrf_failed( scope, wrapped_send, Errors.UNKNOWN_CONTENT_TYPE ) return return app_wrapped_with_csrf return _asgi_csrf_decorator async def _parse_form_urlencoded(receive): # Returns {key: value}, replay_receive # where replay_receive is an awaitable that can replay what was received # We ignore cases like foo=one&foo=two because we do not need to # handle that case for our single csrftoken= argument body = b"" more_body = True messages = [] while more_body: message = await receive() assert message["type"] == "http.request", message messages.append(message) body += message.get("body", b"") more_body = message.get("more_body", False) async def replay_receive(): if messages: return messages.pop(0) else: return await receive() return dict(parse_qsl(body.decode("utf-8"))), replay_receive class NoToken(Exception): pass class TokenFound(Exception): pass class FileBeforeToken(Exception): pass async def _parse_multipart_form_data(boundary, receive): # Returns (csrftoken, replay_receive) - or raises an exception csrftoken = None def on_field(field): if field.field_name == b"csrftoken": csrftoken = field.value.decode("utf-8") raise TokenFound(csrftoken) class ErrorOnWrite: def __init__(self, file_name, field_name, config): pass def write(self, data): raise FileBeforeToken body = b"" more_body = True messages = [] async def replay_receive(): if messages: return messages.pop(0) else: return await receive() form_parser = FormParser( "multipart/form-data", on_field, lambda: None, boundary=boundary, FileClass=ErrorOnWrite, ) try: while more_body: message = await receive() assert message["type"] == "http.request", message messages.append(message) form_parser.write(message.get("body", b"")) more_body = message.get("more_body", False) except TokenFound as t: return t.args[0], replay_receive return None, replay_receive async def default_send_csrf_failed(scope, send, message_id): assert scope["type"] == "http" await send( { "type": "http.response.start", "status": 403, "headers": [[b"content-type", b"text/html; charset=utf-8"]], } ) message = error_messages.get(message_id) or "CSRF validation failed" await send({"type": "http.response.body", "body": message.encode("utf-8")}) def asgi_csrf( app, cookie_name=DEFAULT_COOKIE_NAME, http_header=DEFAULT_HTTP_HEADER, signing_secret=None, signing_namespace=DEFAULT_SIGNING_NAMESPACE, always_protect=None, always_set_cookie=False, skip_if_scope=None, cookie_path=DEFAULT_COOKIE_PATH, cookie_domain=DEFAULT_COOKIE_DOMAIN, cookie_secure=DEFAULT_COOKIE_SECURE, cookie_samesite=DEFAULT_COOKIE_SAMESITE, send_csrf_failed=None, ): return asgi_csrf_decorator( cookie_name, http_header, signing_secret=signing_secret, signing_namespace=signing_namespace, always_protect=always_protect, always_set_cookie=always_set_cookie, skip_if_scope=skip_if_scope, cookie_path=cookie_path, cookie_domain=cookie_domain, cookie_secure=cookie_secure, cookie_samesite=cookie_samesite, send_csrf_failed=send_csrf_failed, )(app) def cookies_from_scope(scope): cookie = dict(scope.get("headers") or {}).get(b"cookie") if not cookie: return {} simple_cookie = SimpleCookie() simple_cookie.load(cookie.decode("utf8")) return {key: morsel.value for key, morsel in simple_cookie.items()} allowed_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" def make_secret(length): return "".join(secrets.choice(allowed_chars) for i in range(length)) asgi-csrf-0.11/pyproject.toml 0000664 0000000 0000000 00000001626 14715516742 0016246 0 ustar 00root root 0000000 0000000 [project] name = "asgi-csrf" version = "0.11" description = "ASGI middleware for protecting against CSRF attacks" readme = "README.md" requires-python = ">=3.9" authors = [{name = "Simon Willison"}] license = {text = "Apache-2.0"} classifiers = [ "License :: OSI Approved :: Apache Software License" ] dependencies = [ "itsdangerous", "python-multipart>=0.0.13" ] [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project.urls] Homepage = "https://github.com/simonw/asgi-csrf" Changelog = "https://github.com/simonw/asgi-csrf/releases" Issues = "https://github.com/simonw/asgi-csrf/issues" CI = "https://github.com/simonw/asgi-csrf/actions" [project.optional-dependencies] test = [ "pytest", "pytest-asyncio", "httpx>=0.16", "starlette", "pytest-cov", "asgi-lifespan", ] [tool.pytest.ini_options] asyncio_default_fixture_loop_scope = "function" asgi-csrf-0.11/tests/ 0000775 0000000 0000000 00000000000 14715516742 0014467 5 ustar 00root root 0000000 0000000 asgi-csrf-0.11/tests/test_asgi_csrf.py 0000664 0000000 0000000 00000045515 14715516742 0020052 0 ustar 00root root 0000000 0000000 from asgi_lifespan import LifespanManager from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route from asgi_csrf import asgi_csrf, Errors from itsdangerous.url_safe import URLSafeSerializer import httpx import json import pytest SECRET = "secret" async def hello_world(request): if "csrftoken" in request.scope and "_no_token" not in request.query_params: request.scope["csrftoken"]() if request.method == "POST": data = await request.form() data = dict(data) if "csv" in data: data["csv"] = (await data["csv"].read()).decode("utf-8") return JSONResponse(data) headers = {} if "_vary" in request.query_params: headers["Vary"] = request.query_params["_vary"] return JSONResponse({"hello": "world"}, headers=headers) async def hello_world_static(request): return JSONResponse({"hello": "world", "static": True}) hello_world_app = Starlette( routes=[ Route("/", hello_world, methods=["GET", "POST"]), Route("/static", hello_world_static, methods=["GET"]), Route("/api/", hello_world_static, methods=["POST"]), Route("/api/foo", hello_world_static, methods=["POST"]), ] ) @pytest.fixture def app_csrf(): return asgi_csrf(hello_world_app, signing_secret=SECRET) async def custom_csrf_failed(scope, send, message_id): assert scope["type"] == "http" await send( { "type": "http.response.start", "status": 403, "headers": [[b"content-type", b"text/html; charset=utf-8"]], } ) await send( { "type": "http.response.body", "body": { Errors.FORM_URLENCODED_MISMATCH: "custom form-urlencoded error", Errors.MULTIPART_MISMATCH: "custom multipart error", Errors.FILE_BEFORE_TOKEN: "custom file before token error", Errors.UNKNOWN_CONTENT_TYPE: "custom unknown content type error", } .get(message_id, "") .encode("utf-8"), } ) @pytest.fixture def app_csrf_custom_errors(): return asgi_csrf( hello_world_app, signing_secret=SECRET, send_csrf_failed=custom_csrf_failed, ) @pytest.fixture def csrftoken(): return URLSafeSerializer(SECRET).dumps("token", "csrftoken") @pytest.mark.asyncio async def test_hello_world_app(): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=hello_world_app) ) as client: response = await client.get("http://localhost/") assert b'{"hello":"world"}' == response.content def test_signing_secret_if_none_provided(monkeypatch): app = asgi_csrf(hello_world_app) # Should be randomly generated def _get_secret_key(app): found = [ cell.cell_contents for cell in app.__closure__ if "URLSafeSerializer" in repr(cell) ] assert found return found[0].secret_key assert isinstance(_get_secret_key(app), bytes) # Should pick up `ASGI_CSRF_SECRET` if available monkeypatch.setenv("ASGI_CSRF_SECRET", "secret-from-environment") app2 = asgi_csrf(hello_world_app) assert _get_secret_key(app2) == b"secret-from-environment" @pytest.mark.asyncio async def test_asgi_csrf_sets_cookie(app_csrf): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app_csrf)) as client: response = await client.get("http://localhost/") assert b'{"hello":"world"}' == response.content assert "csrftoken" in response.cookies assert response.headers["set-cookie"].endswith("; Path=/; SameSite=Lax") assert "Cookie" == response.headers["vary"] @pytest.mark.asyncio async def test_asgi_csrf_modifies_existing_vary_header(app_csrf): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app_csrf)) as client: response = await client.get("http://localhost/?_vary=User-Agent") assert b'{"hello":"world"}' == response.content assert "csrftoken" in response.cookies assert response.headers["set-cookie"].endswith("; Path=/; SameSite=Lax") assert "User-Agent, Cookie" == response.headers["vary"] @pytest.mark.asyncio async def test_asgi_csrf_sets_no_cookie_or_vary_if_page_has_no_form(app_csrf): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app_csrf)) as client: response = await client.get("http://localhost/static") assert b'{"hello":"world","static":true}' == response.content assert "csrftoken" not in response.cookies assert "vary" not in response.headers @pytest.mark.asyncio async def test_vary_header_only_if_page_contains_csrftoken(app_csrf, csrftoken): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app_csrf), cookies={"csrftoken": csrftoken} ) as client: assert "vary" in (await client.get("http://localhost/")).headers assert "vary" not in (await client.get("http://localhost/?_no_token=1")).headers @pytest.mark.asyncio async def test_headers_passed_through_correctly(app_csrf): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app_csrf)) as client: response = await client.get("http://localhost/static") assert "application/json" == response.headers["content-type"] @pytest.mark.asyncio async def test_asgi_csrf_does_not_set_cookie_if_one_sent(app_csrf, csrftoken): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app_csrf), cookies={"csrftoken": csrftoken} ) as client: response = await client.get("http://localhost/") assert b'{"hello":"world"}' == response.content assert "csrftoken" not in response.cookies @pytest.mark.asyncio async def test_prevents_post_if_cookie_not_sent_in_post(app_csrf, csrftoken): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app_csrf), cookies={"csrftoken": csrftoken} ) as client: response = await client.post("http://localhost/") assert 403 == response.status_code @pytest.mark.asyncio @pytest.mark.parametrize("custom_errors", (False, True)) async def test_prevents_post_if_cookie_not_sent_in_post( custom_errors, app_csrf, app_csrf_custom_errors, csrftoken ): async with httpx.AsyncClient( transport=httpx.ASGITransport( app=app_csrf_custom_errors if custom_errors else app_csrf ), cookies={"csrftoken": csrftoken}, ) as client: response = await client.post( "http://localhost/", data={"csrftoken": csrftoken[-1]}, ) assert 403 == response.status_code assert ( response.text == "custom form-urlencoded error" if custom_errors else "form-urlencoded POST field did not match cookie" ) @pytest.mark.asyncio async def test_allows_post_if_cookie_duplicated_in_header(app_csrf, csrftoken): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app_csrf), cookies={"csrftoken": csrftoken} ) as client: response = await client.post( "http://localhost/", headers={"x-csrftoken": csrftoken}, ) assert 200 == response.status_code @pytest.mark.asyncio async def test_allows_post_if_cookie_duplicated_in_post_data(csrftoken): async with httpx.AsyncClient( transport=httpx.ASGITransport( app=asgi_csrf(hello_world_app, signing_secret=SECRET) ), cookies={"csrftoken": csrftoken}, ) as client: response = await client.post( "http://localhost/", data={"csrftoken": csrftoken, "hello": "world"}, ) assert 200 == response.status_code assert {"csrftoken": csrftoken, "hello": "world"} == json.loads(response.content) @pytest.mark.asyncio async def test_multipart(csrftoken): async with httpx.AsyncClient( transport=httpx.ASGITransport( app=asgi_csrf(hello_world_app, signing_secret=SECRET) ), cookies={"csrftoken": csrftoken}, ) as client: response = await client.post( "http://localhost/", data={"csrftoken": csrftoken}, files={"csv": ("data.csv", "blah,foo\n1,2", "text/csv")}, ) assert response.status_code == 200 assert response.json() == {"csrftoken": csrftoken, "csv": "blah,foo\n1,2"} @pytest.mark.asyncio @pytest.mark.parametrize("custom_errors", (False, True)) async def test_multipart_failure_wrong_token(csrftoken, custom_errors): async with httpx.AsyncClient( transport=httpx.ASGITransport( app=asgi_csrf( hello_world_app, signing_secret=SECRET, send_csrf_failed=custom_csrf_failed if custom_errors else None, ) ), cookies={"csrftoken": csrftoken[:-1]}, ) as client: response = await client.post( "http://localhost/", data={"csrftoken": csrftoken}, files={"csv": ("data.csv", "blah,foo\n1,2", "text/csv")}, ) assert response.status_code == 403 assert ( response.text == "custom multipart error" if custom_errors else "multipart/form-data POST field did not match cookie" ) class TrickEmptyDictionary(dict): # https://github.com/simonw/asgi-csrf/pull/14#issuecomment-674424080 def __bool__(self): return True @pytest.mark.asyncio @pytest.mark.parametrize("custom_errors", (False, True)) async def test_multipart_failure_missing_token(csrftoken, custom_errors): async with httpx.AsyncClient( transport=httpx.ASGITransport( app=asgi_csrf( hello_world_app, signing_secret=SECRET, send_csrf_failed=custom_csrf_failed if custom_errors else None, ) ), cookies={"csrftoken": csrftoken}, ) as client: response = await client.post( "http://localhost/", data={"foo": "bar"}, files=TrickEmptyDictionary(), ) assert response.status_code == 403 assert response.text == ( "custom multipart error" if custom_errors else "multipart/form-data POST field did not match cookie" ) @pytest.mark.asyncio @pytest.mark.parametrize("custom_errors", (False, True)) async def test_multipart_failure_file_comes_before_token(csrftoken, custom_errors): async with httpx.AsyncClient( transport=httpx.ASGITransport( app=asgi_csrf( hello_world_app, signing_secret=SECRET, send_csrf_failed=custom_csrf_failed if custom_errors else None, ) ) ) as client: request = httpx.Request( url="http://localhost/", method="POST", content=( b"--boo\r\n" b'Content-Disposition: form-data; name="csv"; filename="data.csv"' b"\r\nContent-Type: text/csv\r\n\r\n" b"blah,foo\n1,2" b"\r\n" b"--boo\r\n" b'Content-Disposition: form-data; name="csrftoken"\r\n\r\n' + csrftoken.encode("utf-8") + b"\r\n" b"--boo--\r\n" ), headers={"content-type": "multipart/form-data; boundary=boo"}, cookies={"csrftoken": csrftoken}, ) response = await client.send(request) assert response.status_code == 403 assert ( response.text == "custom file before token error" if custom_errors else "File encountered before csrftoken - make sure csrftoken is first in the HTML" ) @pytest.mark.asyncio @pytest.mark.parametrize( "authorization,expected_status", [("Bearer xxx", 200), ("Basic xxx", 403)] ) async def test_post_with_authorization(authorization, expected_status): async with httpx.AsyncClient( transport=httpx.ASGITransport( app=asgi_csrf(hello_world_app, signing_secret=SECRET) ), cookies={"foo": "bar"}, ) as client: response = await client.post( "http://localhost/", headers={"Authorization": authorization}, ) assert expected_status == response.status_code @pytest.mark.asyncio @pytest.mark.parametrize( "cookies,path,expected_status", [ ({}, "/", 200), ({"foo": "bar"}, "/", 403), ({}, "/login", 403), ({"foo": "bar"}, "/login", 403), ], ) async def test_no_cookies_skips_check_unless_path_required( cookies, path, expected_status ): async with httpx.AsyncClient( transport=httpx.ASGITransport( app=asgi_csrf( hello_world_app, signing_secret=SECRET, always_protect={"/login"} ) ), cookies=cookies, ) as client: response = await client.post("http://localhost{}".format(path)) assert expected_status == response.status_code @pytest.mark.asyncio @pytest.mark.parametrize( "cookies,path,expected_status", [ ({}, "/", 200), ({"foo": "bar"}, "/", 403), ({}, "/api/", 200), ({"foo": "bar"}, "/api/", 200), ({}, "/api/foo", 200), ({"foo": "bar"}, "/api/foo", 200), ], ) async def test_skip_if_scope(cookies, path, expected_status): async with httpx.AsyncClient( transport=httpx.ASGITransport( app=asgi_csrf( hello_world_app, signing_secret=SECRET, skip_if_scope=lambda scope: scope["path"].startswith("/api/"), ) ), cookies=cookies, ) as client: response = await client.post("http://localhost{}".format(path)) assert expected_status == response.status_code @pytest.mark.asyncio @pytest.mark.parametrize("always_set_cookie", [True, False]) async def test_always_set_cookie(always_set_cookie): async with httpx.AsyncClient( transport=httpx.ASGITransport( app=asgi_csrf( hello_world_app, signing_secret=SECRET, always_set_cookie=always_set_cookie, ) ) ) as client: response = await client.get("http://localhost/static") assert 200 == response.status_code if always_set_cookie: assert "csrftoken" in response.cookies else: assert "csrftoken" not in response.cookies @pytest.mark.asyncio @pytest.mark.parametrize("send_csrftoken_cookie", [True, False]) async def test_always_set_cookie_unless_cookie_is_set(send_csrftoken_cookie, csrftoken): cookies = {} if send_csrftoken_cookie: cookies["csrftoken"] = csrftoken async with httpx.AsyncClient( transport=httpx.ASGITransport( app=asgi_csrf( hello_world_app, signing_secret=SECRET, always_set_cookie=True ) ), cookies=cookies, ) as client: response = await client.get("http://localhost/static") assert 200 == response.status_code if send_csrftoken_cookie: assert "csrftoken" not in response.cookies else: assert "csrftoken" in response.cookies @pytest.mark.asyncio async def test_asgi_lifespan(): app = asgi_csrf(hello_world_app, signing_secret=SECRET) async with LifespanManager(app): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), cookies={"foo": "bar"}, ) as client: response = await client.post( "http://localhost/", headers={"Authorization": "Bearer xxx"}, ) assert 200 == response.status_code # Tests for different cookie options @pytest.mark.asyncio @pytest.mark.parametrize("cookie_name", ["csrftoken", "custom_csrf"]) async def test_cookie_name(cookie_name): wrapped_app = asgi_csrf( hello_world_app, signing_secret="secret", cookie_name=cookie_name ) transport = httpx.ASGITransport(app=wrapped_app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver" ) as client: response = await client.get("http://testserver/") assert cookie_name in response.cookies @pytest.mark.asyncio @pytest.mark.parametrize("cookie_path", ["/", "/custom"]) async def test_cookie_path(cookie_path): wrapped_app = asgi_csrf( hello_world_app, signing_secret="secret", cookie_path=cookie_path ) transport = httpx.ASGITransport(app=wrapped_app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver" ) as client: response = await client.get("http://testserver/") assert f"Path={cookie_path}" in response.headers["set-cookie"] @pytest.mark.asyncio @pytest.mark.parametrize("cookie_domain", [None, "example.com"]) async def test_cookie_domain(cookie_domain): wrapped_app = asgi_csrf( hello_world_app, signing_secret="secret", cookie_domain=cookie_domain ) transport = httpx.ASGITransport(app=wrapped_app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver" ) as client: response = await client.get("http://testserver/") if cookie_domain: assert f"Domain={cookie_domain}" in response.headers["set-cookie"] else: assert "Domain" not in response.headers["set-cookie"] @pytest.mark.asyncio @pytest.mark.parametrize("cookie_secure", [True, False]) async def test_cookie_secure(cookie_secure): wrapped_app = asgi_csrf( hello_world_app, signing_secret="secret", cookie_secure=cookie_secure ) transport = httpx.ASGITransport(app=wrapped_app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver" ) as client: response = await client.get("http://testserver/") if cookie_secure: assert "Secure" in response.headers["set-cookie"] else: assert "Secure" not in response.headers["set-cookie"] @pytest.mark.asyncio @pytest.mark.parametrize("cookie_samesite", ["Strict", "Lax", "None"]) async def test_cookie_samesite(cookie_samesite): wrapped_app = asgi_csrf( hello_world_app, signing_secret="secret", cookie_samesite=cookie_samesite ) transport = httpx.ASGITransport(app=wrapped_app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver" ) as client: response = await client.get("http://testserver/") assert f"SameSite={cookie_samesite}" in response.headers["set-cookie"] @pytest.mark.asyncio async def test_default_cookie_options(): wrapped_app = asgi_csrf(hello_world_app, signing_secret="secret") transport = httpx.ASGITransport(app=wrapped_app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver" ) as client: response = await client.get("http://testserver/") set_cookie = response.headers["set-cookie"] assert "csrftoken" in set_cookie assert "Path=/" in set_cookie assert "Domain" not in set_cookie assert "Secure" not in set_cookie assert "SameSite=Lax" in set_cookie