fix: macOS binary download — preserve .app symlinks, remove quarantine xattrs

macOS downloads were broken: symlinks in Chromium.app Framework layout were
skipped and flatten logic removed the .app bundle structure. Now allows safe
symlinks, skips flattening .app dirs, and runs xattr -cr post-extraction to
prevent Gatekeeper prompts. Also adds Turnstile GIF to README proof section.
This commit is contained in:
CloakHQ
2026-02-27 07:32:43 +01:00
parent cee166c2d2
commit 8c76a68cb5
3 changed files with 62 additions and 6 deletions
+5
View File
@@ -106,6 +106,11 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom
### Proof ### Proof
<p align="center">
<img src="https://i.imgur.com/IvB0It7.gif" width="600" alt="Cloudflare Turnstile — 3 Tests Passing (Headed Mode)">
<br><em>Cloudflare Turnstile — 3 live tests passing in headed mode (macOS)</em>
</p>
<p align="center"> <p align="center">
<img src="https://i.imgur.com/hvIQyMv.png" width="600" alt="reCAPTCHA v3 — Score 0.9"> <img src="https://i.imgur.com/hvIQyMv.png" width="600" alt="reCAPTCHA v3 — Score 0.9">
<br><em>reCAPTCHA v3 score 0.9 — server-side verified (human-level)</em> <br><em>reCAPTCHA v3 score 0.9 — server-side verified (human-level)</em>
+37 -6
View File
@@ -8,7 +8,9 @@ from __future__ import annotations
import logging import logging
import os import os
import platform
import stat import stat
import subprocess
import tarfile import tarfile
import tempfile import tempfile
import threading import threading
@@ -168,27 +170,39 @@ def _extract_archive(
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "r:gz") as tar: with tarfile.open(archive_path, "r:gz") as tar:
# Security: prevent path traversal and symlink attacks # Security: prevent path traversal
safe_members = [] safe_members = []
for member in tar.getmembers(): for member in tar.getmembers():
# Allow symlinks — macOS .app bundles require them (Framework layout)
if member.issym() or member.islnk(): if member.issym() or member.islnk():
logger.warning("Skipping symlink in archive: %s", member.name) link_target = member.linkname
continue # Reject symlinks that escape the dest dir
member_path = (dest_dir / member.name).resolve() if os.path.isabs(link_target) or ".." in link_target.split("/"):
if not str(member_path).startswith(str(dest_dir.resolve())): logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
raise RuntimeError(f"Archive contains path traversal: {member.name}") 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) safe_members.append(member)
tar.extractall(dest_dir, members=safe_members) tar.extractall(dest_dir, members=safe_members)
# If tar extracted into a single subdirectory, flatten it # If tar extracted into a single subdirectory, flatten it
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome) # (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) _flatten_single_subdir(dest_dir)
# Make binary executable # Make binary executable
bp = binary_path or get_binary_path() bp = binary_path or get_binary_path()
if bp.exists(): if bp.exists():
_make_executable(bp) _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) logger.info("Binary ready: %s", bp)
@@ -203,6 +217,10 @@ def _flatten_single_subdir(dest_dir: Path) -> None:
entries = list(dest_dir.iterdir()) entries = list(dest_dir.iterdir())
if len(entries) == 1 and entries[0].is_dir(): if len(entries) == 1 and entries[0].is_dir():
subdir = entries[0] 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) logger.debug("Flattening single subdirectory: %s", subdir.name)
for item in subdir.iterdir(): for item in subdir.iterdir():
shutil.move(str(item), str(dest_dir / item.name)) 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) 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: def clear_cache() -> None:
"""Remove all cached binaries. Forces re-download on next launch.""" """Remove all cached binaries. Forces re-download on next launch."""
from .config import get_cache_dir from .config import get_cache_dir
+20
View File
@@ -4,6 +4,7 @@
* Mirrors Python cloakbrowser/download.py. * Mirrors Python cloakbrowser/download.py.
*/ */
import { execFileSync } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
@@ -266,6 +267,14 @@ async function extractArchive(
const bp = binaryPath || getBinaryPath(); const bp = binaryPath || getBinaryPath();
if (fs.existsSync(bp)) { if (fs.existsSync(bp)) {
fs.chmodSync(bp, 0o755); 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}`); console.log(`[cloakbrowser] Binary ready: ${bp}`);
} }
} }
@@ -278,6 +287,8 @@ function flattenSingleSubdir(destDir: string): void {
const entries = fs.readdirSync(destDir); const entries = fs.readdirSync(destDir);
if (entries.length === 1) { if (entries.length === 1) {
const subdir = path.join(destDir, entries[0]!); 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()) { if (fs.statSync(subdir).isDirectory()) {
const children = fs.readdirSync(subdir); const children = fs.readdirSync(subdir);
for (const child of children) { 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 { function isExecutable(filePath: string): boolean {
try { try {
fs.accessSync(filePath, fs.constants.X_OK); fs.accessSync(filePath, fs.constants.X_OK);