Fixed on 31 August, Disclosed on 9 October: Strawberry GraphQL’s Permission Bypass Spent Six Weeks in a Patch Note

A Python authorization check that returns an object instead of a boolean does not fail loudly. It passes. CVE-2026-107728, published as GHSA-pfvf-fwfp-25mp on 9 October 2026 at 14:07:16Z, describes a permission bypass in Strawberry GraphQL in which a synchronous permission whose has_permission() returns an awaitable grants access to the field it was written to protect. GitHub rates it 7.5 high (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) under CWE-863, Incorrect Authorization. Affected: ≥ 0.217.0, ≤ 0.326.0. Fixed: 0.326.1.

The mechanism is a one-line type confusion. The disclosure timeline is the part worth arguing about: we verified first-hand that the fix shipped to PyPI on 31 August 2026 — 39 days before the advisory that told anyone why they needed it.

An awaitable is always truthy

Strawberry attaches authorization to a field through permission_classes, and PermissionExtension decides at resolve time whether the field runs. There are two paths, and the extension picks one by inspecting how the permission is declared. From the advisory:

“supports_sync only classifies a permission as asynchronous when has_permission is declared with async def (via inspect.iscoroutinefunction), so a plain def that returns an awaitable is treated as synchronous. An awaitable is always truthy, so the check passes even when it resolves to False and the protected resolver runs.”

We read strawberry/permission.py at tag 0.326.0 directly. The synchronous path was four lines, and it tested the return value for truthiness with no type check at all:

for permission in self.permissions:
    if not permission.has_permission(source, info, **kwargs):
        return self._on_unauthorized(permission)
return next_(source, info, **kwargs)

A coroutine object is truthy. not coroutine_object is False. The branch that denies access is never taken, the resolver runs, and the field returns its data. The asynchronous path was never affected, because resolve_async() passes the result through await_maybe() before testing it — the bug is specifically the absence of that unwrapping on the sibling path.

The advisory's proof of concept is eight lines and self-evident: a permission whose has_permission is a plain def returning the result of calling an inner async def that returns False. schema.execute_sync("{ secret }") returns {'secret': 'secret'} rather than a permission error.

The detail that widens the blast radius

One sentence in the advisory does more work than the rest, and it is easy to skim past:

“The resolve path is chosen by the field resolver, not by the execution method, so any field with a synchronous resolver is affected under both execute_sync() and execute().”

The intuitive mitigation — “we run an async server, so the async path protects us” — is wrong. An async ASGI application calling execute() still dispatches a synchronous field resolver down the synchronous extension path. The deciding factor is how each individual resolver is written, not how the schema is executed. In a codebase that mixes def and async def resolvers, which is the normal case, the vulnerable surface is whichever fields happen to be synchronous.

Who is actually exposed

This is narrower than 7.5 suggests, and the advisory says so plainly: exploitability “depends on the application using this specific permission shape.” Three cases, only one of which is vulnerable:

  • async def has_permission — not affected. Correctly classified, correctly awaited.
  • def has_permission returning a boolean — not affected. The overwhelmingly common shape.
  • def has_permission returning an awaitable — vulnerable. A plain function that calls an async helper and returns the coroutine without awaiting it.

That third shape is not exotic, which is the uncomfortable part. It is what you get when someone wraps an async authorization backend — a token introspection call, an async policy lookup, an await-based database check — inside a permission class and forgets the async keyword on the wrapper. Python does not complain at definition time. It complains at interpreter shutdown, with a coroutine was never awaited RuntimeWarning that lands in logs nobody reads, long after the request returned data it should not have.

The failure is therefore silent in exactly the way authorization failures must never be: there is no error, no denied request, no 403 in the access log. A field that should have returned a permission error returns its contents, and the only trace is a warning about coroutine hygiene.

The fix fails closed — and we checked it

We read permission.py at 0.326.1 as well. The synchronous path now detects the awaitable, closes the coroutine so the misleading warning is not emitted on top of the error, and raises PermissionReturnedAwaitableInSyncContextError rather than making an access decision it cannot make:

has_permission = permission.has_permission(source, info, **kwargs)

if inspect.isawaitable(has_permission):
    if inspect.iscoroutine(has_permission):
        has_permission.close()

    raise PermissionReturnedAwaitableInSyncContextError(permission)

if not has_permission:
    return self._on_unauthorized(permission)

This is the correct shape for an authorization fix. It does not try to rescue the situation by awaiting the value — there is no event loop to await it on — and it does not silently deny. It raises, which converts a silent authorization bypass into a loud application error that a developer must resolve by declaring the permission async def. A fix that fails closed and refuses to guess is the right outcome; compare it with the pattern we documented in DB-GPT’s silent sandbox fallback, where the degraded path quietly kept running.

The timeline is the story

