Skip to content

Commit fcef667

Browse files
authored
Merge pull request #95 from lsst-sqre/tickets/DM-34097
[DM-34097] Fix database passwords with special characters
2 parents 7a794bb + d0ac92a commit fcef667

File tree

4 files changed

+51
-3
lines changed

4 files changed

+51
-3
lines changed

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ Change log
55
.. Headline template:
66
X.Y.Z (YYYY-MM-DD)
77
8+
3.0.2 (2022-03-25)
9+
==================
10+
11+
- Fix handling of passwords containing special characters such as ``@`` and ``/`` in ``safir.database``.
12+
813
3.0.1 (2022-02-24)
914
==================
1015

src/safir/database.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import time
77
from datetime import datetime, timezone
88
from typing import TYPE_CHECKING, Optional, overload
9-
from urllib.parse import urlparse
9+
from urllib.parse import quote, urlparse
1010

1111
from sqlalchemy import create_engine
1212
from sqlalchemy.exc import OperationalError
@@ -63,12 +63,24 @@ def _build_database_url(
6363
-------
6464
url : `str`
6565
The URL including the password.
66+
67+
Raises
68+
------
69+
ValueError
70+
A password was provided but the connection URL has no username.
6671
"""
6772
if is_async or password:
6873
parsed_url = urlparse(url)
6974
if is_async and parsed_url.scheme == "postgresql":
7075
parsed_url = parsed_url._replace(scheme="postgresql+asyncpg")
7176
if password:
77+
if not parsed_url.username:
78+
raise ValueError(f"No username in database URL {url}")
79+
password = quote(password, safe="")
80+
81+
# The username portion of the parsed URL does not appear to decode
82+
# URL escaping of the username, so we should not quote it again or
83+
# we will get double-quoting.
7284
netloc = f"{parsed_url.username}:{password}@{parsed_url.hostname}"
7385
if parsed_url.port:
7486
netloc = f"{netloc}:{parsed_url.port}"
@@ -138,6 +150,11 @@ def create_database_engine(
138150
engine : `sqlalchemy.ext.asyncio.AsyncEngine`
139151
Newly-created database engine. When done with the engine, the caller
140152
must call ``await engine.dispose()``.
153+
154+
Raises
155+
------
156+
ValueError
157+
A password was provided but the connection URL has no username.
141158
"""
142159
url = _build_database_url(url, password, is_async=True)
143160
if isolation_level:
@@ -244,6 +261,11 @@ def create_sync_session(
244261
session : `sqlalchemy.orm.scoping.scoped_session`
245262
The database session proxy. This manages a separate session per
246263
thread and therefore should be thread-safe.
264+
265+
Raises
266+
------
267+
ValueError
268+
A password was provided but the connection URL has no username.
247269
"""
248270
url = _build_database_url(url, password, is_async=False)
249271
if isolation_level:

tests/database_test.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
from datetime import datetime, timedelta, timezone
7+
from urllib.parse import unquote, urlparse
78

89
import pytest
910
import structlog
@@ -93,6 +94,26 @@ def test_build_database_url() -> None:
9394
)
9495
assert url == "postgresql+asyncpg://foo:otherpass@127.0.0.1:5433/foo"
9596

97+
# Test that the username and password are quoted properly.
98+
url = _build_database_url(
99+
"postgresql://foo%40e.com@127.0.0.1:4444/foo",
100+
"pass@word/with stuff",
101+
is_async=False,
102+
)
103+
assert url == (
104+
"postgresql://foo%40e.com:pass%40word%2Fwith%20stuff@127.0.0.1:4444"
105+
"/foo"
106+
)
107+
parsed_url = urlparse(url)
108+
assert parsed_url.username
109+
assert parsed_url.password
110+
111+
# urlparse does not undo quoting in the components of netloc.
112+
assert unquote(parsed_url.username) == "foo@e.com"
113+
assert unquote(parsed_url.password) == "pass@word/with stuff"
114+
assert parsed_url.hostname == "127.0.0.1"
115+
assert parsed_url.port == 4444
116+
96117

97118
@pytest.mark.asyncio
98119
async def test_create_async_session() -> None:

tox.ini

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ image = postgres:latest
77
ports =
88
5432:5432/tcp
99
environment =
10-
POSTGRES_PASSWORD=INSECURE-PASSWORD
10+
POSTGRES_PASSWORD=INSECURE@%PASSWORD/
1111
POSTGRES_USER=safir
1212
POSTGRES_DB=safir
1313
PGPORT=5432
@@ -37,7 +37,7 @@ commands =
3737
coverage run -m pytest {posargs}
3838
setenv =
3939
TEST_DATABASE_URL = postgresql://safir@127.0.0.1/safir
40-
TEST_DATABASE_PASSWORD = INSECURE-PASSWORD
40+
TEST_DATABASE_PASSWORD = INSECURE@%PASSWORD/
4141

4242
[testenv:coverage-report]
4343
description = Compile coverage from each test run.

0 commit comments

Comments
 (0)