Opened 6 months ago

Last modified 3 weeks ago

#36897 assigned Cleanup/optimization

Improve repercent_broken_unicode() performance

Reported by: Tarek Nakkouch Owned by: beestarkdev
Component: Utilities Version: 6.0
Severity: Normal Keywords:
Cc: Harsh007 Triage Stage: Accepted
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: yes
Easy pickings: no UI/UX: no

Description

The repercent_broken_unicode() function in django/utils/encoding.py has performance issues when processing URLs with many consecutive invalid UTF-8 bytes. The bottleneck is due to raising an exception for each invalid byte and creating intermediate bytes objects through concatenation.

changed_parts = []
while True:
    try:
        path.decode()
    except UnicodeDecodeError as e:
        repercent = quote(path[e.start : e.end], safe=b"/#%[]=:;$&()+,!?*@'~")
        # creates new bytes object
        changed_parts.append(path[: e.start] + repercent.encode())
        path = path[e.end :]
    else:
        return b"".join(changed_parts) + path

Suggested optimization

The simplest solution is to append byte parts separately to the list instead of concatenating them with the + operator, avoiding creation of intermediate bytes objects. This provides ~40% improvement while keeping the same exception-based approach:

changed_parts = []
while True:
    try:
        path.decode()
    except UnicodeDecodeError as e:
        repercent = quote(path[e.start : e.end], safe=b"/#%[]=:;$&()+,!?*@'~")
        changed_parts.append(path[: e.start])
        changed_parts.append(repercent.encode())
        path = path[e.end :]
    else:
        changed_parts.append(path)
        return b"".join(changed_parts)

Alternatively, a manual UTF-8 validation approach could eliminate exception overhead entirely by scanning byte-by-byte and checking UTF-8 patterns to identify invalid sequences without raising exceptions. This would reduce processing time by ~80% though the implementation is more complex.

Change History (9)

comment:1 by Tarek Nakkouch, 6 months ago

Summary: Optimize repercent_broken_unicode() performanceImprove repercent_broken_unicode() performance

comment:2 by Harsh007, 6 months ago

Cc: Harsh007 added
Owner: set to Harsh007
Status: newassigned

comment:3 by Harsh007, 6 months ago

Owner: Harsh007 removed
Status: assignednew

comment:4 by beestarkdev, 6 months ago

Owner: set to beestarkdev
Status: newassigned

comment:5 by beestarkdev, 6 months ago

Triage Stage: UnreviewedAccepted

comment:6 by beestarkdev, 6 months ago

Has patch: set

comment:7 by beestarkdev, 6 months ago

Hi, here is the pull request: https://github.com/django/django/pull/20626

This is my first time ever contributing to open source so please feel free to give me feedback if there's anything I can improve on.

I have attempted to add another optimization to this function in addition to the recommendation here. I will post the testing/benchmarking methodologies as well in the pull request for full transparency. Thank you!

comment:8 by David Smith, 3 weeks ago

The last couple of times this function has been touched is to address CVEs (see 1 and 2 below). So anything here needs to be treated with caution, i feel.

I confirmed that a long string of invalid characters has much slower performance than a similarly sized string which is valid. I prepared a benchmark at asv benchmark. The patch propsed above has a ~15% improvement for the worse case scenario but is a more complex patch.

Anthropic's Claude pointed out to me that you can register custom error handlers for codecs, see python docs. This approach has a ~4x increase in performance for the scenario where there is a long string of invalid inputs to decode.

I would suggest next steps would be to investigte if this approach has any security issues and to be as certain as we can be that we don't re-introduce any issues that have previosuly been addressed.

  • django/utils/encoding.py

    diff --git a/django/utils/encoding.py b/django/utils/encoding.py
    index e57e2a2ba1..c65db586cb 100644
    a b def punycode(domain):  
    210210    return domain.encode("idna").decode("ascii")
    211211
    212212
     213def _repercent_error_handler(exc):
     214    invalid_input = exc.object[exc.start : exc.end]
     215    repercent = quote(invalid_input, safe=b"/#%[]=:;$&()+,!?*@'~")
     216    return repercent, exc.end
     217
     218
     219codecs.register_error("django_repercent_error_handler", _repercent_error_handler)
     220
     221
    213222def repercent_broken_unicode(path):
    214223    """
    215224    As per RFC 3987 Section 3.2, step three of converting a URI into an IRI,
    216225    repercent-encode any octet produced that is not part of a strictly legal
    217226    UTF-8 octet sequence.
    218227    """
    219     changed_parts = []
    220     while True:
    221         try:
    222             path.decode()
    223         except UnicodeDecodeError as e:
    224             # CVE-2019-14235: A recursion shouldn't be used since the exception
    225             # handling uses massive amounts of memory
    226             repercent = quote(path[e.start : e.end], safe=b"/#%[]=:;$&()+,!?*@'~")
    227             changed_parts.append(path[: e.start] + repercent.encode())
    228             path = path[e.end :]
    229         else:
    230             return b"".join(changed_parts) + path
     228    return path.decode("utf-8", "django_repercent_error_handler").encode("utf-8")
    231229
    232230
    233231def filepath_to_uri(path):
  • tests/utils_tests/test_encoding.py

    diff --git a/tests/utils_tests/test_encoding.py b/tests/utils_tests/test_encoding.py
    index e0ee190431..01bddfeeff 100644
    a b class TestEncodingUtils(SimpleTestCase):  
    128128        decoded_paths = []
    129129
    130130        def mock_quote(*args, **kwargs):
    131             # The second frame is the call to repercent_broken_unicode().
    132             decoded_paths.append(inspect.currentframe().f_back.f_locals["path"])
     131            # The second frame is the call from _repercent_encode.
     132            decoded_paths.append(
     133                inspect.currentframe().f_back.f_locals["invalid_input"]
     134            )
    133135            return quote(*args, **kwargs)
    134136
    135137        with mock.patch("django.utils.encoding.quote", mock_quote):
    136138            self.assertEqual(repercent_broken_unicode(data), b"test%FCtest%FCtest%FC")
    137139
    138         # decode() is called on smaller fragment of the path each time.
     140        # decode() is called on each invalid fragment
    139141        self.assertEqual(
    140142            decoded_paths,
    141             [b"test\xfctest\xfctest\xfc", b"test\xfctest\xfc", b"test\xfc"],
     143            [b"\xfc", b"\xfc", b"\xfc"],
    142144        )

1 - 76ed1c49f804d409cfc2911a890c78584db3c76e
2 - 3f41d6d62929dfe53eda8109b3b836f26645bdce

comment:9 by David Smith, 3 weeks ago

Patch needs improvement: set
Note: See TracTickets for help on using tickets.
Back to Top