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 , 6 months ago
| Summary: | Optimize repercent_broken_unicode() performance → Improve repercent_broken_unicode() performance |
|---|
comment:2 by , 6 months ago
| Cc: | added |
|---|---|
| Owner: | set to |
| Status: | new → assigned |
comment:3 by , 6 months ago
| Owner: | removed |
|---|---|
| Status: | assigned → new |
comment:4 by , 6 months ago
| Owner: | set to |
|---|---|
| Status: | new → assigned |
comment:5 by , 6 months ago
| Triage Stage: | Unreviewed → Accepted |
|---|
comment:6 by , 6 months ago
| Has patch: | set |
|---|
comment:7 by , 6 months ago
comment:8 by , 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): 210 210 return domain.encode("idna").decode("ascii") 211 211 212 212 213 def _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 219 codecs.register_error("django_repercent_error_handler", _repercent_error_handler) 220 221 213 222 def repercent_broken_unicode(path): 214 223 """ 215 224 As per RFC 3987 Section 3.2, step three of converting a URI into an IRI, 216 225 repercent-encode any octet produced that is not part of a strictly legal 217 226 UTF-8 octet sequence. 218 227 """ 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") 231 229 232 230 233 231 def 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): 128 128 decoded_paths = [] 129 129 130 130 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 ) 133 135 return quote(*args, **kwargs) 134 136 135 137 with mock.patch("django.utils.encoding.quote", mock_quote): 136 138 self.assertEqual(repercent_broken_unicode(data), b"test%FCtest%FCtest%FC") 137 139 138 # decode() is called on smaller fragment of the path each time.140 # decode() is called on each invalid fragment 139 141 self.assertEqual( 140 142 decoded_paths, 141 [b" test\xfctest\xfctest\xfc", b"test\xfctest\xfc", b"test\xfc"],143 [b"\xfc", b"\xfc", b"\xfc"], 142 144 )
1 - 76ed1c49f804d409cfc2911a890c78584db3c76e
2 - 3f41d6d62929dfe53eda8109b3b836f26645bdce
comment:9 by , 3 weeks ago
| Patch needs improvement: | set |
|---|
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!