Opened 2 days ago

Last modified 2 days ago

#36152 new Cleanup/optimization

Annotation with `%` in alias fails at db level on Postgres and MySQL

Reported by: Jacob Walls Owned by:
Component: Database layer (models, ORM) Version: 5.1
Severity: Normal Keywords:
Cc: Triage Stage: Accepted
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description (last modified by Jacob Walls)

When developing with postgres, if you are creating dynamic annotations, you have to know to reject % in the provided alias or transform it to %%, otherwise you end up with an unhelpful exception from the db adapter.

test case:

  • tests/annotations/tests.py

    diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py
    index 29660a827e..6bc9bc9ed4 100644
    a b class NonAggregateAnnotationTestCase(TestCase):  
    11711171                with self.assertRaisesMessage(ValueError, msg):
    11721172                    Book.objects.annotate(**{crafted_alias: Value(1)})
    11731173
     1174    def test_annotation_contains_percent(self):
     1175        Book.objects.annotate(**{"contains_percent%": F("pk")}).first()
     1176
    11741177    @skipUnless(connection.vendor == "postgresql", "PostgreSQL tests")
    11751178    @skipUnlessDBFeature("supports_json_field")
    11761179    def test_set_returning_functions(self):

Gives on psycopg2:

IndexError: tuple index out of range

On psycopg 3, the errors are more helpful, either:

django.db.utils.ProgrammingError: only '%s', '%b', '%t' are allowed as placeholders, got '%"'

Or if the annotation indeed included %s:

django.db.utils.ProgrammingError: the query has 2 placeholders but 1 parameters were passed

But I still think we could easily fail at the ORM level instead of going to the backend and failing there just quote % to %%.

The Oracle backend does this for you.

Change History (5)

comment:1 by Jacob Walls, 2 days ago

Description: modified (diff)

comment:2 by Simon Charette, 2 days ago

If we are going to do that we'd need to be particularly careful here as annotations have been a vector of SQL injection in the past.

I think we should instead include % in FORBIDDEN_ALIAS_PATTERN as by the time comes to escaping the SQL is mixed up with actual placeholders %s and now alias names. I wouldn't be surprised if the fact we allow % today could be exploited one way to leak some parameter that should be present in the WHERE clause for example.

The naive approach of adjusting SQLCompiler.get_select to escape percent signs could cause more harm than good as PEP-249 allows for Cursor.execute to be called with and without params: tuple | None and when None is provided no parameter interpolation takes place so the prior escaping would now assign the wrong alias.

In summary I think we should either wont-fix that one or accept it on the basis of augmenting FORBIDDEN_ALIAS_PATTERN as the complexity and security tradeoff are not worth the UX benefits it would provide (particularly because it has been broken for years already).

Last edited 2 days ago by Simon Charette (previous) (diff)

comment:3 by Jacob Walls, 2 days ago

Summary: Postgres backend could quote `%` in column aliases instead of failing at the db levelAnnotation with `%` in alias fails at db level on postgres

I'd be in favor of adjusting the regex. That's essentially what my code does, and I could remove some workarounds if we get it in core.

I wouldn't be surprised if the fact we allow % today could be exploited one way to leak some parameter that should be present in the WHERE clause for example.

I did try to fiddle with that before deciding whether to go to the security team first. This had been on my medium term tinker list for a while and finally opened an issue today because I saw a similar issue for oracle in ticket:36147 waiting for a reproducer. (I was surprised to see the annotation "just work" on Oracle -- this is how I noticed that backend replaces % for you.)

Do we need to worry about a deprecation path for non-postgres backends?

comment:4 by Simon Charette, 2 days ago

Summary: Annotation with `%` in alias fails at db level on postgresAnnotation with `%` in alias fails at db level on Postgres and MySQL

Do we need to worry about a deprecation path for non-postgres backends?

I assume we would need a path yes, it's also crashing on MySQL though so not only a problem on Postgres

Traceback (most recent call last):
  File "/usr/local/lib/python3.12/site-packages/MySQLdb/cursors.py", line 200, in _mogrify
    query = query % args
            ~~~~~~^~~~~~
TypeError: not enough arguments for format string

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/django/source/django/db/backends/utils.py", line 105, in _execute
    return self.cursor.execute(sql, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/django/source/django/db/backends/mysql/base.py", line 76, in execute
    return self.cursor.execute(query, args)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/MySQLdb/cursors.py", line 176, in execute
    mogrified_query = self._mogrify(query, args)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/MySQLdb/cursors.py", line 202, in _mogrify
    raise ProgrammingError(str(m))
MySQLdb.ProgrammingError: not enough arguments for format string

The specialized logic for Oracle originated from 2249bd275cbae6b73716bf36f5f36def1bb4222e which was likely to support introspecting legacy tables columns containing % signs. Tat means we can't realistically deprecate the quote_name path entirely. I haven't looked into why things work on SQLite.

comment:5 by Natalia Bidart, 2 days ago

Triage Stage: UnreviewedAccepted

I'm inclined to go with the "make FORBIDDEN_ALIAS_PATTERN more restrictive" option, accepting the ticket on that basis.

Note: See TracTickets for help on using tickets.
Back to Top