Safeguard
Application Security•

Strawberry GraphQL CVE-2026-107728: When an Unawaited Permission Check Means Yes

Strawberry GraphQL's CVE-2026-107728: a plain def has_permission returning an awaitable was treated as truthy on the synchronous path, granting access. The fix in 0.326.1, a lab in Python and JavaScript, why the 'never awaited' warning doesn't fail anything, and a test guard and scanner for the bug class.

Safeguard Security Team
Security Research
10 min read

Strawberry GraphQL's CVE-2026-107728 is a one-line authorization bypass with a cause every Python and JavaScript developer can hit: an unawaited coroutine or Promise is always truthy. When a permission check returned an awaitable on Strawberry's synchronous path, the framework tested the awaitable itself, found it truthy, and ran the protected resolver. The awaitable would have said no.

This post is for teams running Strawberry, and for anyone who writes authorization checks in async Python or JavaScript. It covers the bug and the fix, a lab that reproduces the shape in a few lines, why Python's "never awaited" warning doesn't stop it, and two ways to catch the pattern: a test guard and a static scanner.

The short answer

CVE-2026-107728 (GHSA-pfvf-fwfp-25mp, high) affects strawberry-graphql 0.217.0 up to 0.326.0. If a permission class's has_permission is a plain def that returns an awaitable, the synchronous resolve path treated the awaitable as truthy and granted access. Permissions written as async def, or as a plain def returning a boolean, aren't affected. Upgrade to 0.326.1 or later, which raises an error instead. Then look for the same bug class in your own code: any condition that tests a coroutine or Promise without awaiting it always passes, and Python only prints a warning that most test suites ignore.

The bug

Strawberry runs permission classes through PermissionExtension. Which path it takes depends on the field's resolver: a synchronous resolver goes through resolve(), under both execute_sync() and execute(). Strawberry decided whether a permission was async with inspect.iscoroutinefunction, which is true only for functions declared with async def.

So a permission like this was classified as synchronous:

class IsEditor(BasePermission):
    message = "editors only"

    def has_permission(self, source, info, **kwargs):
        # a plain def that returns a coroutine
        return auth.user_has_role(info.context["user_id"], "editor")

and the synchronous path did this with the result:

if not permission.has_permission(source, info, **kwargs):
    ...  # deny

A coroutine object is truthy, so not coroutine is False, nothing is denied, and the resolver runs. The coroutine is never awaited, so the identity service is never even asked. The advisory's proof of concept uses a permission whose awaitable resolves to False and gets the protected field's value back.

The shape is easy to arrive at. A team moves its role lookup to an async client, and a wrapper like the one above still looks right in review.

The fix

The fix landed in pull request #4605 and shipped in 0.326.1 on 31 August 2026. The CVE was published on 8 October. On the synchronous path, Strawberry now checks the result with inspect.isawaitable. If it's awaitable, Strawberry closes the coroutine (to avoid a second warning) and raises a new PermissionReturnedAwaitableInSyncContextError instead of granting access. The asynchronous path was never affected, because it already used await_maybe().

That is the right shape for this class of bug: fail closed when the type is wrong, rather than guessing what the caller meant.

Lab: the bug in a few lines

This model reproduces the vulnerable check and the fixed check side by side, using only the standard library:

import inspect


class DenyViaAwaitable:
    """A permission whose has_permission is a plain def that returns an awaitable resolving to False."""

    def has_permission(self, source, info):
        async def result():
            return False
        return result()


def resolve_before(permission, resolver):
    # shape of the synchronous check before 0.326.1
    if not permission.has_permission(None, None):
        raise PermissionError("denied")
    return resolver()


def resolve_after(permission, resolver):
    # shape of the 0.326.1 fix: fail closed if the result is awaitable
    result = permission.has_permission(None, None)
    if inspect.isawaitable(result):
        if inspect.iscoroutine(result):
            result.close()
        raise TypeError("has_permission returned an awaitable on the synchronous path")
    if not result:
        raise PermissionError("denied")
    return resolver()


if __name__ == "__main__":
    secret = lambda: "secret"
    for name, fn in (("before", resolve_before), ("after", resolve_after)):
        try:
            print(f"{name}: returned {fn(DenyViaAwaitable(), secret)!r}")
        except Exception as e:
            print(f"{name}: {type(e).__name__}: {e}")
$ python3 model.py
before: returned 'secret'
after: TypeError: has_permission returned an awaitable on the synchronous path

