From cf6ab076eb792eebe94fa3ee569109b118b28bcb Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Mon, 9 Nov 2026 17:10:52 -0800 Subject: [PATCH] fix(proxy-extras): accept unencoded DB passwords in the v2 migration lock and redact its errors (#46592) Partial backport of #46592 to stable/1.103.x: only the libpq URL userinfo encoding the index build connects with. Cherry-picked hunk from 1ac9cb2a1dcf17645ee440d23693845ad6e36e2f (main). Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../litellm_proxy_extras/utils.py | 13 +++- .../test_litellm_proxy_extras_utils.py | 72 +++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/litellm-proxy-extras/litellm_proxy_extras/utils.py b/litellm-proxy-extras/litellm_proxy_extras/utils.py index 67570d1de2b..a0b305a53e0 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/utils.py +++ b/litellm-proxy-extras/litellm_proxy_extras/utils.py @@ -708,16 +708,23 @@ class ProxyExtrasDBManager: (connection_limit, pool_timeout, schema, pgbouncer, sslaccept, ...) and translate Prisma's TLS params back, since libpq reads ``sslcert`` as a client certificate where Prisma reads it as the CA.""" - from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse + from urllib.parse import parse_qsl, quote, unquote, urlencode, urlparse, urlunparse parsed: Final = urlparse(url) + netloc: Final = parsed.netloc + userinfo, separator, hostport = netloc.rpartition("@") + user, password_separator, password = userinfo.partition(":") + canonical_userinfo: Final = quote(unquote(user), safe="") + ( + password_separator + quote(unquote(password), safe="") if password_separator else "" + ) + canonical_netloc: Final = f"{canonical_userinfo}{separator}{hostport}" if separator else netloc if not parsed.query: - return url + return url if canonical_netloc == netloc else urlunparse(parsed._replace(netloc=canonical_netloc)) pairs: Final = tuple(parse_qsl(parsed.query)) kept: Final = tuple((k, v) for k, v in pairs if k in LIBPQ_URL_PARAMS) sslaccept: Final = next((v for k, v in pairs if k == "sslaccept"), None) libpq_pairs: Final = ProxyExtrasDBManager._libpq_tls_params(kept, sslaccept) - return urlunparse(parsed._replace(query=urlencode(libpq_pairs, quote_via=quote))) + return urlunparse(parsed._replace(netloc=canonical_netloc, query=urlencode(libpq_pairs, quote_via=quote))) @staticmethod def _libpq_tls_params( diff --git a/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py b/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py index 87b20c1535c..5e54ccd2bfc 100644 --- a/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py +++ b/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py @@ -1006,6 +1006,78 @@ class TestStripPrismaQueryParams: return dict(parse_qsl(urlparse(url).query)) + @pytest.mark.parametrize( + "url,expected_user,expected_password,expected_host,expected_port,expected_sslmode", + ( + ( + "postgresql://u:wr ong'pw9@writer:5433/litellm", + "u", + "wr ong'pw9", + "writer", + "5433", + None, + ), + ( + "postgresql://u:wr ong'pw9@writer:5433/litellm?schema=x&sslmode=require", + "u", + "wr ong'pw9", + "writer", + "5433", + "require", + ), + ( + "postgresql://u:p%40ss%20w@writer:5433/litellm", + "u", + "p@ss w", + "writer", + "5433", + None, + ), + ( + "postgresql://u:p%z@writer:5433/litellm", + "u", + "p%z", + "writer", + "5433", + None, + ), + ( + "postgresql://u:wr ong'pw9@[::1]:5433/litellm", + "u", + "wr ong'pw9", + "::1", + "5433", + None, + ), + ), + ) + def test_userinfo_is_encoded_for_libpq( + self, + url: str, + expected_user: str, + expected_password: str, + expected_host: str, + expected_port: str, + expected_sslmode: str | None, + ) -> None: + from psycopg.conninfo import conninfo_to_dict + + result: Final = ProxyExtrasDBManager._strip_prisma_query_params(url) + conninfo: Final = conninfo_to_dict(result) + + assert conninfo["user"] == expected_user + assert conninfo["password"] == expected_password + assert conninfo["host"] == expected_host + assert conninfo["port"] == expected_port + assert conninfo["dbname"] == "litellm" + assert conninfo.get("sslmode") == expected_sslmode + assert "schema" not in conninfo + + def test_empty_password_userinfo_keeps_its_separator(self) -> None: + url: Final = "postgresql://u:@writer/litellm" + + assert ProxyExtrasDBManager._strip_prisma_query_params(url) == url + def test_prisma_ca_sslcert_becomes_sslrootcert_with_verify_full(self): url = "postgresql://u:p@writer:5432/db?schema=public&sslmode=require&sslcert=/tmp/pinned.pem&sslaccept=strict"