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 @@
+
@@ -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"]