Skip to content

Conversation

@WaVEV
Copy link
Collaborator

@WaVEV WaVEV commented Nov 12, 2025

INTPYTHON-827

Summary

This PR fixes unsafe handling of $-prefixed values during save() and update() 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 $literal when appropriate, preventing unintended expression or operator injection.

Changes in this PR

  • Updated value compilation to wrap dict, tuple, string, and numeric values in $literal where needed.
  • Update SQLUpdateCompiler to wrap any direct value (i.e. string, dict, tuple, etc).

Test Plan

  • Added unit tests verifying that $-prefixed values are safely stored and not interpreted as expressions.
  • Tested scenarios for dicts, tuples, and Value() wrappers.
  • Verified that regular (non-$-prefixed) values are unaffected (basic unit test from Django)

Checklist

Checklist for Author

  • Did you update the changelog (if necessary)?
  • Is the intention of the code captured in relevant tests?
  • If there are new TODOs, has a related JIRA ticket been created?

Checklist for Reviewer @Jibola @aclark4life @timgraham

  • Does the title of the PR reference a JIRA Ticket?
  • Do you fully understand the implementation?
  • Have you checked for spelling & grammar errors?
  • Is all relevant documentation (README or docstring) updated?

Focus Areas for Reviewer

Pay attention to the logic handling $literal wrapping in value compilation.

@WaVEV WaVEV marked this pull request as draft November 12, 2025 16:35
@timgraham
Copy link
Collaborator

Did you forget to git add a new file with the test?

@WaVEV
Copy link
Collaborator Author

WaVEV commented Nov 12, 2025

Did you forget to git add a new file with the test?

Yes

Comment on lines +886 to +887
if is_direct_value(value):
prepared = {"$literal": prepared}
Copy link
Collaborator

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.

Copy link
Collaborator Author

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")

Copy link
Collaborator

@timgraham timgraham Nov 12, 2025

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?

Copy link
Collaborator Author

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.

Copy link
Collaborator Author

@WaVEV WaVEV Nov 13, 2025

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

Copy link
Collaborator

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.

Copy link
Collaborator Author

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.

expected_pipeline,
)

def assertUpdateQuery(self, query, expected_collection, expected_pipeline, new_values):
Copy link
Collaborator

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?

def assertUpdateQuery(self, query, expected_collection, expected_pipeline, new_values):
"""
Assert that the logged query is equal to:
db.{expected_collection}.aggregate({expected_pipeline})
Copy link
Collaborator

Choose a reason for hiding this comment

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

update_many

Comment on lines 9 to 15
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")

Copy link
Collaborator

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.

Copy link
Collaborator Author

@WaVEV WaVEV Nov 12, 2025

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?

Copy link
Collaborator

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.

Comment on lines 16 to 31
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"}}}],
)
Copy link
Collaborator

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”."

Comment on lines 214 to 216
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.
Copy link
Collaborator

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.

Copy link
Collaborator Author

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.

Comment on lines 214 to 216
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.
Copy link
Collaborator

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.

Copy link
Collaborator Author

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.

Copy link
Collaborator Author

@WaVEV WaVEV Nov 13, 2025

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"

Comment on lines 1 to 9
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):
Copy link
Collaborator

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()

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)
Copy link
Collaborator

Choose a reason for hiding this comment

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

obj.refresh_from_db()

"""Ensure $-prefixed values are correctly saved on update."""
obj = Author.objects.create(name="foobar")
with self.assertNumQueries(1) as ctx:
obj.name = "$updated"
Copy link
Collaborator

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.

@timgraham timgraham changed the title Fix handling of $-prefixed string values during save INTPYTHON-827 Fix Model.save() updates and QuerySet.update() for $-prefixed strings Nov 12, 2025
@WaVEV WaVEV force-pushed the INTPYTHON-827-Cannot-save-CharField-and-TextField-with-values-starting-with-$ branch from 6c2af4c to 68e4632 Compare November 12, 2025 21:57
@WaVEV WaVEV force-pushed the INTPYTHON-827-Cannot-save-CharField-and-TextField-with-values-starting-with-$ branch from 68e4632 to c1b41ac Compare November 13, 2025 01:16
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.

2 participants