Harden open wrapper for Bash 3 and IDN host parity

This commit is contained in:
Lawrence Chen 2026-02-22 18:38:37 -08:00
parent 0046b674aa
commit 3afa345f3a
2 changed files with 176 additions and 18 deletions

View file

@ -8,6 +8,7 @@
SYSTEM_OPEN_BIN="${CMUX_OPEN_WRAPPER_SYSTEM_OPEN:-/usr/bin/open}"
DEFAULTS_BIN="${CMUX_OPEN_WRAPPER_DEFAULTS:-/usr/bin/defaults}"
PYTHON3_BIN="${CMUX_OPEN_WRAPPER_PYTHON3:-}"
if [[ ! -x "$SYSTEM_OPEN_BIN" ]]; then
SYSTEM_OPEN_BIN="/usr/bin/open"
@ -17,6 +18,14 @@ if [[ ! -x "$DEFAULTS_BIN" ]]; then
DEFAULTS_BIN="/usr/bin/defaults"
fi
if [[ -n "$PYTHON3_BIN" ]]; then
if [[ ! -x "$PYTHON3_BIN" ]]; then
PYTHON3_BIN=""
fi
elif command -v python3 >/dev/null 2>&1; then
PYTHON3_BIN="$(command -v python3)"
fi
settings_domain="${CMUX_BUNDLE_ID:-}"
whitelist_raw=""
whitelist_patterns=()
@ -32,10 +41,74 @@ trim() {
printf '%s' "$value"
}
to_lower_ascii() {
# Bash 3.2-compatible lowercase conversion.
LC_ALL=C printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
normalize_boolean() {
to_lower_ascii "$(trim "$1")"
}
is_false_setting() {
local normalized
normalized="$(normalize_boolean "$1")"
case "$normalized" in
0|false|no|off)
return 0
;;
esac
return 1
}
canonicalize_idn_host() {
local value="$1"
[[ -z "$PYTHON3_BIN" ]] && {
printf '%s' "$value"
return 0
}
local canonicalized
canonicalized="$("$PYTHON3_BIN" - "$value" <<'PY' 2>/dev/null || true
import sys
host = sys.argv[1].strip().rstrip(".")
if not host:
raise SystemExit(1)
labels = host.split(".")
if any(not label for label in labels):
raise SystemExit(1)
try:
canonical = ".".join(label.encode("idna").decode("ascii") for label in labels)
except Exception:
raise SystemExit(1)
sys.stdout.write(canonical.lower())
PY
)"
if [[ -n "$canonicalized" ]]; then
printf '%s' "$canonicalized"
return 0
fi
printf '%s' "$value"
}
is_http_url() {
local value="$1"
case "$value" in
[Hh][Tt][Tt][Pp]://*|[Hh][Tt][Tt][Pp][Ss]://*)
return 0
;;
esac
return 1
}
normalize_host() {
local value
value="$(trim "$1")"
value="${value,,}"
value="$(to_lower_ascii "$value")"
[[ -z "$value" ]] && return 1
if [[ "$value" == *"://"* ]]; then
@ -68,13 +141,14 @@ normalize_host() {
done
[[ -z "$value" ]] && return 1
value="$(canonicalize_idn_host "$value")"
printf '%s' "$value"
}
normalize_whitelist_pattern() {
local value
value="$(trim "$1")"
value="${value,,}"
value="$(to_lower_ascii "$value")"
[[ -z "$value" ]] && return 1
if [[ "$value" == \*.* ]]; then
@ -152,13 +226,14 @@ for arg in "$@"; do
passthrough=true
break
;;
http://*|https://*)
urls+=("$arg")
;;
*)
# Non-URL, non-flag argument (file path, etc.) → pass through all
passthrough=true
break
if is_http_url "$arg"; then
urls+=("$arg")
else
# Non-URL, non-flag argument (file path, etc.) → pass through all
passthrough=true
break
fi
;;
esac
done
@ -174,11 +249,9 @@ if [[ -n "$settings_domain" ]]; then
# Backward compatibility for installs that predate the dedicated open-wrapper toggle.
open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)"
fi
case "${open_in_cmux,,}" in
0|false|no)
system_open "$@"
;;
esac
if is_false_setting "$open_in_cmux"; then
system_open "$@"
fi
whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)"
if [[ -n "$whitelist_raw" ]]; then

View file

@ -65,21 +65,21 @@ fi
key="${3:-}"
case "$key" in
browserInterceptTerminalOpenCommandInCmuxBrowser)
if [[ -v FAKE_DEFAULTS_INTERCEPT_OPEN ]]; then
if [[ "${FAKE_DEFAULTS_INTERCEPT_OPEN+x}" == "x" ]]; then
printf '%s\\n' "$FAKE_DEFAULTS_INTERCEPT_OPEN"
exit 0
fi
exit 1
;;
browserOpenTerminalLinksInCmuxBrowser)
if [[ -v FAKE_DEFAULTS_LEGACY_OPEN ]]; then
if [[ "${FAKE_DEFAULTS_LEGACY_OPEN+x}" == "x" ]]; then
printf '%s\\n' "$FAKE_DEFAULTS_LEGACY_OPEN"
exit 0
fi
exit 1
;;
browserHostWhitelist)
if [[ -v FAKE_DEFAULTS_WHITELIST ]]; then
if [[ "${FAKE_DEFAULTS_WHITELIST+x}" == "x" ]]; then
printf '%s' "$FAKE_DEFAULTS_WHITELIST"
exit 0
fi
@ -97,7 +97,10 @@ esac
"""#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> "$FAKE_CMUX_LOG"
url="${*: -1}"
url=""
for arg in "$@"; do
url="$arg"
done
if [[ -n "${FAKE_CMUX_FAIL_URLS:-}" ]]; then
IFS=',' read -r -a failures <<< "$FAKE_CMUX_FAIL_URLS"
for fail_url in "${failures[@]}"; do
@ -139,7 +142,7 @@ exit 0
env.pop("FAKE_CMUX_FAIL_URLS", None)
result = subprocess.run(
[str(wrapper), *args],
["/bin/bash", str(wrapper), *args],
env=env,
capture_output=True,
text=True,
@ -166,6 +169,26 @@ def test_toggle_disabled_passthrough(failures: list[str]) -> None:
expect(open_log == [url], f"toggle off: expected system open [{url}], got {open_log}", failures)
def test_toggle_disabled_case_insensitive_passthrough(failures: list[str]) -> None:
url = "https://example.com"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting=" FaLsE ",
whitelist="",
)
expect(code == 0, f"toggle off (case-insensitive): wrapper exited {code}: {stderr}", failures)
expect(
cmux_log == [],
f"toggle off (case-insensitive): cmux should not be called, got {cmux_log}",
failures,
)
expect(
open_log == [url],
f"toggle off (case-insensitive): expected system open [{url}], got {open_log}",
failures,
)
def test_whitelist_miss_passthrough(failures: list[str]) -> None:
url = "https://example.com"
open_log, cmux_log, code, stderr = run_wrapper(
@ -226,13 +249,75 @@ def test_legacy_toggle_fallback_passthrough(failures: list[str]) -> None:
expect(open_log == [url], f"legacy fallback: expected system open [{url}], got {open_log}", failures)
def test_legacy_toggle_fallback_case_insensitive_passthrough(failures: list[str]) -> None:
url = "https://example.com"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting=None,
legacy_open_setting=" Off ",
whitelist="",
)
expect(code == 0, f"legacy fallback (case-insensitive): wrapper exited {code}: {stderr}", failures)
expect(
cmux_log == [],
f"legacy fallback (case-insensitive): cmux should not be called, got {cmux_log}",
failures,
)
expect(
open_log == [url],
f"legacy fallback (case-insensitive): expected system open [{url}], got {open_log}",
failures,
)
def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None:
url = "HTTPS://api.example.com/path?q=1"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="*.example.com",
)
expect(code == 0, f"uppercase scheme: wrapper exited {code}: {stderr}", failures)
expect(open_log == [], f"uppercase scheme: system open should not be called, got {open_log}", failures)
expect(cmux_log == [f"browser open {url}"], f"uppercase scheme: unexpected cmux log {cmux_log}", failures)
def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None:
url = "https://xn--bcher-kva.example/path"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="bücher.example",
)
expect(code == 0, f"unicode whitelist: wrapper exited {code}: {stderr}", failures)
expect(open_log == [], f"unicode whitelist: system open should not be called, got {open_log}", failures)
expect(cmux_log == [f"browser open {url}"], f"unicode whitelist: unexpected cmux log {cmux_log}", failures)
def test_punycode_whitelist_matches_unicode_url(failures: list[str]) -> None:
url = "https://bücher.example/path"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="xn--bcher-kva.example",
)
expect(code == 0, f"punycode whitelist: wrapper exited {code}: {stderr}", failures)
expect(open_log == [], f"punycode whitelist: system open should not be called, got {open_log}", failures)
expect(cmux_log == [f"browser open {url}"], f"punycode whitelist: unexpected cmux log {cmux_log}", failures)
def main() -> int:
failures: list[str] = []
test_toggle_disabled_passthrough(failures)
test_toggle_disabled_case_insensitive_passthrough(failures)
test_whitelist_miss_passthrough(failures)
test_whitelist_match_routes_to_cmux(failures)
test_partial_failures_only_fallback_failed_urls(failures)
test_legacy_toggle_fallback_passthrough(failures)
test_legacy_toggle_fallback_case_insensitive_passthrough(failures)
test_uppercase_scheme_routes_to_cmux(failures)
test_unicode_whitelist_matches_punycode_url(failures)
test_punycode_whitelist_matches_unicode_url(failures)
if failures:
print("open wrapper regression tests failed:")