Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make strawberry.Private compatible with PEP-563 #1684

Conversation

skilkis
Copy link
Contributor

@skilkis skilkis commented Feb 25, 2022

Description

This commit Closes #1586 by checking for private fields at schema
conversion time. As per PEP-563, in the future Python annotations will
no longer be evaluated at definition time. As reported by Issue #1586,
this means that strawberry.Private is incompatible with postponed
evaluation as the check for a private field requires an evaluated
type-hint annotation to work.

By checking for private fields at schema conversion time, it is
guaranteed that all fields that should be included in the schema are
resolvable using an eval. This ensures that the current approach for
defining private fields can be left intact.

The current filtering for fields annotated with strawberry.Private in
types.type_resolver.py are left intact to not needlessly instantiate
StrawberryField objects when strawberry.Private is resolvable.

Summary of Changes:

  • Added check for private fields at schema-conversion time
  • Added test to check that postponed evaluation with
    strawberry.Private functions correctly
  • Reduced code duplication in schema_converter.py

Types of Changes

  • Core
  • Bugfix

Issues Fixed or Closed by This PR

Issue #1586

Checklist

  • My code follows the code style of this project.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • I have tested the changes and verified that they work and don't break anything (as well as I can manage).

@botberry
Copy link
Member

botberry commented Feb 25, 2022

Thanks for adding the RELEASE.md file!

Here's a preview of the changelog:


Add support for postponed evaluation of annotations
(PEP-563) to strawberry.Private
annotated fields.

Example

This release fixes Issue #1586 using schema-conversion time filtering of
strawberry.Private fields for PEP-563. This means the following is now
supported:

@strawberry.type
class Query:
    foo: "strawberry.Private[int]"

Forward references are supported as well:

from __future__ import annotations

from dataclasses import dataclass

@strawberry.type
class Query:
    private_foo: strawberry.Private[SensitiveData]

    @strawberry.field
    def foo(self) -> int:
        return self.private_foo.visible

@dataclass
class SensitiveData:
    visible: int
    not_visible int

Here's the preview release card for twitter:

Here's the tweet text:

🆕 Release (next) is out! Thanks to San Kilkis for the PR 👏

Get it here 👉 https://github.com/strawberry-graphql/strawberry/releases/tag/(next)

@codecov
Copy link

codecov bot commented Feb 25, 2022

Codecov Report

Merging #1684 (a2905e2) into main (c6093b0) will increase coverage by 0.00%.
The diff coverage is 100.00%.

@@           Coverage Diff           @@
##             main    #1684   +/-   ##
=======================================
  Coverage   98.21%   98.21%           
=======================================
  Files         130      130           
  Lines        4592     4594    +2     
  Branches      790      794    +4     
=======================================
+ Hits         4510     4512    +2     
  Misses         43       43           
  Partials       39       39           

Copy link
Member

@BryceBeagle BryceBeagle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! I've added a few (minor) requests, and this should be good to merge 👍

strawberry/schema/schema_converter.py Show resolved Hide resolved
Comment on lines +183 to +185
name_converter: Callable[[StrawberryField], str],
field_converter: Callable[[StrawberryField], FieldType],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason these pull from args instead of using self. variables directly (which is where the values come from anywhere). Is this abstraction beneficial?

Copy link
Contributor Author

@skilkis skilkis Feb 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name_converter can be pulled from self directly since all type definitions seem to use the same converter, but from what I've seen field_converter changes based on if it is called from self.from_input_object or self.from_object. The reason I made an abstraction here is to separate the logic of the filtering and the creation of a GraphQL Core ThunkMapping from that of selecting which converter(s) to use for the field. This is to prevent having to change _get_thunk_mapping if new types are added. With this in mind I didn't pull the converter(s) from self directly so that other functions (or in the future a mapping of Strawberry types to GraphQL types ) can decide which one to use.

The amount of code here could be reduced further if there is a way to have a single get_graphql_fields method that is responsible for selecting the right converters. But right now that's difficult to do since the static type of type_definition in from_input_object evaluates to Any. I couldn't find a way to address that quickly so I left it as is for now. There is still a lot of room for improvement but I need your views/opinions on that!

Comment on lines 60 to 83
def test_private_field_with_str_annotations():
"""Check compatibility of strawberry.Private with annotations as string."""