The same thing happens in JavaScript, where a Promise is truthy:

async function isAdmin(user) { return user.role === "admin"; }
const user = { role: "viewer" };
if (isAdmin(user)) console.log("missing await: viewer treated as admin");
if (await isAdmin(user)) console.log("never printed"); else console.log("with await: viewer denied");
$ node truthy.mjs
missing await: viewer treated as admin
with await: viewer denied

Why the warning doesn't save you

Python does notice. When an unawaited coroutine is garbage collected, it emits RuntimeWarning: coroutine '...' was never awaited. But a warning is not a failure. In our lab, the vulnerable check returned the secret and the process exited with status 0. Even with python3 -W error::RuntimeWarning, the warning is raised during finalisation, where Python can only report it as "Exception ignored". The secret is still returned and the exit code is still 0.

A test that catches the permission error but doesn't fail when none is raised passes too, which is easier to write by accident than it sounds. What does work is routing those ignored exceptions somewhere a test can see them. sys.unraisablehook receives exceptions Python can't raise, so a base test class can collect them and fail:

import gc
import sys
import unittest
import warnings

from model import DenyViaAwaitable, resolve_before


class UnawaitedCoroutineGuard(unittest.TestCase):
    """Fail any test that leaves a coroutine unawaited, which is what a truthy-awaitable bug does."""

    def setUp(self):
        self.leaks = []
        self._hook = sys.unraisablehook
        sys.unraisablehook = lambda u: self.leaks.append(str(u.exc_value))
        self._filters = warnings.catch_warnings()
        self._filters.__enter__()
        warnings.simplefilter("error", RuntimeWarning)  # routes the warning to unraisablehook

    def tearDown(self):
        gc.collect()
        self._filters.__exit__(None, None, None)
        sys.unraisablehook = self._hook
        if self.leaks:
            self.fail("unawaited coroutine: " + "; ".join(self.leaks))


class PermissionTests(UnawaitedCoroutineGuard):
    def test_secret_is_protected(self):
        # the assertion a typical test makes; the guard catches what the assertion misses
        try:
            resolve_before(DenyViaAwaitable(), lambda: "secret")
        except PermissionError:
            pass


if __name__ == "__main__":
    unittest.main()
$ python3 -m unittest test_unawaited
F
======================================================================
FAIL: test_secret_is_protected (test_unawaited.PermissionTests.test_secret_is_protected)
----------------------------------------------------------------------
AssertionError: unawaited coroutine: coroutine 'DenyViaAwaitable.has_permission.<locals>.result' was never awaited

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

If you use pytest, version 6.2 and later already detects unraisable exceptions and reports them as warnings. Running it with -W error::pytest.PytestUnraisableExceptionWarning turns them into failures. Either way, the point is to make "never awaited" fail the build.

Find the pattern in your code

Tests only catch the paths they exercise. This scanner looks for the two shapes statically: an async function's result used as a condition without await, and a plain def that returns an async call, which a caller may test for truthiness. It matches functions by name across the source tree, so treat each hit as something to read, not proof:

#!/usr/bin/env python3
"""Find Python code that may use an awaitable as a boolean: the CVE-2026-107728 bug class.

Usage: python3 truthy_await.py SRC_DIR
Flags (1) calls to async functions used in a condition without await, and (2) plain `def`
functions that return an awaitable, which callers may test for truthiness. Matching is by
function name across the tree, so review each hit. Standard library only.
"""
import ast
import os
import sys

AUTHZ_WORDS = ("perm", "auth", "allow", "access", "admin", "can_", "is_", "has_", "check")


def py_files(root):
    for d, dirs, files in os.walk(root):
        dirs[:] = [x for x in dirs if x not in (".venv", "venv", "node_modules", ".git", "site-packages")]
        for f in files:
            if f.endswith(".py"):
                yield os.path.join(d, f)


def call_name(node):
    if isinstance(node, ast.Call):
        f = node.func
        return f.id if isinstance(f, ast.Name) else f.attr if isinstance(f, ast.Attribute) else None
    return None


def conditions(tree):
    """Yield expressions evaluated for truthiness."""
    for node in ast.walk(tree):
        if isinstance(node, (ast.If, ast.While, ast.IfExp, ast.Assert)):
            yield node.test
        elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
            yield node.operand
        elif isinstance(node, ast.BoolOp):
            yield from node.values
        elif isinstance(node, ast.comprehension):
            yield from node.ifs


