mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-10-22 00:22:17 -05:00
Route authenticated HTTP/HTTPS proxies through the browser's native proxy authentication only on binaries that support it, resolved per platform and binary version via a capability gate (sibling to the existing viewport and window-geometry gates). Older binaries, including the free macOS and ARM builds, fall back to the standard Playwright proxy path instead of emitting credentials the binary cannot parse, so authenticated proxies keep working on macOS and ARM instead of silently failing. Applied across the Python, JavaScript, Puppeteer, and .NET wrappers.
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
/**
|
|
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
|
|
*/
|
|
|
|
import { binarySupportsHttpProxyInlineAuth } from "./config.js";
|
|
|
|
export interface ParsedProxy {
|
|
server: string;
|
|
username?: string;
|
|
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 authenticated proxy URLs by extracting credentials into separate
|
|
* fields. Also handles: no credentials, URL-encoded special chars, socks5://,
|
|
* missing port, and bare proxy strings without a scheme.
|
|
*/
|
|
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
|
|
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };
|
|
|
|
/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
|
|
export interface ProxyConfig {
|
|
/** Playwright proxy option (for HTTP proxies). */
|
|
proxyOption?: ParsedProxy;
|
|
/** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
|
|
proxyArgs: string[];
|
|
}
|
|
|
|
/**
|
|
* Check if a proxy uses the SOCKS5 protocol.
|
|
*/
|
|
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
|
|
if (!proxy) return false;
|
|
const url = typeof proxy === "string" ? proxy : proxy.server;
|
|
return /^socks5h?:\/\//i.test(url);
|
|
}
|
|
|
|
/**
|
|
* Build a SOCKS URL from already-percent-encoded credentials and a host suffix.
|
|
*
|
|
* `encPass === null` means no password (no colon in userinfo). Empty string
|
|
* means present-but-empty (colon preserved).
|
|
*/
|
|
function assembleSocksUrl(
|
|
scheme: string,
|
|
encUser: string,
|
|
encPass: string | null,
|
|
hostAndRest: string,
|
|
): string {
|
|
let userinfo: string;
|
|
if (encPass !== null) {
|
|
userinfo = `${encUser}:${encPass}@`;
|
|
} else if (encUser) {
|
|
userinfo = `${encUser}@`;
|
|
} else {
|
|
userinfo = "";
|
|
}
|
|
return `${scheme}://${userinfo}${hostAndRest}`;
|
|
}
|
|
|
|
/**
|
|
* Lenient percent-decode that handles malformed escapes gracefully, matching
|
|
* Python's ``urllib.parse.unquote``: valid ``%XX`` sequences are decoded,
|
|
* bare ``%`` not followed by two hex digits is left as a literal ``%``.
|
|
*/
|
|
function lenientDecodeURIComponent(s: string): string {
|
|
return s.replace(/%([0-9A-Fa-f]{2})|%/g, (_match, hex) =>
|
|
hex ? String.fromCharCode(parseInt(hex, 16)) : "%",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
|
|
*/
|
|
export function reconstructSocksUrl(proxy: ProxyDict): string {
|
|
const url = new URL(proxy.server);
|
|
if (proxy.username) {
|
|
url.username = encodeURIComponent(proxy.username);
|
|
if (proxy.password) url.password = encodeURIComponent(proxy.password);
|
|
}
|
|
return url.href.replace(/\/$/, "");
|
|
}
|
|
|
|
/**
|
|
* Re-encode credentials in a SOCKS5 URL string so Chromium's parser doesn't
|
|
* truncate them at special chars like '='. Idempotent: pre-encoded input stays
|
|
* the same (decoded then re-encoded).
|
|
*
|
|
* Parsing is done manually rather than via `new URL` + setters, because WHATWG
|
|
* URL's username/password setters re-encode `%` on assignment, causing
|
|
* double-encoding when we round-trip decode-then-encode.
|
|
*
|
|
* On any unexpected failure, logs a warning and returns the original string
|
|
* so Chromium's own error handling can surface the real problem.
|
|
*/
|
|
export function normalizeSocksStringUrl(urlStr: string): string {
|
|
// Split userinfo from host at the LAST '@' (RFC 3986), so a raw '@' inside
|
|
// a password like `socks5://user:p@ss@host:1080` parses correctly. Matches
|
|
// Python urlparse's rpartition('@') behavior.
|
|
const schemeMatch = urlStr.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
|
|
if (!schemeMatch) return urlStr;
|
|
const [, scheme, rest] = schemeMatch;
|
|
const hostStart = rest.search(/[/?#]/);
|
|
const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
|
|
const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
|
|
const atIdx = authority.lastIndexOf("@");
|
|
if (atIdx === -1) return urlStr; // no creds
|
|
const userinfo = authority.slice(0, atIdx);
|
|
const hostPart = authority.slice(atIdx + 1);
|
|
// Validate port (matches Python's urlparse().port ValueError guard).
|
|
// Extract port after last ':' — but skip IPv6 brackets (e.g. [::1]:1080).
|
|
const bracketEnd = hostPart.lastIndexOf("]");
|
|
const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
|
|
if (portColonIdx !== -1) {
|
|
const portStr = hostPart.slice(portColonIdx + 1);
|
|
if (portStr && !/^\d+$/.test(portStr)) {
|
|
console.warn(`[cloakbrowser] Malformed SOCKS5 proxy URL, passing through unchanged: invalid port`);
|
|
return urlStr;
|
|
}
|
|
}
|
|
const hostAndRest = hostPart + suffix;
|
|
const colonIdx = userinfo.indexOf(":");
|
|
const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
|
|
const hasPassword = colonIdx !== -1;
|
|
const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
|
|
try {
|
|
const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
|
|
const encPass = hasPassword
|
|
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
|
|
: null;
|
|
const normalized = assembleSocksUrl(scheme, encUser, encPass, hostAndRest);
|
|
// Compare credentials, not the full URL: keeps the log condition focused
|
|
// on real encoding work, not cosmetic differences (parity with the Python
|
|
// implementation, which has to skip urlparse's hostname lowercasing).
|
|
const credsChanged = encUser !== rawUserEnc
|
|
|| (hasPassword ? encPass !== rawPassEnc : false);
|
|
if (credsChanged) {
|
|
console.info(
|
|
"[cloakbrowser] Auto URL-encoded SOCKS5 proxy credentials (special " +
|
|
"characters detected). Pre-encode the URL to suppress this notice.",
|
|
);
|
|
}
|
|
return normalized;
|
|
} catch (e) {
|
|
console.warn(`[cloakbrowser] Could not normalize SOCKS5 proxy URL, passing through unchanged: ${(e as Error).message}`);
|
|
return urlStr;
|
|
}
|
|
}
|
|
|
|
function hasCredentials(proxy: string | ProxyDict): boolean {
|
|
if (typeof proxy === "string") return proxy.includes("@");
|
|
return !!proxy.username;
|
|
}
|
|
|
|
/**
|
|
* Reconstruct an HTTP(S) proxy URL with inline credentials from a proxy dict.
|
|
*/
|
|
export function reconstructHttpUrl(proxy: ProxyDict): string {
|
|
if (!proxy.username) return proxy.server;
|
|
const url = new URL(ensureProxyScheme(proxy.server));
|
|
url.username = encodeURIComponent(proxy.username);
|
|
if (proxy.password) url.password = encodeURIComponent(proxy.password);
|
|
return url.href.replace(/\/$/, "");
|
|
}
|
|
|
|
/**
|
|
* Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
|
|
* Same pattern as normalizeSocksStringUrl.
|
|
*/
|
|
export function normalizeHttpStringUrl(urlStr: string): string {
|
|
const normalized = urlStr.includes("://") ? urlStr : `http://${urlStr}`;
|
|
const schemeMatch = normalized.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
|
|
if (!schemeMatch) return normalized;
|
|
const [, scheme, rest] = schemeMatch;
|
|
const hostStart = rest.search(/[/?#]/);
|
|
const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
|
|
const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
|
|
const atIdx = authority.lastIndexOf("@");
|
|
if (atIdx === -1) return normalized;
|
|
const userinfo = authority.slice(0, atIdx);
|
|
const hostPart = authority.slice(atIdx + 1);
|
|
const bracketEnd = hostPart.lastIndexOf("]");
|
|
const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
|
|
if (portColonIdx !== -1) {
|
|
const portStr = hostPart.slice(portColonIdx + 1);
|
|
if (portStr && !/^\d+$/.test(portStr)) {
|
|
console.warn(`[cloakbrowser] Malformed HTTP proxy URL, passing through unchanged: invalid port`);
|
|
return normalized;
|
|
}
|
|
}
|
|
const hostAndRest = hostPart + suffix;
|
|
const colonIdx = userinfo.indexOf(":");
|
|
const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
|
|
const hasPassword = colonIdx !== -1;
|
|
const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
|
|
try {
|
|
const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
|
|
const encPass = hasPassword
|
|
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
|
|
: null;
|
|
let userinfoPart: string;
|
|
if (encPass !== null) {
|
|
userinfoPart = `${encUser}:${encPass}@`;
|
|
} else if (encUser) {
|
|
userinfoPart = `${encUser}@`;
|
|
} else {
|
|
userinfoPart = "";
|
|
}
|
|
const result = `${scheme}://${userinfoPart}${hostAndRest}`;
|
|
const credsChanged = encUser !== rawUserEnc
|
|
|| (hasPassword ? encPass !== rawPassEnc : false);
|
|
if (credsChanged) {
|
|
console.info(
|
|
"[cloakbrowser] Auto URL-encoded HTTP proxy credentials (special " +
|
|
"characters detected). Pre-encode the URL to suppress this notice.",
|
|
);
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
console.warn(`[cloakbrowser] Could not normalize HTTP proxy URL, passing through unchanged: ${(e as Error).message}`);
|
|
return normalized;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve proxy into Playwright option and/or Chrome args.
|
|
*
|
|
* Proxies with credentials (SOCKS5 or HTTP/HTTPS on supported platforms) are
|
|
* passed via Chrome's --proxy-server flag with inline credentials, bypassing
|
|
* Playwright's CDP auth interceptor which breaks on some proxies (#182).
|
|
*/
|
|
export function resolveProxyConfig(
|
|
proxy: string | ProxyDict | undefined,
|
|
browserVersion?: string,
|
|
licenseKey?: string
|
|
): ProxyConfig {
|
|
if (!proxy) return { proxyArgs: [] };
|
|
|
|
if (isSocksProxy(proxy)) {
|
|
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
|
|
if (typeof proxy === "string") {
|
|
// Re-encode creds to work around Chromium parser truncating passwords
|
|
// at '=' and other special chars (#157).
|
|
return { proxyArgs: [`--proxy-server=${normalizeSocksStringUrl(proxy)}`] };
|
|
}
|
|
const socksUrl = reconstructSocksUrl(proxy);
|
|
const args = [`--proxy-server=${socksUrl}`];
|
|
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
|
|
return { proxyArgs: args };
|
|
}
|
|
|
|
// HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth:
|
|
// use Chrome's native proxy authentication path instead of Playwright's CDP
|
|
// auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse
|
|
// inline credentials, so they fall through to the Playwright proxy dict below.
|
|
if (hasCredentials(proxy) && binarySupportsHttpProxyInlineAuth(licenseKey, browserVersion)) {
|
|
if (typeof proxy === "string") {
|
|
return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] };
|
|
}
|
|
const httpUrl = reconstructHttpUrl(proxy);
|
|
const args = [`--proxy-server=${httpUrl}`];
|
|
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
|
|
return { proxyArgs: args };
|
|
}
|
|
|
|
// HTTP/HTTPS without credentials (or unsupported platform): use Playwright's proxy dict
|
|
if (typeof proxy === "string") {
|
|
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
|
|
}
|
|
return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
|
|
}
|
|
|
|
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(normalized);
|
|
} 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;
|
|
}
|