from dataclasses import dataclass

@strawberry.type
class Query:
not_seen: "strawberry.Private[SensitiveData]"

@strawberry.field
def accesible_info(self) -> str:
return self.not_seen.info

@dataclass
class SensitiveData:
value: int
info: str

schema = strawberry.Schema(query=Query)

result = schema.execute_sync(
"query { accesibleInfo }", root_value=Query(not_seen=SensitiveData(1, "foo"))
)
assert result.data == {"accesibleInfo": "foo"}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be instead (or in addition) having this test ensure that not_seen does not exist on the schema? This test passes exactly the same if the strawberry.Private wrapping is simply removed.

Also, a couple nitpicky requests:

  • Unless it serves a purpose for this test (which I don't think it does 🤔), can you move the dataclass import to the top of the file?
  • accessible is spelled wrong

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @BryceBeagle thanks a lot for the review! You made a good point about the test still passing if strawberry.Private is removed. I'll fix that right away

@skilkis
Copy link
Contributor Author

skilkis commented Feb 28, 2022

@BryceBeagle I've implemented your suggestions, thanks again for the great feedback.

Adding a negative-engineering test actually revealed an important limitation: this PR works for classes defined on module-scope but not for within a function for example. This happens since field.type is only resolved if the class was defined at module level, otherwise it becomes None. StrawberryAnnotation uses the accepted approach in PEP-563 using an eval with class.__module.__dict__. Depending on the acceptance of PEP-649 there might be support for resolving type-hints of locally-scoped types in the future. However, this means that for now the filtering will not work if a class is defined outside of module-scope. Please see 7d359de for the failing test.

From here I see three options:

  1. Modify is_private to optionally raise an error when type_ is UNRESOLVED (a new sentinel object returned from StrawberryField.type on NameError). Doing so would make sure that schema conversion fails if an unresolved type exists when performing the final check. This could be implemented as follows:
def is_private(field: dataclasses.Field, raise_if_unresolved: bool = False) -> bool:
   _type = field.type
   if get_origin(_type) is Annotated:
       return any(
           isinstance(argument, StrawberryPrivate) for argument in get_args(_type)
       )
   elif _type is UNRESOLVED and raise_if_unresolved:
       raise TypeError(
           f"Could not determine if type of '{field.name}' is private as it cannot be"
           "resolved. Ensure that the parent class of this field is defined in the "
           "module namespace."
       )
   else:
       return False
  1. Similar to Option 1, but any field that is of type UNRESOLVED raises an error during schema conversion:
    @staticmethod
    def _get_thunk_mapping(
        fields: List[StrawberryField],
        name_converter: Callable[[StrawberryField], str],
        field_converter: Callable[[StrawberryField], FieldType],
    ) -> Dict[str, FieldType]:
        thunk_mapping = {}
        for f in fields:
            if f.type is UNRESOLVED:
                raise TypeError(
                    f"Could not resolve the type of '{f.name}'. Check that the class "
                    "is accessible from the global module scope."
                )
            else:
                if not is_private(f.type):
                    thunk_mapping[name_converter(f)] = field_converter(f)
        return thunk_mapping
  1. Update the documentation / release notes to make the user aware of the potential security vulnerability if they chose to define classes within a local-scope. This is how Pydantic handles postponed annotations, but it would mean that users not reading the documentation might run into this vulnerability with strawberry.Private

Let me know how you would like to proceed! Personally, I feel that Option 2 makes the most sense, since this scope problem is not unique to strawberry.Private annotated fields.

@skilkis skilkis requested a review from BryceBeagle March 3, 2022 08:25
Copy link
Member

@patrick91 patrick91 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me! I'll wait for @BryceBeagle's final review before merging this :)

@patrick91
Copy link
Member

@skilkis I'd go with option 2, but I'd do it in another PR :)

we don't support types defined in functions already and I think it does make sense to add a user friendly error :)

Copy link
Member

@BryceBeagle BryceBeagle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @skilkis, thanks for the very thorough responses! I'm going to approve it now so it can get merged when needed, but I'll also leave my comments here:

this PR works for classes defined on module-scope but not for within a function for example

Yeah... this is an unsolved problem we've had for a while with a lot of our typing things. Main use-case has been in tests, so it's not been the worst thing at least.