def main(root):
    trees = {}
    async_names = set()
    for path in py_files(root):
        try:
            trees[path] = ast.parse(open(path, encoding="utf-8").read(), path)
        except (SyntaxError, UnicodeDecodeError):
            continue
        async_names |= {n.name for n in ast.walk(trees[path]) if isinstance(n, ast.AsyncFunctionDef)}
    hits = 0
    for path, tree in sorted(trees.items()):
        rel = os.path.relpath(path, root)
        for expr in conditions(tree):
            name = call_name(expr)
            if name in async_names:
                hits += 1
                print(f"{rel}:{expr.lineno}: {name}() is async but used as a condition without await")
        for fn in ast.walk(tree):
            if not isinstance(fn, ast.FunctionDef):
                continue
            local_async = {n.name for n in ast.walk(fn) if isinstance(n, ast.AsyncFunctionDef)}
            for ret in ast.walk(fn):
                if isinstance(ret, ast.Return) and call_name(ret.value) in (async_names | local_async):
                    # skip returns that belong to a nested async def
                    owner = next((n for n in ast.walk(fn) if isinstance(n, ast.AsyncFunctionDef) and ret in ast.walk(n)), None)
                    if owner:
                        continue
                    hits += 1
                    risk = " (authorization-style name)" if any(w in fn.name.lower() for w in AUTHZ_WORDS) else ""
                    print(f"{rel}:{ret.lineno}: def {fn.name}() returns {call_name(ret.value)}(), an awaitable{risk}")
    print(f"\n{len(trees)} files scanned, {hits} findings")


if __name__ == "__main__":
    if not sys.argv[1:]:
        sys.exit(__doc__)
    main(sys.argv[1:][0])

On a small fixture app with one Strawberry permission and two views:

$ python3 truthy_await.py fx
app/permissions.py:13: def has_permission() returns user_has_role(), an awaitable (authorization-style name)
app/views.py:11: is_admin() is async but used as a condition without await

3 files scanned, 2 findings

Both findings are real bugs in the fixture. The correct async def permission and the view that awaits is_admin aren't flagged. For TypeScript, the typescript-eslint rule no-misused-promises flags Promises in conditionals through its checksConditionals option. It's part of the recommended-type-checked config and needs type information to run.

Static analysis that knows your codebase finds this class of bug where it's introduced. Here's Safeguard's interactive walkthrough of a SAST scan tracing a finding to its file and line:

What to do

  1. Upgrade Strawberry to 0.326.1 or later. If you can't yet, make every has_permission either async def or a plain def that returns a real boolean.
  2. Search your permission classes for plain def has_permission methods that call async code. The scanner above finds them.
  3. Make "never awaited" fail your tests, with an unraisable-exception guard or pytest's warning filters.
  4. Turn on no-misused-promises in TypeScript projects, with checksConditionals enabled.
  5. Review authorization the same way you review it in REST code. GraphQL puts a check on every resolver, which multiplies the places this can hide. Our post on authorization in every GraphQL resolver covers the wider design.

The fair view

This was a narrow bug. It needed an unusual permission shape, the advisory says so plainly, and the default ways of writing permissions were never affected. Strawberry fixed it with a fail-closed check and an error message that tells you exactly what to change.

What makes it worth a teardown is how general the cause is. Python and JavaScript both let an unawaited result stand in for a boolean, and both only warn about it, if they say anything at all. Any authorization check that crosses from sync to async code can fail open the same way. The defence is cheap: make the warning fatal in tests, and lint for it.

Check yourself

A Strawberry permission's has_permission is a plain def that returns an awaitable resolving to False. On a version before 0.326.1, with a synchronous resolver, what happens?

Access is granted. The synchronous path tests the awaitable for truthiness, and a coroutine object is always truthy, so the protected resolver runs. On 0.326.1 and later, Strawberry raises PermissionReturnedAwaitableInSyncContextError instead.

You run your tests with -W error::RuntimeWarning. Does that catch a truthy-coroutine bug?

Not by itself. The warning fires when the coroutine is finalised, where Python can only print "Exception ignored". You need a sys.unraisablehook guard, or pytest's handling of unraisable exceptions, to turn it into a failure.

What's the JavaScript version of this bug, and what catches it?

Testing a Promise in a condition without await, as in if (isAdmin(user)). A Promise is always truthy. The typescript-eslint rule no-misused-promises with checksConditionals flags it.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.