Skip to content

Commit a18f7d0

Browse files
abraha2dKevin Abrahamsimonw
authored
cookie attribute options
* cookie attribute options, closes #25 * Make test more stable in face of code changes * Configuring the cookie docs, refs #25 * Tests for cookie options, closes #25 --------- Co-authored-by: Kevin Abraham <[email protected]> Co-authored-by: Simon Willison <[email protected]>
1 parent 4db11ae commit a18f7d0

File tree

3 files changed

+143
-8
lines changed

3 files changed

+143
-8
lines changed

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,28 @@ If you would like the middleware to set that cookie for any incoming request tha
6464
app = asgi_csrf(app, signing_secret="secret-goes-here", always_set_cookie=True)
6565
```
6666

67+
## Configuring the cookie
68+
69+
The middleware can be configured with several options to control how the CSRF cookie is set:
70+
71+
```python
72+
app = asgi_csrf(
73+
app,
74+
signing_secret="secret-goes-here",
75+
cookie_name="csrftoken",
76+
cookie_path="/",
77+
cookie_domain=None,
78+
cookie_secure=False,
79+
cookie_samesite="Lax"
80+
)
81+
```
82+
83+
- `cookie_name`: The name of the cookie to set. Defaults to `"csrftoken"`.
84+
- `cookie_path`: The path for which the cookie is valid. Defaults to `"/"`, meaning the cookie is valid for the entire domain.
85+
- `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.
86+
- `cookie_secure`: If set to `True`, the cookie will only be sent over HTTPS connections. Defaults to `False`.
87+
- `cookie_samesite`: Controls how the cookie is sent with cross-site requests. Can be set to `"Strict"`, `"Lax"`, or `"None"`. Defaults to `"Lax"`.
88+
6789
## Other cases that skip CSRF protection
6890