From here I see three options:

I like option #2 the best. All of our types are supposed to be considered "unresolved" until schema generation to give room for everything to get defined in one step, then resolved in another. Feel free to add it in this PR, or open a ticket tracking it so it can be performed in a future one.

I don't understand #3, though. What "potential security vulnerability" are you referring to? Is there a potential attack vector here?

@skilkis
Copy link
Contributor Author

skilkis commented Mar 5, 2022

@BryceBeagle @patrick91 thanks for approving this PR! I'm excited to be contributing to Strawberry and happy to see that we all agreed on going for Option 2 😄 I would prefer to implement it here quickly before the PR gets merged. It's not a lot of code but can tie up this loose-end with the failing test and more importantly with private fields making their way into the schema.

@BryceBeagle the security vulnerability I mentioned is about leaking sensitive data. I assume it occurs since the type gets resolved as None and then makes its way into the schema as a Void field. The data is then leaked via en error message when the return of not_seen is not None. To demonstrate this we can extend the failing test in this PR and query the notSeen field in the schema:

def test_private_field_defined_outside_module_scope():
    """Check compatibility of strawberry.Private when defined outside module scope."""

    @strawberry.type
    class LocallyScopedQuery:
        not_seen: "strawberry.Private[LocallyScopedSensitiveData]"

        @strawberry.field
        def accessible_info(self) -> str:
            return self.not_seen.info

    @dataclass
    class LocallyScopedSensitiveData:
        value: int
        info: str

    @strawberry.type
    class Query:

        @strawberry.field
        def local_query(self) -> LocallyScopedQuery:
            return LocallyScopedQuery(not_seen=LocallyScopedSensitiveData(1, "foo"))

    schema = strawberry.Schema(query=Query)

    import uvicorn

    graphql_app = GraphQLRouter(schema)

    app = FastAPI()
    app.include_router(graphql_app, prefix="/graphql")
    uvicorn.run(app, host="0.0.0.0", port=8000)

Query:

query LeakingData {
  localQuery {
    notSeen
  }
}

Output:

{
  "data": {
    "localQuery": {
      "notSeen": null
    }
  },
  "errors": [
    {
      "message": "Expected 'None', got 'test_private_field_defined_outside_module_scope.<locals>.LocallyScopedSensitiveData(value=1, info='foo')'",
      "locations": [
        {
          "line": 3,
          "column": 5
        }
      ],
      "path": [
        "localQuery",
        "notSeen"
      ]
    }
  ]
}

The error message is long, but if you scroll you can see that the sensitive data gets leaked from the __repr__ of LocallyScopedSensitiveData.

@patrick91
Copy link
Member

@BryceBeagle the security vulnerability I mentioned is about leaking sensitive data. I assume it occurs since the type gets resolved as None and then makes its way into the schema as a Void field.

this is probably not the best behaviour, do you know in what other cases it happens?

@skilkis
Copy link
Contributor Author

skilkis commented Mar 6, 2022

@patrick91 this is the only one I'm aware of right now. It can be solved by Option 2 since right now the type gets resolved to 'None' and then gets converted to a 'Void' field. I'll push a commit to showcase Option 2, and then you can decide if it belongs in this PR. Otherwise we drop it with a rebase and I make a new PR

Comment on lines 200 to 203
raise TypeError(
f"Could not resolve the type of '{f.name}'. Check that the class "
"is accessible from the global module scope."
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make a custom exception for this? 😊

@@ -39,6 +40,8 @@

_RESOLVER_TYPE = Union[StrawberryResolver, Callable, staticmethod, classmethod]

UNRESOLVED = sentinel.create("UNRESOLVED")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we removed the sentinel package recently, can you rebase?

maybe we create our own sentinel without add the package back again :)

skilkis and others added 6 commits March 7, 2022 23:37
This commit Closes strawberry-graphql#1586 by checking for private fields at schema
conversion time. As per [PEP-563], in the future Python annotations will
no longer be evaluated at definition time. As reported by Issue strawberry-graphql#1586,
this means that `strawberry.Private` is incompatible with postponed
evaluation as the check for a private field requires an evaluated
type-hint annotation to work.

By checking for private fields at schema conversion time, it is
guaranteed that all fields that should be included in the schema are
resolvable using an eval. This ensures that the current approach for
defining private fields can be left intact.

