diff --git a/pythonlib/camoufox/ip.py b/pythonlib/camoufox/ip.py index b9045dd..6a64a31 100644 --- a/pythonlib/camoufox/ip.py +++ b/pythonlib/camoufox/ip.py @@ -2,6 +2,7 @@ import re from dataclasses import dataclass from functools import lru_cache from typing import Dict, Optional, Tuple +from urllib.parse import quote import requests @@ -38,10 +39,12 @@ class Proxy: if not schema: schema = 'http' result = f"{schema}://" + # Percent-encode the credentials: a raw `#`, `/`, `?` or `@` breaks the + # URL, and a raw `%XX` is decoded into a different password. if self.username: - result += f"{self.username}" + result += quote(self.username, safe='') if self.password: - result += f":{self.password}" + result += f":{quote(self.password, safe='')}" result += "@" result += url diff --git a/pythonlib/tests/test_proxy_geo.py b/pythonlib/tests/test_proxy_geo.py index 0b8dbfb..2e46a5c 100644 --- a/pythonlib/tests/test_proxy_geo.py +++ b/pythonlib/tests/test_proxy_geo.py @@ -11,6 +11,7 @@ timezone while its traffic went through the proxy: import asyncio from unittest import mock +from urllib.parse import unquote, urlsplit import pytest @@ -75,6 +76,18 @@ def test_lookup_goes_through_the_proxy_with_its_credentials(api, server, expecte assert "203.0.113.7" in context.add_init_script.call_args.args[0] +@pytest.mark.parametrize("api", ["sync", "async"]) +def test_credentials_with_url_delimiters_survive_the_proxy_url(api): + # A raw `#`, `/` or `?` stops the URL parsing, and a raw `%41` would be + # decoded into a different password (#823). + username, password = "us@r name", "p#ss/w?rd:%41" + with mock.patch.object(ip.requests, "get", return_value=_Response(EXIT)) as get: + _new_context(api, {"server": "proxy.example.com:8080", "username": username, "password": password}) + url = urlsplit(get.call_args.kwargs["proxies"]["https"]) + assert (url.hostname, url.port) == ("proxy.example.com", 8080) + assert (unquote(url.username), unquote(url.password)) == (username, password) + + @pytest.mark.parametrize("api", ["sync", "async"]) @pytest.mark.parametrize( "failure", diff --git a/typescript/src/ip.ts b/typescript/src/ip.ts index cffee9a..b9beaae 100644 --- a/typescript/src/ip.ts +++ b/typescript/src/ip.ts @@ -37,10 +37,12 @@ export class ProxyHelper { static asString(proxy: ProxyConfig): string { const { schema, url, port } = ProxyHelper.parseServer(proxy.server); let result = `${schema}://`; + // Percent-encode the credentials: a raw `#`, `/`, `?` or `@` breaks the + // URL, and a raw `%XX` is decoded into a different password. if (proxy.username) { - result += proxy.username; + result += encodeURIComponent(proxy.username); if (proxy.password) { - result += `:${proxy.password}`; + result += `:${encodeURIComponent(proxy.password)}`; } result += "@"; } diff --git a/typescript/tests/proxy-geo.test.ts b/typescript/tests/proxy-geo.test.ts index d58804f..520a5fd 100644 --- a/typescript/tests/proxy-geo.test.ts +++ b/typescript/tests/proxy-geo.test.ts @@ -74,6 +74,22 @@ describe("NewContext proxy lookup", () => { expect(calls.script).toContain("203.0.113.7"); }); + it("credentials with URL delimiters survive the proxy URL", async () => { + // A raw `#`, `/` or `?` stops the URL parsing, and a raw `%41` would be + // decoded into a different password (#823). + const username = "us@r name"; + const password = "p#ss/w?rd:%41"; + const { browser } = fakeBrowser(); + await NewContext(browser, { + os: "linux", + proxy: { server: "proxy.example.com:8080", username, password }, + }); + const url = new URL(impit.proxyUrls[0] as string); + expect([url.hostname, url.port]).toEqual(["proxy.example.com", "8080"]); + expect(decodeURIComponent(url.username)).toBe(username); + expect(decodeURIComponent(url.password)).toBe(password); + }); + it.each([ [ "an unreachable proxy",