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)
|
# Private docs (launch posts, strategy)
|
||||||
docs/
|
docs/
|
||||||
|
|
||||||
|
# Internal test infrastructure (Docker, VPS-specific)
|
||||||
|
test-infra/
|
||||||
|
|
||||||
# Release scripts
|
# Release scripts
|
||||||
publish.sh
|
publish.sh
|
||||||
deploy.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
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import unquote, urlparse, urlunparse
|
||||||
|
|
||||||
from .config import get_default_stealth_args
|
from .config import get_default_stealth_args
|
||||||
from .download import ensure_binary
|
from .download import ensure_binary
|
||||||
@@ -217,8 +218,34 @@ def _build_args(stealth_args: bool, extra_args: list[str] | None) -> list[str]:
|
|||||||
return result
|
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]:
|
def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]:
|
||||||
"""Build proxy kwargs for Playwright launch."""
|
"""Build proxy kwargs for Playwright launch."""
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
return {}
|
return {}
|
||||||
return {"proxy": {"server": proxy}}
|
return {"proxy": _parse_proxy_url(proxy)}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"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.",
|
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Browser, BrowserContext } from "playwright-core";
|
|||||||
import type { LaunchOptions, LaunchContextOptions } from "./types.js";
|
import type { LaunchOptions, LaunchContextOptions } from "./types.js";
|
||||||
import { getDefaultStealthArgs } from "./config.js";
|
import { getDefaultStealthArgs } from "./config.js";
|
||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
|
import { parseProxyUrl } from "./proxy.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch stealth Chromium browser via Playwright.
|
* Launch stealth Chromium browser via Playwright.
|
||||||
@@ -32,7 +33,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
headless: options.headless ?? true,
|
headless: options.headless ?? true,
|
||||||
args,
|
args,
|
||||||
ignoreDefaultArgs: ["--enable-automation"],
|
ignoreDefaultArgs: ["--enable-automation"],
|
||||||
...(options.proxy ? { proxy: { server: options.proxy } } : {}),
|
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}),
|
||||||
...options.launchOptions,
|
...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 type { LaunchOptions } from "./types.js";
|
||||||
import { getDefaultStealthArgs } from "./config.js";
|
import { getDefaultStealthArgs } from "./config.js";
|
||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
|
import { parseProxyUrl } from "./proxy.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch stealth Chromium browser via Puppeteer.
|
* 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 binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||||
const args = buildArgs(options);
|
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) {
|
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({
|
const browser = await puppeteer.default.launch({
|
||||||
@@ -40,6 +48,17 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
...options.launchOptions,
|
...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;
|
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