Reconstructed entirely from GitHub and PyPI API records:

  • 31 August 2026, 21:53:04Z — PR #4605, “Prevent permission bypass when sync has_permission returns an awaitable,” opened. Four files, +142/−2.
  • 31 August 2026, 22:02:51Z — merged as commit 2ebb7979. Nine minutes from open to merge.
  • 31 August 2026, 22:03:21Z — 0.326.1 uploaded to PyPI. The fix is publicly installable.
  • 8 October 2026, 23:16:58Z — CVE-2026-107728 published in the CVE record.
  • 9 October 2026, 14:07:16Z — GHSA-pfvf-fwfp-25mp published, with the mechanism, the PoC and the affected range.

Thirty-nine days separate an installable fix from the advisory explaining it. The release note for 0.326.1 was not evasive — it named GHSA-pfvf-fwfp-25mp and described the bypass in full on 31 August. But a release note is a push to people already reading release notes. An advisory is what reaches everyone else: it is what Dependabot consumes, what pip-audit and safety match against, what an SCA scanner turns into a ticket. Until 9 October, none of that machinery knew 0.326.0 was vulnerable. A team on 0.326.0 with automated dependency scanning got no signal for six weeks, through a window in which the mechanism and a working PoC sat in a public release note and a public merged PR.

We make no claim about why the gap existed, and there is no evidence of exploitation. The pattern is what we keep recording: the Obot advisory that said “Patched versions: None” two months after the fix landed, and PraisonAI’s 24 advisories naming a fixed version that was never published to PyPI. Strawberry is the inverse and the better failure: the remediation was real, installable and correctly described from day one, and only the index entry was late. Of the three, this is the one you would choose. It is still six weeks of scanners reporting clean on a vulnerable version.

The second advisory, and the one before it

Strawberry published a second advisory in the same batch. CVE-2026-107727 (GHSA-m952-2w3f-6r8h, 3.7 low, CWE-400) covers the legacy graphql-ws handler retaining bookkeeping for subscriptions that completed on their own. With max_subscriptions_per_connection configured, a client using distinct operation IDs reaches Subscription limit reached while holding no active subscriptions. Affected ≥ 0.312.3, < 0.327.2; fixed in 0.327.2, uploaded 3 September 2026. The graphql-transport-ws handler is unaffected, and the advisory is careful to state this “is not an unconditional issue in a default Strawberry installation.”

Both advisories credit the same reporter, Hama1cco, with maintainer patrick91 as remediation developer on the permission fix. The advisory for CVE-2026-107727 also names the audited snapshot — Strawberry 0.324.4, commit c3caabd1 — which is better provenance than most advisories offer.

Note the lineage: this is the second security issue in the legacy graphql-ws handler this year. CVE-2026-35526 (unbounded WebSocket subscriptions, high) and CVE-2026-35523 (authentication bypass via the legacy graphql-ws subprotocol, high) were both published on 6 April 2026. The 9 October advisory explicitly distinguishes itself from “the previously fixed single-connection infinite-subscription issue.” A deprecated protocol handler has now produced three advisories in six months. The operational conclusion is not to patch it a third time — it is to stop negotiating the legacy subprotocol if nothing needs it.

What to do

  • Upgrade to 0.327.2 or later if you use the legacy graphql-ws handler with a subscription limit; 0.326.1 is the floor for the permission fix alone. Current release at the time of writing is 0.332.0.
  • Grep before you upgrade, not after. Search your permission classes for a def has_permission (not async def) whose body returns a call to an async function. That is the exact vulnerable shape, and finding one means the field it guards has been open since you deployed it — not since October. Upgrading converts the bypass into a raised exception, so an unfixed permission of this shape will now break the request rather than leak it. That is the intended behaviour; fix the permission by declaring it async def.
  • Check your logs for coroutine was never awaited warnings naming a permission module. On an affected version, that warning is the observable signature of a bypass that already happened.
  • Treat “no advisory” as unknown, not safe. Both fixes were in public release notes weeks before any scanner could see them. If you pin dependencies, read the release notes for the versions you skip — that is where the six-week gap was visible to anyone looking.
  • Disable the legacy subprotocol unless a client requires it. Three advisories in six months against one deprecated handler is a surface, not a sequence of accidents.

Verification note: we read both advisories through the GitHub Advisory API (GHSA-pfvf-fwfp-25mp and GHSA-m952-2w3f-6r8h), including severities, CVSS v3.1 vectors, CWE assignments, affected ranges, credits and full descriptions, and confirmed CVE-2026-107728 and CVE-2026-107727 against NVD, where both were “Awaiting Analysis” at the time of writing with the 7.5 and 3.7 scores supplied by GitHub as a secondary source — NVD has published no independent score. We read strawberry/permission.py at tags 0.326.0 and 0.326.1 directly from the repository and quote both the vulnerable and fixed code verbatim; the PoC, the execution-path sentence and the exploitability caveat are quoted from the advisory. Timeline entries come from the GitHub API records for PR #4605, PR #4610, commits 2ebb7979 and 24c5e46d, the release objects for 0.326.0, 0.326.1 and 0.327.2, and PyPI upload timestamps. The six-week gap is our measurement of published records, not a statement by the project. We found no evidence of exploitation and do not imply any, and we did not run the PoC against any system.

Sources: