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