Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
release type: minor
social_messages:
x: >-
{project_name} {version} is out! This release bounds the ParserCache and
ValidationCache extensions by default, protecting servers from unbounded
memory growth. 🍓 https://strawberry.rocks/release/{version}
linkedin: >-
{project_name} {version} is out. This release changes the ParserCache and
ValidationCache extensions to use a bounded LRU cache by default (128
entries), so enabling them on a public endpoint no longer allows clients to
grow server memory without limit by sending unique query texts. An unbounded
cache is still available by explicitly passing maxsize=None.
---

This release fixes a potential unbounded memory growth in the `ParserCache` and
`ValidationCache` extensions.

Both extensions previously defaulted to `maxsize=None`, which creates an
unbounded `functools.lru_cache`. On a network-exposed endpoint with one of these
extensions enabled, a client sending many distinct query texts could grow the
server's memory without limit.

The default is now a bounded LRU cache of 128 entries, matching the
`functools.lru_cache` default. Existing behavior can be restored by explicitly
opting in to an unbounded cache:

```python
import strawberry
from strawberry.extensions import ParserCache, ValidationCache

schema = strawberry.Schema(
Query,
extensions=[
ParserCache(maxsize=None), # explicitly unbounded
ValidationCache(maxsize=100),
],
)
```

Only use `maxsize=None` when the set of distinct query texts reaching the server
is trusted and bounded.
12 changes: 8 additions & 4 deletions docs/extensions/parser-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,17 @@ schema = strawberry.Schema(
## API reference:

```python
class ParserCache(maxsize=None): ...
class ParserCache(maxsize=128): ...
```

#### `maxsize: Optional[int] = None`
#### `maxsize: Optional[int] = 128`

Set the maxsize of the cache. If `maxsize` is set to `None` then the cache will
grow without bound.
Set the maxsize of the cache. By default the cache is bounded to 128 entries,
with the least recently used entries evicted first. Pass an explicit
`maxsize=None` to let the cache grow without bound; only do this when the set of
distinct query texts reaching the server is trusted and bounded, as an unbounded
cache lets clients grow the server's memory indefinitely by sending unique query
texts.

More info: https://docs.python.org/3/library/functools.html#functools.lru_cache

Expand Down
12 changes: 8 additions & 4 deletions docs/extensions/validation-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,17 @@ schema = strawberry.Schema(
## API reference:

```python
class ValidationCache(maxsize=None): ...
class ValidationCache(maxsize=128): ...
```

#### `maxsize: Optional[int] = None`
#### `maxsize: Optional[int] = 128`

Set the maxsize of the cache. If `maxsize` is set to `None` then the cache will
grow without bound.
Set the maxsize of the cache. By default the cache is bounded to 128 entries,
with the least recently used entries evicted first. Pass an explicit
`maxsize=None` to let the cache grow without bound; only do this when the set of
distinct query texts reaching the server is trusted and bounded, as an unbounded
cache lets clients grow the server's memory indefinitely by sending unique query
texts.

More info: https://docs.python.org/3/library/functools.html#functools.lru_cache

Expand Down
13 changes: 10 additions & 3 deletions strawberry/extensions/parser_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

from strawberry.extensions.base_extension import SchemaExtension

# Bounded by default so caching enabled on a public endpoint cannot be used
# to grow memory without limit via unique query texts
DEFAULT_MAXSIZE = 128


@cache
def _get_parse_cache(maxsize: int | None) -> Callable[..., Any]:
Expand Down Expand Up @@ -34,12 +38,15 @@ class ParserCache(SchemaExtension):
```
"""

def __init__(self, maxsize: int | None = None) -> None:
def __init__(self, maxsize: int | None = DEFAULT_MAXSIZE) -> None:
"""Initialize the ParserCache.

Args:
maxsize: Set the maxsize of the cache. If `maxsize` is set to `None` then the
cache will grow without bound.
maxsize: Set the maxsize of the cache. Defaults to a bounded cache of
`DEFAULT_MAXSIZE` (128) entries. Pass an explicit `maxsize=None` to
let the cache grow without bound; only do this when the set of
distinct query texts reaching the server is trusted and bounded,
as an unbounded cache can otherwise grow memory indefinitely.
More info: https://docs.python.org/3/library/functools.html#functools.lru_cache
"""
super().__init__()
Expand Down
13 changes: 10 additions & 3 deletions strawberry/extensions/validation_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

from strawberry.extensions.base_extension import SchemaExtension

# Bounded by default so caching enabled on a public endpoint cannot be used
# to grow memory without limit via unique query texts
DEFAULT_MAXSIZE = 128


@cache
def _get_validate_cache(maxsize: int | None) -> Callable[..., Any]:
Expand Down Expand Up @@ -36,12 +40,15 @@ class ValidationCache(SchemaExtension):
```
"""

def __init__(self, maxsize: int | None = None) -> None:
def __init__(self, maxsize: int | None = DEFAULT_MAXSIZE) -> None:
"""Initialize the ValidationCache.

Args:
maxsize: Set the maxsize of the cache. If `maxsize` is set to `None` then the
cache will grow without bound.
maxsize: Set the maxsize of the cache. Defaults to a bounded cache of
`DEFAULT_MAXSIZE` (128) entries. Pass an explicit `maxsize=None` to
let the cache grow without bound; only do this when the set of
distinct query texts reaching the server is trusted and bounded,
as an unbounded cache can otherwise grow memory indefinitely.

More info: https://docs.python.org/3/library/functools.html#functools.lru_cache
"""
Expand Down
11 changes: 11 additions & 0 deletions tests/schema/extensions/test_parser_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,14 @@ def ping(self) -> str:
assert result.data == {"ping": "pong"}

assert mock_parse.call_count == 2


def test_parser_cache_extension_default_is_bounded():
cache = ParserCache()

assert cache.cached_parse_document.cache_info().maxsize == 128

for i in range(200):
cache.cached_parse_document(f"query {{ field_{i} }}")

assert cache.cached_parse_document.cache_info().currsize == 128
19 changes: 19 additions & 0 deletions tests/schema/extensions/test_validation_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,22 @@ def ping(self) -> str:
assert result.data == {"ping": "pong"}

assert mock_validate.call_count == 2


def test_validation_cache_extension_default_is_bounded():
@strawberry.type
class Query:
@strawberry.field
def hello(self) -> str:
return "world"

schema = strawberry.Schema(query=Query, extensions=[ValidationCache])

cache = ValidationCache()
assert cache.cached_validate_document.cache_info().maxsize == 128

for i in range(200):
result = schema.execute_sync(f"query {{ alias_{i}: hello }}")
assert not result.errors

assert cache.cached_validate_document.cache_info().currsize == 128
Loading