fix: support proxy authentication credentials in URL (closes #4)

Parse user:pass from proxy URLs into separate Playwright username/password
fields. Puppeteer wrapper strips credentials from --proxy-server and
auto-calls page.authenticate(). Bump Python 0.1.6, JS 0.1.3.
This commit is contained in:
CloakHQ
2026-02-24 19:00:12 +01:00
parent 4e809b9678
commit 4d96db1448
9 changed files with 197 additions and 6 deletions
+3
View File
@@ -50,6 +50,9 @@ AGENTS.md
# Private docs (launch posts, strategy)
docs/
# Internal test infrastructure (Docker, VPS-specific)
test-infra/
# Release scripts
publish.sh
deploy.sh
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.5"
__version__ = "0.1.6"
+28 -1
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import logging
from typing import Any
from urllib.parse import unquote, urlparse, urlunparse
from .config import get_default_stealth_args
from .download import ensure_binary
@@ -217,8 +218,34 @@ def _build_args(stealth_args: bool, extra_args: list[str] | None) -> list[str]:
return result
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.
"""
parsed = urlparse(proxy)
if not parsed.username:
return {"server": proxy}
# Rebuild server URL without credentials
netloc = parsed.hostname or ""
if parsed.port:
netloc += f":{parsed.port}"
server = urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
result: dict[str, Any] = {"server": server}
result["username"] = unquote(parsed.username)
if parsed.password:
result["password"] = unquote(parsed.password)
return result
def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]:
"""Build proxy kwargs for Playwright launch."""
if proxy is None:
return {}
return {"proxy": {"server": proxy}}
return {"proxy": _parse_proxy_url(proxy)}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.1.2",
"version": "0.1.3",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
+2 -1
View File
@@ -7,6 +7,7 @@ import type { Browser, BrowserContext } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
/**
* Launch stealth Chromium browser via Playwright.
@@ -32,7 +33,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy ? { proxy: { server: options.proxy } } : {}),
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
...options.launchOptions,
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
*/
export interface ParsedProxy {
server: string;
username?: string;
password?: string;
}
/**
* 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.
*/
export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL;
try {
url = new URL(proxy);
} catch {
// Not a parseable URL (e.g. bare "host:port") — pass through as-is
return { server: proxy };
}
if (!url.username) {
return { server: proxy };
}
// Rebuild server URL without credentials
const server = `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ""}`;
const result: ParsedProxy = {
server,
username: decodeURIComponent(url.username),
};
if (url.password) {
result.password = decodeURIComponent(url.password);
}
return result;
}
+21 -2
View File
@@ -7,6 +7,7 @@ import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
/**
* Launch stealth Chromium browser via Puppeteer.
@@ -27,9 +28,16 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const args = buildArgs(options);
// Puppeteer handles proxy via CLI args, not a separate option
// Puppeteer handles proxy via CLI args, not a separate option.
// Chromium's --proxy-server does NOT support inline credentials,
// so we strip them and use page.authenticate() instead.
let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) {
args.push(`--proxy-server=${options.proxy}`);
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
if (username) {
proxyAuth = { username, password: password || "" };
}
}
const browser = await puppeteer.default.launch({
@@ -40,6 +48,17 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
...options.launchOptions,
});
// Monkey-patch newPage() to auto-authenticate proxy credentials
if (proxyAuth) {
const origNewPage = browser.newPage.bind(browser);
const auth = proxyAuth;
browser.newPage = async (...pageArgs: Parameters<typeof origNewPage>) => {
const page = await origNewPage(...pageArgs);
await page.authenticate(auth);
return page;
};
}
return browser;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { parseProxyUrl } from "../src/proxy.js";
describe("parseProxyUrl", () => {
it("passes through URL without credentials", () => {
expect(parseProxyUrl("http://proxy:8080")).toEqual({
server: "http://proxy:8080",
});
});
it("extracts credentials from URL", () => {
expect(parseProxyUrl("http://user:pass@proxy:8080")).toEqual({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
});
it("decodes URL-encoded special chars", () => {
const result = parseProxyUrl("http://user:p%40ss%3Aword@proxy:8080");
expect(result.password).toBe("p@ss:word");
expect(result.username).toBe("user");
expect(result.server).toBe("http://proxy:8080");
});
it("handles socks5 protocol", () => {
const result = parseProxyUrl("socks5://user:pass@proxy:1080");
expect(result.server).toBe("socks5://proxy:1080");
expect(result.username).toBe("user");
expect(result.password).toBe("pass");
});
it("handles URL without port", () => {
const result = parseProxyUrl("http://user:pass@proxy");
expect(result.server).toBe("http://proxy");
expect(result.username).toBe("user");
});
it("handles username only (no password)", () => {
const result = parseProxyUrl("http://user@proxy:8080");
expect(result.server).toBe("http://proxy:8080");
expect(result.username).toBe("user");
expect(result.password).toBeUndefined();
});
it("passes through unparseable string", () => {
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
});
});
+50
View File
@@ -0,0 +1,50 @@
"""Tests for proxy URL parsing and credential extraction."""
from cloakbrowser.browser import _build_proxy_kwargs, _parse_proxy_url
class TestParseProxyUrl:
def test_no_credentials(self):
assert _parse_proxy_url("http://proxy:8080") == {"server": "http://proxy:8080"}
def test_with_credentials(self):
result = _parse_proxy_url("http://user:pass@proxy:8080")
assert result == {"server": "http://proxy:8080", "username": "user", "password": "pass"}
def test_url_encoded_password(self):
result = _parse_proxy_url("http://user:p%40ss%3Aword@proxy:8080")
assert result["password"] == "p@ss:word"
assert result["username"] == "user"
assert result["server"] == "http://proxy:8080"
def test_socks5(self):
result = _parse_proxy_url("socks5://user:pass@proxy:1080")
assert result["server"] == "socks5://proxy:1080"
assert result["username"] == "user"
assert result["password"] == "pass"
def test_no_port(self):
result = _parse_proxy_url("http://user:pass@proxy")
assert result["server"] == "http://proxy"
assert result["username"] == "user"
def test_username_only(self):
result = _parse_proxy_url("http://user@proxy:8080")
assert result["server"] == "http://proxy:8080"
assert result["username"] == "user"
assert "password" not in result
class TestBuildProxyKwargs:
def test_none(self):
assert _build_proxy_kwargs(None) == {}
def test_simple_proxy(self):
result = _build_proxy_kwargs("http://proxy:8080")
assert result == {"proxy": {"server": "http://proxy:8080"}}
def test_proxy_with_auth(self):
result = _build_proxy_kwargs("http://user:pass@proxy:8080")
assert result == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
}