diff --git a/README.md b/README.md index 356a08c..990834a 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,11 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom ### Proof +
+
+
Cloudflare Turnstile — 3 live tests passing in headed mode (macOS)
+
reCAPTCHA v3 score 0.9 — server-side verified (human-level)
diff --git a/cloakbrowser/download.py b/cloakbrowser/download.py
index 2eb0fcf..3137448 100644
--- a/cloakbrowser/download.py
+++ b/cloakbrowser/download.py
@@ -8,7 +8,9 @@ from __future__ import annotations
import logging
import os
+import platform
import stat
+import subprocess
import tarfile
import tempfile
import threading
@@ -168,27 +170,39 @@ def _extract_archive(
dest_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "r:gz") as tar:
- # Security: prevent path traversal and symlink attacks
+ # Security: prevent path traversal
safe_members = []
for member in tar.getmembers():
+ # Allow symlinks — macOS .app bundles require them (Framework layout)
if member.issym() or member.islnk():
- logger.warning("Skipping symlink in archive: %s", member.name)
- continue
- member_path = (dest_dir / member.name).resolve()
- if not str(member_path).startswith(str(dest_dir.resolve())):
- raise RuntimeError(f"Archive contains path traversal: {member.name}")
+ link_target = member.linkname
+ # Reject symlinks that escape the dest dir
+ if os.path.isabs(link_target) or ".." in link_target.split("/"):
+ logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
+ continue
+ else:
+ member_path = (dest_dir / member.name).resolve()
+ if not str(member_path).startswith(str(dest_dir.resolve())):
+ raise RuntimeError(f"Archive contains path traversal: {member.name}")
safe_members.append(member)
tar.extractall(dest_dir, members=safe_members)
# If tar extracted into a single subdirectory, flatten it
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
+ # But never flatten .app bundles — macOS needs the bundle structure intact
_flatten_single_subdir(dest_dir)
# Make binary executable
bp = binary_path or get_binary_path()
if bp.exists():
_make_executable(bp)
+
+ # macOS: remove quarantine/provenance xattrs to prevent Gatekeeper prompts
+ if platform.system() == "Darwin":
+ _remove_quarantine(dest_dir)
+
+ if bp.exists():
logger.info("Binary ready: %s", bp)
@@ -203,6 +217,10 @@ def _flatten_single_subdir(dest_dir: Path) -> None:
entries = list(dest_dir.iterdir())
if len(entries) == 1 and entries[0].is_dir():
subdir = entries[0]
+ # Never flatten .app bundles — macOS needs the bundle structure
+ if subdir.name.endswith(".app"):
+ logger.debug("Keeping .app bundle intact: %s", subdir.name)
+ return
logger.debug("Flattening single subdirectory: %s", subdir.name)
for item in subdir.iterdir():
shutil.move(str(item), str(dest_dir / item.name))
@@ -220,6 +238,19 @@ def _make_executable(path: Path) -> None:
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+def _remove_quarantine(path: Path) -> None:
+ """Remove macOS quarantine/provenance xattrs so Gatekeeper doesn't block the binary."""
+ try:
+ subprocess.run(
+ ["xattr", "-cr", str(path)],
+ capture_output=True,
+ timeout=30,
+ )
+ logger.debug("Removed quarantine attributes from %s", path)
+ except Exception:
+ logger.debug("Failed to remove quarantine attributes", exc_info=True)
+
+
def clear_cache() -> None:
"""Remove all cached binaries. Forces re-download on next launch."""
from .config import get_cache_dir
diff --git a/js/src/download.ts b/js/src/download.ts
index ed9bbdb..0c7c388 100644
--- a/js/src/download.ts
+++ b/js/src/download.ts
@@ -4,6 +4,7 @@
* Mirrors Python cloakbrowser/download.py.
*/
+import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pipeline } from "node:stream/promises";
@@ -266,6 +267,14 @@ async function extractArchive(
const bp = binaryPath || getBinaryPath();
if (fs.existsSync(bp)) {
fs.chmodSync(bp, 0o755);
+ }
+
+ // macOS: remove quarantine/provenance xattrs to prevent Gatekeeper prompts
+ if (process.platform === "darwin") {
+ removeQuarantine(destDir);
+ }
+
+ if (fs.existsSync(bp)) {
console.log(`[cloakbrowser] Binary ready: ${bp}`);
}
}
@@ -278,6 +287,8 @@ function flattenSingleSubdir(destDir: string): void {
const entries = fs.readdirSync(destDir);
if (entries.length === 1) {
const subdir = path.join(destDir, entries[0]!);
+ // Never flatten .app bundles — macOS needs the bundle structure
+ if (entries[0]!.endsWith(".app")) return;
if (fs.statSync(subdir).isDirectory()) {
const children = fs.readdirSync(subdir);
for (const child of children) {
@@ -291,6 +302,15 @@ function flattenSingleSubdir(destDir: string): void {
}
}
+/** Remove macOS quarantine/provenance xattrs so Gatekeeper doesn't block the binary. */
+function removeQuarantine(dirPath: string): void {
+ try {
+ execFileSync("xattr", ["-cr", dirPath], { timeout: 30_000 });
+ } catch {
+ // Non-fatal — user can manually run: xattr -cr ~/.cloakbrowser/
+ }
+}
+
function isExecutable(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK);