Skip to content

Commit fe73270

Browse files
authored
Escape only "[" "]" for db url parsing (tortoise#2092)
1 parent 8c3aff7 commit fe73270

File tree

4 files changed

+92
-57
lines changed

4 files changed

+92
-57
lines changed

docs/databases.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,37 @@ If password contains special characters it need to be URL encoded:
3030
>>> urllib.parse.quote_plus("kx%jj5/g")
3131
'kx%25jj5%2Fg'
3232
33+
.. note::
34+
35+
Passwords containing ``%`` followed by valid hex digits (e.g., ``foo%bar``)
36+
may not be parsed correctly from a URL string, because the URL parser interprets
37+
such sequences as percent-encoded characters. If your password contains ``%``,
38+
either percent-encode it (as shown above) or use the dict-based configuration
39+
format instead, which bypasses URL parsing entirely:
40+
41+
.. code-block:: python3
42+
43+
await Tortoise.init(config={
44+
"connections": {
45+
"default": {
46+
"engine": "tortoise.backends.asyncpg",
47+
"credentials": {
48+
"host": "127.0.0.1",
49+
"port": 5432,
50+
"user": "myuser",
51+
"password": "ADM[r$VIS]",
52+
"database": "mydb",
53+
}
54+
}
55+
},
56+
"apps": {
57+
"models": {
58+
"models": ["myapp.models"],
59+
"default_connection": "default",
60+
}
61+
},
62+
})
63+
3364
The supported ``DB_TYPE``:
3465

3566
``sqlite``:

tests/backends/test_db_url.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,55 @@ def test_postgres_params():
196196
}
197197

198198

199+
def test_mysql_special_chars_in_password():
200+
db_url = "mysql://some_user:ADM[r$VIS]@test-rds.somedata.net:3306/mydb?charset=utf8mb4"
201+
res = expand_db_url(db_url)
202+
assert res == {
203+
"engine": "tortoise.backends.mysql",
204+
"credentials": {
205+
"database": "mydb",
206+
"host": "test-rds.somedata.net",
207+
"password": "ADM[r$VIS]",
208+
"port": 3306,
209+
"user": "some_user",
210+
"charset": "utf8mb4",
211+
"sql_mode": "STRICT_TRANS_TABLES",
212+
},
213+
}
214+
215+
216+
def test_mysql_unbalanced_brackets_in_password():
217+
db_url = "mysql://fail_user:DMK_15[ZWIN6@test-rds.somedata.net:3306/mydb2?charset=utf8mb4"
218+
res = expand_db_url(db_url)
219+
assert res == {
220+
"engine": "tortoise.backends.mysql",
221+
"credentials": {
222+
"database": "mydb2",
223+
"host": "test-rds.somedata.net",
224+
"password": "DMK_15[ZWIN6",
225+
"port": 3306,
226+
"user": "fail_user",
227+
"charset": "utf8mb4",
228+
"sql_mode": "STRICT_TRANS_TABLES",
229+
},
230+
}
231+
232+
233+
def test_mysql_literal_percent_in_password_is_corrupted():
234+
# Known limitation: a literal '%' followed by valid hex (e.g. '%ba') gets
235+
# decoded by unquote_plus, corrupting the password. Users must pre-encode
236+
# '%' as '%25' in their URLs to avoid this.
237+
db_url = "mysql://user:foo%bar@127.0.0.1:3306/mydb"
238+
res = expand_db_url(db_url)
239+
assert res["credentials"]["password"] != "foo%bar"
240+
241+
242+
def test_mysql_pre_encoded_percent_in_password():
243+
db_url = "mysql://user:foo%25bar@127.0.0.1:3306/mydb"
244+
res = expand_db_url(db_url)
245+
assert res["credentials"]["password"] == "foo%bar"
246+
247+
199248
def test_mysql_basic():
200249
res = expand_db_url("mysql://root:@127.0.0.1:33060/test")
201250
assert res == {

tests/test_issue_404.py

Lines changed: 0 additions & 34 deletions
This file was deleted.

tortoise/backends/base/config_generator.py

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -130,47 +130,36 @@
130130

131131

132132
def _quote_url_userinfo(db_url: str) -> str:
133-
"""Quote special characters in username and password to avoid URL parsing issues.
133+
"""Encode characters in the userinfo section that break urlparse.
134134
135-
urlparse fails when passwords contain characters like '[' or ']' because it
136-
tries to parse them as IPv6 addresses. This function percent-encodes the userinfo
137-
part (username:password) while leaving the rest of the URL intact.
138-
139-
Only characters that cause parsing issues (like '[' and ']') are encoded.
140-
Already percent-encoded sequences (like %25) are preserved.
135+
Specifically, '[' and ']' cause urlparse to fail with a ValueError because
136+
it interprets them as IPv6 address brackets. This encodes only those characters,
137+
leaving everything else (including '%') untouched.
141138
"""
142-
# Find the scheme delimiter
143139
scheme_end = db_url.find("://")
144140
if scheme_end == -1:
145141
return db_url
146142

147-
scheme = db_url[: scheme_end + 3] # Include "://"
143+
scheme = db_url[: scheme_end + 3]
148144
rest = db_url[scheme_end + 3 :]
149145

150-
# Find the userinfo section (everything before @)
151146
at_pos = rest.find("@")
152147
if at_pos == -1:
153-
# No credentials
154148
return db_url
155149

156150
userinfo = rest[:at_pos]
157151
after_userinfo = rest[at_pos:]
158152

159-
# Split userinfo into username and password
160153
colon_pos = userinfo.find(":")
161154
if colon_pos == -1:
162-
# No password, just username
163-
# Only quote characters that cause parsing issues
164-
username = urlparse.quote(userinfo, safe="%")
155+
username = userinfo.replace("[", "%5B").replace("]", "%5D")
165156
return scheme + username + after_userinfo
166-
else:
167-
username = userinfo[:colon_pos]
168-
password = userinfo[colon_pos + 1 :]
169-
# Quote username and password, but preserve already-encoded sequences
170-
# We keep % as safe so existing percent-encoded chars aren't double-encoded
171-
username_quoted = urlparse.quote(username, safe="%")
172-
password_quoted = urlparse.quote(password, safe="%")
173-
return scheme + username_quoted + ":" + password_quoted + after_userinfo
157+
158+
username = userinfo[:colon_pos]
159+
password = userinfo[colon_pos + 1 :]
160+
username_quoted = username.replace("[", "%5B").replace("]", "%5D")
161+
password_quoted = password.replace("[", "%5B").replace("]", "%5D")
162+
return scheme + username_quoted + ":" + password_quoted + after_userinfo
174163

175164

176165
def expand_db_url(db_url: str, testing: bool = False) -> dict:

0 commit comments

Comments
 (0)