-
Notifications
You must be signed in to change notification settings - Fork 30
INTPYTHON-827 Fix Model.save() updates and QuerySet.update() for $-prefixed strings #445
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
base: main
Are you sure you want to change the base?
Conversation
|
Did you forget to |
Yes |
| if is_direct_value(value): | ||
| prepared = {"$literal": prepared} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it necessary to wrap all values? Are there problems with other types? Our logic in Value() only wraps list/int/str.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A unit test (test_update_dollar_prefix_in_value_expression) shows the issue. Because we could update passing an expression, and the expression could be Value("$update")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I meant: Is it necessary to wrap all direct values?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
understood, 🤔 . Will try to make a test and try to inject something.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
well, 🤔. maybe only string should be wrapped, not any kind of value.
EDIT
it is possible:
def test_update_dict(self):
obj = Author.objects.create(name="foobar")
obj.name = Value({"$concat": ["$name", "-", "$name"]})
obj.save()
obj.refresh_from_db()
self.assertEqual(obj.name, {"$concat": ["$name", "-", "$name"]})this tails fails and the name was update as foobar-foobar
I think we should scape any kind of literal
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That seems sufficient to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another case:
def test_update_dictvalue(self):
obj = Author.objects.create(name="foobar", data={})
obj.data = {"$concat": ["$name", "-", "$name"]}
obj.save()
obj.refresh_from_db()
self.assertEqual(obj.data, {"$concat": ["$name", "-", "$name"]})If the field is a JSONField. Any kind of type that could be a MongoDB query, could be exploitable.
I think wrapping al values will bring us a safe net.
django_mongodb_backend/test.py
Outdated
| expected_pipeline, | ||
| ) | ||
|
|
||
| def assertUpdateQuery(self, query, expected_collection, expected_pipeline, new_values): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
new_values -> expected_values?
expected_pipeline -> expected_condition?
django_mongodb_backend/test.py
Outdated
| def assertUpdateQuery(self, query, expected_collection, expected_pipeline, new_values): | ||
| """ | ||
| Assert that the logged query is equal to: | ||
| db.{expected_collection}.aggregate({expected_pipeline}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
update_many
tests/queries_/tests.py
Outdated
| class SaveUpdateDollarPrefixTests(MongoTestCaseMixin, TestCase): | ||
| def test_insert_dollar_prefix(self): | ||
| """Ensure $-prefixed values are correctly saved on insert.""" | ||
| obj = Author.objects.create(name="$foobar") | ||
| refreshed = Author.objects.get(pk=obj.pk) | ||
| self.assertEqual(refreshed.name, "$foobar") | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not a regression test so should be a separate commit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤔 I though I need to test also the save process, but the insertion isn't a problem. So could we remove this test? or it is a nice to have?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nothing wrong with more tests, just saying that additional test coverage should be added in a separate commit so it's easy to identify what a commit does and doesn't fix.
| def test_update_dollar_prefix(self): | ||
| """Ensure $-prefixed values are correctly saved on update.""" | ||
| obj = Author.objects.create(name="foobar") | ||
| with self.assertNumQueries(1) as ctx: | ||
| obj.name = "$updated" | ||
| obj.save() | ||
| refreshed = Author.objects.get(pk=obj.pk) | ||
| self.assertEqual(refreshed.name, "$updated") | ||
| self.assertUpdateQuery( | ||
| ctx.captured_queries[0]["sql"], | ||
| "queries__author", | ||
| {"_id": obj.id}, | ||
| [{"$set": {"name": {"$literal": "$updated"}}}], | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Per https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/#python-style, "In test docstrings, state the expected behavior that each test demonstrates. Don’t include preambles such as “Tests that” or “Ensures that”."
| if isinstance(value, (list, int, str)) and as_expr: | ||
| # Wrap lists, numbers, & string in $literal to prevent ambiguity when Value | ||
| # appears in $project or $update. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fix for Value could be a separate commit. Also, add a test in expressions_/test_value.py.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, will separate the commits.
| if isinstance(value, (list, int, str)) and as_expr: | ||
| # Wrap lists, numbers, & string in $literal to prevent ambiguity when Value | ||
| # appears in $project or $update. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know what you mean, but I think "$update" isn't a thing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A rushed comment, there is nothing called that in our pipeline. should be rewritten.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All the str should be wrapped.
def test_query_injection(self):
obj1 = Author.objects.create(name="Gustavo")
obj2 = Author.objects.create(name="Walter")
with self.assertNumQueries(1) as ctx:
qs = list(Author.objects.annotate(a_value=Value("$name")))
print(ctx.captured_queries[0]["sql"])
print(qs)the generated mql is:
db.basic__author.aggregate([{'$project': {'a_value': '$name', '_id': 1, 'name': 1}}])
and the results are:
qs[0].a_value == "Gustavo"
qs[1].a_value == "Walter"So the code was injected. the expected value is a constant value "$name"
tests/basic_/tests.py
Outdated
| from django.db.models import Value | ||
| from django.test import TestCase | ||
|
|
||
| from django_mongodb_backend.test import MongoTestCaseMixin | ||
|
|
||
| from .models import Author | ||
|
|
||
|
|
||
| class SaveUpdateDollarPrefixTests(MongoTestCaseMixin, TestCase): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can add tests/basic_/tests.py for basic model operations tests. So I see three commits: add insert test, fix update, fix update with Value()
tests/queries_/tests.py
Outdated
| def test_insert_dollar_prefix(self): | ||
| """Ensure $-prefixed values are correctly saved on insert.""" | ||
| obj = Author.objects.create(name="$foobar") | ||
| refreshed = Author.objects.get(pk=obj.pk) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
obj.refresh_from_db()
tests/queries_/tests.py
Outdated
| """Ensure $-prefixed values are correctly saved on update.""" | ||
| obj = Author.objects.create(name="foobar") | ||
| with self.assertNumQueries(1) as ctx: | ||
| obj.name = "$updated" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the assignment can be outside the with.
6c2af4c to
68e4632
Compare
68e4632 to
c1b41ac
Compare
INTPYTHON-827
Summary
This PR fixes unsafe handling of
$-prefixed values duringsave()andupdate()operations in the Django MongoDB backend.Values like
{"$concat": [...]}or("$name", "-", "$name")were previously interpreted as MongoDB operators instead of being stored literally.The patch ensures that all such values are wrapped with
$literalwhen appropriate, preventing unintended expression or operator injection.Changes in this PR
$literalwhere needed.Test Plan
$-prefixed values are safely stored and not interpreted as expressions.Value()wrappers.$-prefixed) values are unaffected (basic unit test from Django)Checklist
Checklist for Author
Checklist for Reviewer @Jibola @aclark4life @timgraham
Focus Areas for Reviewer
Pay attention to the logic handling
$literalwrapping in value compilation.