6991
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.
@@ -101,7 +123,7 @@ app = asgi_csrf(
101123
)
102124
```
103125

104-
### send_csrf_failed
126+
## Custom errors with send_csrf_failed
105127

106128
By default, when a CSRF token is missing or invalid, the middleware will return a 403 Forbidden response page with a short error message.
107129

asgi_csrf.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
import secrets
1111

1212
DEFAULT_COOKIE_NAME = "csrftoken"
13+
DEFAULT_COOKIE_PATH = "/"
14+
DEFAULT_COOKIE_DOMAIN = None
15+
DEFAULT_COOKIE_SECURE = False
16+
DEFAULT_COOKIE_SAMESITE = "Lax"
1317
DEFAULT_FORM_INPUT = "csrftoken"
1418
DEFAULT_HTTP_HEADER = "x-csrftoken"
1519
DEFAULT_SIGNING_NAMESPACE = "csrftoken"
@@ -41,6 +45,10 @@ def asgi_csrf_decorator(
4145
always_protect=None,
4246
always_set_cookie=False,
4347
skip_if_scope=None,
48+
cookie_path=DEFAULT_COOKIE_PATH,
49+
cookie_domain=DEFAULT_COOKIE_DOMAIN,
50+
cookie_secure=DEFAULT_COOKIE_SECURE,
51+
cookie_samesite=DEFAULT_COOKIE_SAMESITE,
4452
send_csrf_failed=None,
4553
):
4654
send_csrf_failed = send_csrf_failed or default_send_csrf_failed
@@ -106,12 +114,22 @@ async def wrapped_send(event):
106114
else:
107115
new_headers = original_headers
108116
if should_set_cookie:
117+
cookie_attrs = [
118+
"{}={}".format(cookie_name, csrftoken),
119+
"Path={}".format(cookie_path),
120+
"SameSite={}".format(cookie_samesite),
121+
]
122+
123+
if cookie_domain is not None:
124+
cookie_attrs.append("Domain={}".format(cookie_domain))
125+
126+
if cookie_secure:
127+
cookie_attrs.append("Secure")
128+
109129
new_headers.append(
110130
(
111131
b"set-cookie",
112-
"{}={}; Path=/".format(cookie_name, csrftoken).encode(
113-
"utf-8"
114-
),
132+
"; ".join(cookie_attrs).encode("utf-8"),
115133
)
116134
)
117135
event = {
@@ -309,6 +327,10 @@ def asgi_csrf(
309327
always_protect=None,
310328
always_set_cookie=False,
311329
skip_if_scope=None,
330+
cookie_path=DEFAULT_COOKIE_PATH,
331+
cookie_domain=DEFAULT_COOKIE_DOMAIN,
332+
cookie_secure=DEFAULT_COOKIE_SECURE,
333+
cookie_samesite=DEFAULT_COOKIE_SAMESITE,
312334
send_csrf_failed=None,
313335
):
314336
return asgi_csrf_decorator(
@@ -319,6 +341,10 @@ def asgi_csrf(
319341
always_protect=always_protect,
320342
always_set_cookie=always_set_cookie,
321343
skip_if_scope=skip_if_scope,
344+
cookie_path=cookie_path,
345+
cookie_domain=cookie_domain,
346+
cookie_secure=cookie_secure,
347+
cookie_samesite=cookie_samesite,
322348
send_csrf_failed=send_csrf_failed,
323349
)(app)
324350

test_asgi_csrf.py

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,22 @@ async def test_hello_world_app():
9292

9393
def test_signing_secret_if_none_provided(monkeypatch):
9494
app = asgi_csrf(hello_world_app)
95+
9596
# Should be randomly generated
96-
assert isinstance(app.__closure__[7].cell_contents.secret_key, bytes)
97+
def _get_secret_key(app):
98+
found = [
99+
cell.cell_contents
100+
for cell in app.__closure__
101+
if "URLSafeSerializer" in repr(cell)
102+
]
103+
assert found
104+
return found[0].secret_key
105+
106+
assert isinstance(_get_secret_key(app), bytes)
97107
# Should pick up `ASGI_CSRF_SECRET` if available
98108
monkeypatch.setenv("ASGI_CSRF_SECRET", "secret-from-environment")
99109
app2 = asgi_csrf(hello_world_app)
100-
assert app2.__closure__[7].cell_contents.secret_key == b"secret-from-environment"
110+
assert _get_secret_key(app2) == b"secret-from-environment"
101111

102112

103113
@pytest.mark.asyncio
@@ -106,7 +116,7 @@ async def test_asgi_csrf_sets_cookie(app_csrf):
106116
response = await client.get("http://localhost/")
107117
assert b'{"hello":"world"}' == response.content
108118
assert "csrftoken" in response.cookies
109-
assert response.headers["set-cookie"].endswith("; Path=/")
119+
assert response.headers["set-cookie"].endswith("; Path=/; SameSite=Lax")
110120
assert "Cookie" == response.headers["vary"]
111121

112122

@@ -116,7 +126,7 @@ async def test_asgi_csrf_modifies_existing_vary_header(app_csrf):
116126
response = await client.get("http://localhost/?_vary=User-Agent")
117127
assert b'{"hello":"world"}' == response.content
118128
assert "csrftoken" in response.cookies
119-
assert response.headers["set-cookie"].endswith("; Path=/")
129+
assert response.headers["set-cookie"].endswith("; Path=/; SameSite=Lax")
120130
assert "User-Agent, Cookie" == response.headers["vary"]
121131

122132

@@ -430,3 +440,80 @@ async def test_asgi_lifespan():
430440
cookies={"foo": "bar"},
431441
)
432442
assert 200 == response.status_code
443+
444+
445+
# Tests for different cookie options
446+
447+
448+
@pytest.mark.asyncio
449+
@pytest.mark.parametrize("cookie_name", ["csrftoken", "custom_csrf"])
450+
async def test_cookie_name(cookie_name):
451+
wrapped_app = asgi_csrf(
452+
hello_world_app, signing_secret="secret", cookie_name=cookie_name
453+
)
454+
async with httpx.AsyncClient(app=wrapped_app) as client:
455+
response = await client.get("http://testserver/")
456+
assert cookie_name in response.cookies
457+
458+
459+
@pytest.mark.asyncio
460+
@pytest.mark.parametrize("cookie_path", ["/", "/custom"])
461+
async def test_cookie_path(cookie_path):
462+
wrapped_app = asgi_csrf(
463+
hello_world_app, signing_secret="secret", cookie_path=cookie_path
464+
)
465+
async with httpx.AsyncClient(app=wrapped_app) as client:
466+
response = await client.get("http://testserver/")
467+
assert f"Path={cookie_path}" in response.headers["set-cookie"]
468+
469+
470+
@pytest.mark.asyncio
471+
@pytest.mark.parametrize("cookie_domain", [None, "example.com"])
472+
async def test_cookie_domain(cookie_domain):
473+
wrapped_app = asgi_csrf(
474+
hello_world_app, signing_secret="secret", cookie_domain=cookie_domain
475+
)
476+
async with httpx.AsyncClient(app=wrapped_app) as client:
477+
response = await client.get("http://testserver/")
478+
if cookie_domain:
479+
assert f"Domain={cookie_domain}" in response.headers["set-cookie"]
480+
else:
481+
assert "Domain" not in response.headers["set-cookie"]
482+
483+
484+
@pytest.mark.asyncio
485+
@pytest.mark.parametrize("cookie_secure", [True, False])
486+
async def test_cookie_secure(cookie_secure):
487+
wrapped_app = asgi_csrf(
488+
hello_world_app, signing_secret="secret", cookie_secure=cookie_secure
489+
)
490+
async with httpx.AsyncClient(app=wrapped_app) as client:
491+
response = await client.get("http://testserver/")
492+
if cookie_secure:
493+
assert "Secure" in response.headers["set-cookie"]
494+
else:
495+
assert "Secure" not in response.headers["set-cookie"]
496+
497+
498+
@pytest.mark.asyncio
499+
@pytest.mark.parametrize("cookie_samesite", ["Strict", "Lax", "None"])
500+
async def test_cookie_samesite(cookie_samesite):
501+
wrapped_app = asgi_csrf(
502+
hello_world_app, signing_secret="secret", cookie_samesite=cookie_samesite
503+
)
504+
async with httpx.AsyncClient(app=wrapped_app) as client:
505+
response = await client.get("http://testserver/")
506+
assert f"SameSite={cookie_samesite}" in response.headers["set-cookie"]
507+
508+
509+
@pytest.mark.asyncio
510+
async def test_default_cookie_options():
511+
wrapped_app = asgi_csrf(hello_world_app, signing_secret="secret")
512+
async with httpx.AsyncClient(app=wrapped_app) as client:
513+
response = await client.get("http://testserver/")
514+
set_cookie = response.headers["set-cookie"]
515+
assert "csrftoken" in set_cookie
516+
assert "Path=/" in set_cookie
517+
assert "Domain" not in set_cookie
518+
assert "Secure" not in set_cookie
519+
assert "SameSite=Lax" in set_cookie

0 commit comments

Comments
 (0)