From 1fb554e061e0162e5f70f7a43aaf6ba3ef50692d Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Mon, 9 Mar 2026 04:12:54 +0100 Subject: [PATCH] fix: support bare proxy format (user:pass@host:port) without scheme Normalize bare proxy strings by prepending http:// before parsing when @ is present but :// is absent. Tests added for Python and JS. --- README.md | 3 ++- cloakbrowser/browser.py | 18 ++++++++++--- js/src/geoip.ts | 22 ++++++++++++++++ js/src/playwright.ts | 17 +----------- js/src/proxy.ts | 16 ++++++++++-- js/src/puppeteer.ts | 17 +----------- js/tests/geoip.test.ts | 11 ++++++++ js/tests/proxy.test.ts | 33 ++++++++++++++++++++++++ js/tests/puppeteer.test.ts | 1 + tests/test_proxy.py | 53 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 153 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 1563732..965073e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Stars PyPI Downloads npm Downloads +Docker Pulls


@@ -129,7 +130,7 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details. - **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser. - **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface. - **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed. -- **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path. +- **Works with AI agents and automation frameworks** — drop-in stealth for browser-use, Crawl4AI, agent-browser, Claude computer use, and OpenAI Operator. Also tested with Playwright, Puppeteer, and Selenium — point any Chromium-based framework at the binary path. CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know. diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index ce41fed..b82a41f 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -585,6 +585,11 @@ def _import_async_playwright(backend: str): # --------------------------------------------------------------------------- +def _ensure_proxy_scheme(proxy_url: str) -> str: + """Prepend http:// to schemeless proxy URLs so parsers can extract hostname.""" + return proxy_url if "://" in proxy_url else f"http://{proxy_url}" + + def _maybe_resolve_geoip( geoip: bool, proxy: str | ProxySettings | None, @@ -600,6 +605,7 @@ def _maybe_resolve_geoip( proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy if not proxy_url: return timezone, locale + proxy_url = _ensure_proxy_scheme(proxy_url) geo_tz, geo_locale = resolve_proxy_geo(proxy_url) if timezone is None: timezone = geo_tz @@ -653,12 +659,18 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]: """Parse proxy URL, extracting credentials into separate Playwright fields. Handles: http://user:pass@host:port -> {server: "http://host:port", username: "user", password: "pass"} - Also handles: no credentials, URL-encoded special chars, socks5://, missing port. + Also handles: no credentials, URL-encoded special chars, socks5://, missing port, + and bare proxy strings without a scheme (e.g. 'user:pass@host:port' -> treated as http). """ - parsed = urlparse(proxy) + # Bare format: "user:pass@host:port" — urlparse needs a scheme to extract credentials. + normalized = proxy + if "@" in proxy and "://" not in proxy: + normalized = f"http://{proxy}" + + parsed = urlparse(normalized) if not parsed.username: - return {"server": proxy} + return {"server": proxy} # no creds — return original unchanged # Rebuild server URL without credentials netloc = parsed.hostname or "" diff --git a/js/src/geoip.ts b/js/src/geoip.ts index 7fe3150..9103458 100644 --- a/js/src/geoip.ts +++ b/js/src/geoip.ts @@ -14,6 +14,8 @@ import { createWriteStream } from "node:fs"; import dns from "node:dns/promises"; import net from "node:net"; import { getCacheDir } from "./config.js"; +import type { LaunchOptions } from "./types.js"; +import { ensureProxyScheme } from "./proxy.js"; // P3TERX mirror of MaxMind GeoLite2-City — no license key needed const GEOIP_DB_URL = @@ -260,3 +262,23 @@ function maybeTriggerUpdate(dbPath: string): void { // Fire-and-forget background update downloadGeoipDb(dbPath).catch(() => {}); } + +/** + * Auto-fill timezone/locale from proxy IP when geoip is enabled. + * Shared by the Playwright and Puppeteer wrappers. + */ +export async function maybeResolveGeoip( + options: LaunchOptions +): Promise<{ timezone?: string; locale?: string }> { + if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale }; + if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale }; + + let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server; + if (!proxyUrl) return { timezone: options.timezone, locale: options.locale }; + proxyUrl = ensureProxyScheme(proxyUrl); + const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl); + return { + timezone: options.timezone ?? geoTz ?? undefined, + locale: options.locale ?? geoLocale ?? undefined, + }; +} diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 57e74b4..673c369 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -9,6 +9,7 @@ import { DEFAULT_VIEWPORT } from "./config.js"; import { buildArgs } from "./args.js"; import { ensureBinary } from "./download.js"; import { parseProxyUrl } from "./proxy.js"; +import { maybeResolveGeoip } from "./geoip.js"; /** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */ export function migrateTimezoneId(options: T): T { @@ -193,21 +194,5 @@ export async function launchPersistentContext( // Internal // --------------------------------------------------------------------------- -async function maybeResolveGeoip( - options: LaunchOptions -): Promise<{ timezone?: string; locale?: string }> { - if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale }; - if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale }; - - const { resolveProxyGeo } = await import("./geoip.js"); - const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server; - if (!proxyUrl) return { timezone: options.timezone, locale: options.locale }; - const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl); - return { - timezone: options.timezone ?? geoTz ?? undefined, - locale: options.locale ?? geoLocale ?? undefined, - }; -} - /** @internal Exposed for unit tests only. */ export { buildArgs as _buildArgsForTest } from "./args.js"; diff --git a/js/src/proxy.ts b/js/src/proxy.ts index 1f04b07..9ca348d 100644 --- a/js/src/proxy.ts +++ b/js/src/proxy.ts @@ -8,16 +8,28 @@ export interface ParsedProxy { password?: string; } +/** + * Prepend http:// to schemeless proxy URLs so parsers can extract hostname. + * Used by geoip resolution which only needs a valid hostname, not auth fields. + */ +export function ensureProxyScheme(proxyUrl: string): string { + return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`; +} + /** * Parse a proxy URL, extracting credentials into separate fields. * * Handles: "http://user:pass@host:port" -> { server: "http://host:port", username: "user", password: "pass" } - * Also handles: no credentials, URL-encoded special chars, socks5://, missing port. + * Also handles: no credentials, URL-encoded special chars, socks5://, missing port, + * and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http). */ export function parseProxyUrl(proxy: string): ParsedProxy { let url: URL; + // Bare format: "user:pass@host:port" — new URL() throws without a scheme. + const normalized = + proxy.includes("@") && !proxy.includes("://") ? `http://${proxy}` : proxy; try { - url = new URL(proxy); + url = new URL(normalized); } catch { // Not a parseable URL (e.g. bare "host:port") — pass through as-is return { server: proxy }; diff --git a/js/src/puppeteer.ts b/js/src/puppeteer.ts index 15a596e..9327a95 100644 --- a/js/src/puppeteer.ts +++ b/js/src/puppeteer.ts @@ -8,6 +8,7 @@ import type { LaunchOptions } from "./types.js"; import { buildArgs } from "./args.js"; import { ensureBinary } from "./download.js"; import { parseProxyUrl } from "./proxy.js"; +import { maybeResolveGeoip } from "./geoip.js"; /** * Launch stealth Chromium browser via Puppeteer. @@ -83,19 +84,3 @@ export async function launch(options: LaunchOptions = {}): Promise { // Internal // --------------------------------------------------------------------------- -async function maybeResolveGeoip( - options: LaunchOptions -): Promise<{ timezone?: string; locale?: string }> { - if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale }; - if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale }; - - const { resolveProxyGeo } = await import("./geoip.js"); - const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server; - if (!proxyUrl) return { timezone: options.timezone, locale: options.locale }; - const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl); - return { - timezone: options.timezone ?? geoTz ?? undefined, - locale: options.locale ?? geoLocale ?? undefined, - }; -} - diff --git a/js/tests/geoip.test.ts b/js/tests/geoip.test.ts index 5b4bde3..da58567 100644 --- a/js/tests/geoip.test.ts +++ b/js/tests/geoip.test.ts @@ -25,6 +25,17 @@ describe("resolveProxyIp", () => { it("returns null for empty string", async () => { expect(await resolveProxyIp("")).toBeNull(); }); + + it("returns null for schemeless proxy (shows why normalization is needed)", async () => { + // no scheme — new URL() gives empty hostname for both bare formats + expect(await resolveProxyIp("user:pass@10.50.96.5:8888")).toBeNull(); + expect(await resolveProxyIp("10.50.96.5:8888")).toBeNull(); + }); + + it("extracts IP after normalization (http:// prepended by maybeResolveGeoip)", async () => { + expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe("10.50.96.5"); + expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5"); + }); }); describe("COUNTRY_LOCALE_MAP", () => { diff --git a/js/tests/proxy.test.ts b/js/tests/proxy.test.ts index f4d39d2..2f687d4 100644 --- a/js/tests/proxy.test.ts +++ b/js/tests/proxy.test.ts @@ -82,3 +82,36 @@ describe("proxy dict type", () => { } }); }); + +describe("bare proxy format (user:pass@host:port)", () => { + it("extracts credentials from bare format", () => { + expect(parseProxyUrl("user:pass@proxy:8080")).toEqual({ + server: "http://proxy:8080", + username: "user", + password: "pass", + }); + }); + + it("credentials not in server", () => { + const r = parseProxyUrl("user:pass@proxy1.example.com:5610"); + expect(r.server).not.toContain("user"); + expect(r.server).not.toContain("pass"); + }); + + it("bare username only", () => { + const r = parseProxyUrl("user@proxy:8080"); + expect(r.username).toBe("user"); + expect(r.password).toBeUndefined(); + expect(r.server).toBe("http://proxy:8080"); + }); + + it("bare no port", () => { + const r = parseProxyUrl("user:pass@proxy.example.com"); + expect(r.username).toBe("user"); + expect(r.server).toBe("http://proxy.example.com"); + }); + + it("bare no credentials passes through unchanged", () => { + expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" }); + }); +}); diff --git a/js/tests/puppeteer.test.ts b/js/tests/puppeteer.test.ts index ec3668b..c97a0c6 100644 --- a/js/tests/puppeteer.test.ts +++ b/js/tests/puppeteer.test.ts @@ -13,6 +13,7 @@ vi.mock("../src/download.js", () => ({ vi.mock("../src/geoip.js", () => ({ resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }), + maybeResolveGeoip: vi.fn().mockResolvedValue({}), })); describe("puppeteer launch", () => { diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 8d2781b..17d9a7b 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -98,3 +98,56 @@ class TestMaybeResolveGeoip: tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None) assert tz == "Europe/Berlin" assert locale == "ja-JP" + + @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")) + def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo): + # "user:pass@host:port" must be normalized to http:// before geoip lookup. + tz, locale = _maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None) + mock_geo.assert_called_once_with("http://user:pass@proxy:8080") + assert tz == "America/New_York" + assert locale == "en-US" + + @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")) + def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo): + # "host:port" (no @ and no scheme) must also be normalized. + tz, locale = _maybe_resolve_geoip(True, "proxy:8080", None, None) + mock_geo.assert_called_once_with("http://proxy:8080") + assert tz == "America/New_York" + + +class TestBareProxyFormat: + """_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme).""" + + def test_bare_with_credentials(self): + r = _parse_proxy_url("user:pass@proxy:8080") + assert r["username"] == "user" + assert r["password"] == "pass" + assert r["server"] == "http://proxy:8080" + + def test_bare_credentials_not_in_server(self): + r = _parse_proxy_url("user:pass@proxy1.example.com:5610") + assert "user" not in r["server"] + assert "pass" not in r["server"] + + def test_bare_username_only(self): + r = _parse_proxy_url("user@proxy:8080") + assert r["username"] == "user" + assert "password" not in r + assert r["server"] == "http://proxy:8080" + + def test_bare_no_port(self): + r = _parse_proxy_url("user:pass@proxy.example.com") + assert r["username"] == "user" + assert r["password"] == "pass" + assert r["server"] == "http://proxy.example.com" + + def test_bare_no_credentials_passthrough(self): + # "host:port" without @ — no scheme, no creds — pass through unchanged + r = _parse_proxy_url("proxy:8080") + assert r == {"server": "proxy:8080"} + + def test_build_proxy_kwargs_bare(self): + r = _build_proxy_kwargs("user:pass@proxy:8080") + assert r["proxy"]["username"] == "user" + assert r["proxy"]["password"] == "pass" + assert "user" not in r["proxy"]["server"]