The current filtering for fields annotated with `strawberry.Private` in
`types.type_resolver.py` are left intact to not needlessly instantiate
`StrawberryField` objects when `strawberry.Private` is resolvable.

Summary of Changes:
- Added check for private fields at schema evaluation time
- Added test to check that postponed evaluation with
  `strawberry.Private` functions correctly
- Reduced code duplication in `schema_converter.py`

[PEP-563]: https://www.python.org/dev/peps/pep-0563/
This commit mainly addresses a limitation of PEP-563 wherby annotations
tagetting classes defined outside of module-level scope (i.e. a
function) cannot be resolved. As a result, even at schema-conversion
time, checking `is_private` is not guaranteed to evaluate to a valid
type. As a result, a test is added to ensure that a `strawberry.Private`
annotated field that forward-references a locally-defined class does not
appear in the generated Schema. Although this is a failing test for now
it demonstrates the issue to prevent a potential security vulnerability.

Summary of Changes:
- Check that the `notSeen` private field does not exist in the schema
  and that querying it returns no data.
- Add failing test to show issue of classes defined outside module-scope
- Move import of dataclass to the top of the file
- Correct typo in the word `accessible`
This commit changes `StrawberryField.type` to return a new sentinel
`UNRESOLVED` instead of `None` to make sure that unresolvable types such
as those defined within a local scope of a function do not appear in the
schema as a `Void` field. With the new behavior a `TypeError` will be
raised during schema conversion if any types are still unresolved. Most
importantly, this new behavior prevents a security vulnerability where
`Strawberry.Private` annotated fields would previously appear in the
schema as a `Void` field; thereby, becomming executible and potentially
leaking sensitive data in the resultant error message.

Summary of Changes:
- Return a new sentinel `UNRESOLVED` from `StrawberryField.type` instead
  of `None`
- Update `GraphQLCoreConverter._get_thunk_mapping` to raise a
  `TypeError` if any unresolved types exist during schema conversion
- Update failing `test_private_field_defined_outside_module_scope` to
  check if the correct `TypeError` is raised and to further ensure
  that if no error is raised, that the `Strawberry.Private` annotated
  field does not enter the schema.
@skilkis
Copy link
Contributor Author

skilkis commented Mar 7, 2022

@patrick91 I rebased to the latest release and implemented your suggestions! Should be ready to merge 😄

skilkis and others added 4 commits March 7, 2022 22:48
This commit Closes strawberry-graphql#1586 by checking for private fields at schema
conversion time. As per [PEP-563], in the future Python annotations will
no longer be evaluated at definition time. As reported by Issue strawberry-graphql#1586,
this means that `strawberry.Private` is incompatible with postponed
evaluation as the check for a private field requires an evaluated
type-hint annotation to work.

By checking for private fields at schema conversion time, it is
guaranteed that all fields that should be included in the schema are
resolvable using an eval. This ensures that the current approach for
defining private fields can be left intact.

The current filtering for fields annotated with `strawberry.Private` in
`types.type_resolver.py` are left intact to not needlessly instantiate
`StrawberryField` objects when `strawberry.Private` is resolvable.

Summary of Changes:
- Added check for private fields at schema evaluation time
- Added test to check that postponed evaluation with
  `strawberry.Private` functions correctly
- Reduced code duplication in `schema_converter.py`

[PEP-563]: https://www.python.org/dev/peps/pep-0563/
@patrick91 patrick91 force-pushed the feature/make_strawberry_private_compatible_pep563 branch from cd1f40c to a2905e2 Compare March 8, 2022 03:57
@patrick91
Copy link
Member

I've rebased this again and fixed the sentinel 😊

@patrick91 patrick91 merged commit 7dc715c into strawberry-graphql:main Mar 8, 2022
@botberry
Copy link
Member

botberry commented Mar 8, 2022

Thanks for contributing to Strawberry! 🎉 You've been invited to join
the Strawberry GraphQL organisation 😊

You can also request a free sticker by filling this form: https://forms.gle/dmnfQUPoY5gZbVT67

And don't forget to join our discord server: https://strawberry.rocks/discord 🔥

