mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
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:
@@ -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 @@
|
||||
__version__ = "0.1.5"
|
||||
__version__ = "0.1.6"
|
||||
|
||||
+28
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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"}
|
||||
}
|
||||
Reference in New Issue
Block a user