mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
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.
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
|
||||
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
|
||||
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
|
||||
<a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
@@ -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.
|
||||
|
||||
|
||||
+15
-3
@@ -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 ""
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-16
@@ -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<T extends { timezone?: string; timezoneId?: string }>(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";
|
||||
|
||||
+14
-2
@@ -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 };
|
||||
|
||||
+1
-16
@@ -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<Browser> {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user