Skip to content

Commit 2ebb797

Browse files
authored
Prevent permission bypass when sync has_permission returns an awaitable (#4605)
1 parent 4e975a2 commit 2ebb797

4 files changed

Lines changed: 142 additions & 2 deletions

File tree

RELEASE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
release type: patch
3+
social_messages:
4+
x: >-
5+
Strawberry {version} is out! This release fixes a permission bypass where a
6+
sync `has_permission` returning an awaitable could grant access to protected
7+
fields. 🍓 https://strawberry.rocks/release/{version}
8+
linkedin: >-
9+
Strawberry {version} is out. This release fixes a permission bypass
10+
(GHSA-pfvf-fwfp-25mp) where a synchronous `has_permission` that returned an
11+
awaitable could unintentionally grant access to protected fields. The
12+
synchronous permission path now fails closed with a clear error.
13+
---
14+
15+
This release fixes a permission bypass (GHSA-pfvf-fwfp-25mp) where a custom
16+
permission could unintentionally authorize access to a protected field.
17+
18+
When a permission's `has_permission` was a normal `def` that returned an
19+
awaitable (for example a wrapper returning a coroutine), Strawberry classified
20+
the permission as synchronous because only `async def` methods are detected as
21+
async. On the synchronous resolve path the returned awaitable was evaluated for
22+
truthiness directly, and an awaitable is always truthy — so the check passed and
23+
the protected resolver ran even when the awaitable resolved to `False`. This
24+
affected any field with a synchronous resolver, under both `execute_sync` and
25+
`execute`.
26+
27+
Strawberry now detects this case and fails closed: the synchronous permission
28+
path raises a clear error instead of trusting the awaitable, so access is never
29+
granted by accident. Permissions written as `async def has_permission` continue
30+
to work as before. If you intend a permission to be asynchronous, declare it
31+
with `async def` (or return a plain boolean from a synchronous one).

strawberry/exceptions/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,29 @@ def __init__(self, payload: dict[str, object] | None = None) -> None:
169169
self.payload = payload
170170

171171

172+
class PermissionReturnedAwaitableInSyncContextError(Exception):
173+
"""A permission returned an awaitable while being resolved synchronously.
174+
175+
Raised when a permission's ``has_permission`` returns an awaitable (for
176+
example a plain ``def`` that returns a coroutine) but the field is being
177+
resolved on the synchronous path, where the awaitable cannot be awaited.
178+
Returning it as-is would be truthy and silently grant access, so we fail
179+
closed instead.
180+
"""
181+
182+
def __init__(self, permission: object) -> None:
183+
self.permission = permission
184+
permission_name = type(permission).__name__
185+
message = (
186+
f"Permission {permission_name!r} returned an awaitable from "
187+
"`has_permission` but is being resolved synchronously, so the "
188+
"result cannot be awaited. Declare `has_permission` as "
189+
"`async def` so Strawberry runs it on the asynchronous path, or "
190+
"return a plain boolean."
191+
)
192+
super().__init__(message)
193+
194+
172195
__all__ = [
173196
"ConflictingArgumentsError",
174197
"DuplicatedTypeName",
@@ -191,6 +214,7 @@ def __init__(self, payload: dict[str, object] | None = None) -> None:
191214
"MultipleStrawberryFieldsError",
192215
"ObjectIsNotAnEnumError",
193216
"ObjectIsNotClassError",
217+
"PermissionReturnedAwaitableInSyncContextError",
194218
"PrivateStrawberryFieldError",
195219
"ScalarAlreadyRegisteredError",
196220
"StrawberryException",

strawberry/permission.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
ClassVar,
1111
)
1212

13-
from strawberry.exceptions import StrawberryGraphQLError
13+
from strawberry.exceptions import (
14+
PermissionReturnedAwaitableInSyncContextError,
15+
StrawberryGraphQLError,
16+
)
1417
from strawberry.exceptions.permission_fail_silently_requires_optional import (
1518
PermissionFailSilentlyRequiresOptionalError,
1619
)
@@ -205,8 +208,25 @@ def resolve(
205208
) -> Any:
206209
"""Checks if the permission should be accepted and raises an exception if not."""
207210
for permission in self.permissions:
208-
if not permission.has_permission(source, info, **kwargs):
211+
has_permission = permission.has_permission(source, info, **kwargs)
212+
213+
# A permission whose `has_permission` returns an awaitable (e.g. a
214+
# plain `def` returning a coroutine) cannot be resolved here: the
215+
# awaitable is truthy regardless of what it resolves to, so trusting
216+
# it would silently grant access. `supports_sync` only detects
217+
# `async def` via `iscoroutinefunction`, so this case reaches the
218+
# sync path. Fail closed instead of leaking the field.
219+
if inspect.isawaitable(has_permission):
220+
# Close the coroutine so we don't leak a "coroutine was never
221+
# awaited" warning on top of the error we are about to raise.
222+
if inspect.iscoroutine(has_permission):
223+
has_permission.close()
224+
225+
raise PermissionReturnedAwaitableInSyncContextError(permission)
226+
227+
if not has_permission:
209228
return self._on_unauthorized(permission)
229+
210230
return next_(source, info, **kwargs)
211231

212232
async def resolve_async(

tests/schema/test_permission.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,3 +705,68 @@ def name(self, a_key: str) -> str: # pragma: no cover
705705
result = schema.execute_sync(query)
706706

707707
assert result.data["name"] == "Erik"
708+
709+
710+
def test_sync_permission_returning_awaitable_denies_access():
711+
calls = 0
712+
713+
class DenyViaAwaitable(BasePermission):
714+
message = "denied"
715+
716+
def has_permission(
717+
self, source: typing.Any, info: strawberry.Info, **kwargs: typing.Any
718+
) -> typing.Any:
719+
async def result() -> bool:
720+
return False
721+
722+
return result()
723+
724+
@strawberry.type
725+
class Query:
726+
@strawberry.field(permission_classes=[DenyViaAwaitable])
727+
def secret(self) -> str:
728+
nonlocal calls
729+
calls += 1
730+
return "secret"
731+
732+
schema = strawberry.Schema(query=Query)
733+
734+
result = schema.execute_sync("{ secret }")
735+
736+
assert result.data is None
737+
assert result.errors is not None
738+
assert "returned an awaitable" in result.errors[0].message
739+
assert calls == 0
740+
741+
742+
@pytest.mark.asyncio
743+
async def test_sync_permission_returning_awaitable_denies_access_on_async_execution():
744+
calls = 0
745+
746+
class DenyViaAwaitable(BasePermission):
747+
message = "denied"
748+
749+
def has_permission(
750+
self, source: typing.Any, info: strawberry.Info, **kwargs: typing.Any
751+
) -> typing.Any:
752+
async def result() -> bool:
753+
return False
754+
755+
return result()
756+
757+
@strawberry.type
758+
class Query:
759+
@strawberry.field(permission_classes=[DenyViaAwaitable])
760+
def secret(self) -> str:
761+
nonlocal calls
762+
calls += 1
763+
return "secret"
764+
765+
schema = strawberry.Schema(query=Query)
766+
767+
result = await schema.execute("{ secret }")
768+
769+
assert result.data is None
770+
assert result.errors is not None
771+
assert "returned an awaitable" in result.errors[0].message
772+
assert calls == 0

0 commit comments

Comments
 (0)