@skilkis skilkis deleted the feature/make_strawberry_private_compatible_pep563 branch March 8, 2022 08:31
msobas pushed a commit to msobas/strawberry that referenced this pull request Apr 10, 2022
…#1684)

* Make `strawberry.Private` compatible with PEP-563

This commit Closes strawberry-graphql#1586 by checking for private fields at schema
conversion time. As per [PEP-563], in the future Python annotations will
no longer be evaluated at definition time. As reported by Issue strawberry-graphql#1586,
this means that `strawberry.Private` is incompatible with postponed
evaluation as the check for a private field requires an evaluated
type-hint annotation to work.

By checking for private fields at schema conversion time, it is
guaranteed that all fields that should be included in the schema are
resolvable using an eval. This ensures that the current approach for
defining private fields can be left intact.

The current filtering for fields annotated with `strawberry.Private` in
`types.type_resolver.py` are left intact to not needlessly instantiate
`StrawberryField` objects when `strawberry.Private` is resolvable.

Summary of Changes:
- Added check for private fields at schema evaluation time
- Added test to check that postponed evaluation with
  `strawberry.Private` functions correctly
- Reduced code duplication in `schema_converter.py`

[PEP-563]: https://www.python.org/dev/peps/pep-0563/

* Add RELEASE.md

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add docstring to `_get_thunk_mapping`

* Improve testing of private fields

This commit mainly addresses a limitation of PEP-563 wherby annotations
tagetting classes defined outside of module-level scope (i.e. a
function) cannot be resolved. As a result, even at schema-conversion
time, checking `is_private` is not guaranteed to evaluate to a valid
type. As a result, a test is added to ensure that a `strawberry.Private`
annotated field that forward-references a locally-defined class does not
appear in the generated Schema. Although this is a failing test for now
it demonstrates the issue to prevent a potential security vulnerability.

Summary of Changes:
- Check that the `notSeen` private field does not exist in the schema
  and that querying it returns no data.
- Add failing test to show issue of classes defined outside module-scope
- Move import of dataclass to the top of the file
- Correct typo in the word `accessible`

* Prevent unresolved types from entering the schema

This commit changes `StrawberryField.type` to return a new sentinel
`UNRESOLVED` instead of `None` to make sure that unresolvable types such
as those defined within a local scope of a function do not appear in the
schema as a `Void` field. With the new behavior a `TypeError` will be
raised during schema conversion if any types are still unresolved. Most
importantly, this new behavior prevents a security vulnerability where
`Strawberry.Private` annotated fields would previously appear in the
schema as a `Void` field; thereby, becomming executible and potentially
leaking sensitive data in the resultant error message.

Summary of Changes:
- Return a new sentinel `UNRESOLVED` from `StrawberryField.type` instead
  of `None`
- Update `GraphQLCoreConverter._get_thunk_mapping` to raise a
  `TypeError` if any unresolved types exist during schema conversion
- Update failing `test_private_field_defined_outside_module_scope` to
  check if the correct `TypeError` is raised and to further ensure
  that if no error is raised, that the `Strawberry.Private` annotated
  field does not enter the schema.

* Make `strawberry.Private` compatible with PEP-563

This commit Closes strawberry-graphql#1586 by checking for private fields at schema
conversion time. As per [PEP-563], in the future Python annotations will
no longer be evaluated at definition time. As reported by Issue strawberry-graphql#1586,
this means that `strawberry.Private` is incompatible with postponed
evaluation as the check for a private field requires an evaluated
type-hint annotation to work.

By checking for private fields at schema conversion time, it is
guaranteed that all fields that should be included in the schema are
resolvable using an eval. This ensures that the current approach for
defining private fields can be left intact.

The current filtering for fields annotated with `strawberry.Private` in
`types.type_resolver.py` are left intact to not needlessly instantiate
`StrawberryField` objects when `strawberry.Private` is resolvable.

Summary of Changes:
- Added check for private fields at schema evaluation time
- Added test to check that postponed evaluation with
  `strawberry.Private` functions correctly
- Reduced code duplication in `schema_converter.py`

[PEP-563]: https://www.python.org/dev/peps/pep-0563/

* Create custom exception for unresolved field types

* Remove dependency on sentinel package

* Fix sentinel

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Patrick Arminio <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

from __future__ import annotations incompatible with strawberry.Private
4 participants