#25705: Parameters are not adapted or quoted in Query.__str__
-------------------------------------+-------------------------------------
     Reporter:  Dmitry Dygalo        |                    Owner:  Alex
         Type:                       |                   Status:  assigned
  Cleanup/optimization               |
    Component:  Database layer       |                  Version:  dev
  (models, ORM)                      |
     Severity:  Normal               |               Resolution:
     Keywords:                       |             Triage Stage:  Accepted
    Has patch:  1                    |      Needs documentation:  0
  Needs tests:  1                    |  Patch needs improvement:  1
Easy pickings:  0                    |                    UI/UX:  0
-------------------------------------+-------------------------------------
Comment (by Simon Charette):

 Thank you for chiming in Mariusz.

 > I don't mind documenting `sql_with_params()`, **but would like to avoid
 encouraging users to use raw SQL queries.**. [snip]
 > For most users it may be tricky to include parameters into an SQL string
 which can lead to SQL injection vectors. IMO, sql_with_params() is only
 for advanced users, who know the risks.

 I share this sentiment but I believe that there will always be a need for
 an escape hatch and if we don't provide a safe one users will follow the
 path of least resistance that `sql.Query.__str__` provides. My hope is
 that with the documentation of `sql_with_params` we could even consider
 disallowing passing `str(qs.query)` to `raw` and `cursor.execute`
 completely to prevent its misuse. Something like

 {{{#!diff
 diff --git a/django/db/backends/utils.py b/django/db/backends/utils.py
 index ab0ea8258b..9874b362b3 100644
 --- a/django/db/backends/utils.py
 +++ b/django/db/backends/utils.py
 @@ -9,6 +9,7 @@

  from django.apps import apps
  from django.db import NotSupportedError
 +from django.db.utils import QueryStr
  from django.utils.dateparse import parse_time

  logger = logging.getLogger("django.db.backends")
 @@ -94,6 +95,8 @@ def _execute_with_wrappers(self, sql, params, many,
 executor):
      def _execute(self, sql, params, *ignored_wrapper_args):
          # Raise a warning during app initialization (stored_app_configs
 is only
          # ever set during testing).
 +        if isinstance(sql, QueryStr):
 +            raise TypeError("str(qs.query) cannot be passed directly to
 execute(sql).")
          if not apps.ready and not apps.stored_app_configs:
              warnings.warn(self.APPS_NOT_READY_WARNING_MSG,
 category=RuntimeWarning)
          self.db.validate_no_broken_transaction()
 @@ -107,6 +110,8 @@ def _execute(self, sql, params,
 *ignored_wrapper_args):
      def _executemany(self, sql, param_list, *ignored_wrapper_args):
          # Raise a warning during app initialization (stored_app_configs
 is only
          # ever set during testing).
 +        if isinstance(sql, QueryStr):
 +            raise TypeError("str(qs.query) cannot be passed directly to
 execute(sql).")
          if not apps.ready and not apps.stored_app_configs:
              warnings.warn(self.APPS_NOT_READY_WARNING_MSG,
 category=RuntimeWarning)
          self.db.validate_no_broken_transaction()
 diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py
 index f00eb1e5a5..9e7cc03527 100644
 --- a/django/db/models/sql/query.py
 +++ b/django/db/models/sql/query.py
 @@ -18,6 +18,7 @@

  from django.core.exceptions import FieldDoesNotExist, FieldError
  from django.db import DEFAULT_DB_ALIAS, NotSupportedError, connections
 +from django.db.utils import QueryStr
  from django.db.models.aggregates import Count
  from django.db.models.constants import LOOKUP_SEP
  from django.db.models.expressions import (
 @@ -145,6 +146,8 @@ class RawQuery:
      """A single raw SQL query."""

      def __init__(self, sql, using, params=()):
 +        if isinstance(sql, QueryStr):
 +            raise TypeError("Query strings cannot be safely used by
 RawQuery. Use sql_with_params().")
          self.params = params
          self.sql = sql
          self.using = using
 @@ -191,8 +194,8 @@ def params_type(self):

      def __str__(self):
          if self.params_type is None:
 -            return self.sql
 -        return self.sql % self.params_type(self.params)
 +            return QueryStr(self.sql)
 +        return QueryStr(self.sql % self.params_type(self.params))

      def _execute_query(self):
          connection = connections[self.using]
 @@ -340,7 +343,7 @@ def __str__(self):
          done by the database interface at execution time.
          """
          sql, params = self.sql_with_params()
 -        return sql % params
 +        return QueryStr(sql % params)

      def sql_with_params(self):
          """
 diff --git a/django/db/utils.py b/django/db/utils.py
 index e45f1db249..a5f821cee7 100644
 --- a/django/db/utils.py
 +++ b/django/db/utils.py
 @@ -276,3 +276,12 @@ def get_migratable_models(self, app_config, db,
 include_auto_created=False):
          """Return app models allowed to be migrated on provided db."""
          models =
 app_config.get_models(include_auto_created=include_auto_created)
          return [model for model in models if self.allow_migrate_model(db,
 model)]
 +
 +
 +class QueryStr(str):
 +    """
 +    String representation of a SQL query with interpolated params that is
 +    unsafe to pass to the database in raw form.
 +    """
 +    def __str__(self):
 +        return self
 }}}

 > Also, sql_with_params() is not a panacea, it has it's limitation e.g.
 **it always uses the default database connection**.

 I don't think this is a big issue, the above discussions suggest allowing
 `alias` to be passed

 {{{#!diff
 diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py
 index 9e7cc03527..f952c1bb86 100644
 --- a/django/db/models/sql/query.py
 +++ b/django/db/models/sql/query.py
 @@ -345,12 +345,12 @@ def __str__(self):
          sql, params = self.sql_with_params()
          return QueryStr(sql % params)

 -    def sql_with_params(self):
 +    def sql_with_params(self, alias=DEFAULT_DB_ALIAS):
          """
          Return the query as an SQL string and the parameters that will be
          substituted into the query.
          """
 -        return self.get_compiler(DEFAULT_DB_ALIAS).as_sql()
 +        return self.get_compiler(alias).as_sql()

      def __deepcopy__(self, memo):
          """Limit the amount of work when a Query is deepcopied."""
 }}}

 With your approval I'll re-open #34636, Alex feel free to pick it up.
-- 
Ticket URL: <https://code.djangoproject.com/ticket/25705#comment:21>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.

-- 
You received this message because you are subscribed to the Google Groups 
"Django updates" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To view this discussion on the web visit 
https://groups.google.com/d/msgid/django-updates/01070190c6629f16-f7715432-d850-45f5-9822-09631c2811a8-000000%40eu-central-1.amazonses.com.

Reply via email to