From 3afa345f3a45bda781a22a8ff67c22ead74ccdf3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:38:37 -0800 Subject: [PATCH] Harden open wrapper for Bash 3 and IDN host parity --- Resources/bin/open | 99 +++++++++++++++++++++++++++++++++----- tests/test_open_wrapper.py | 95 ++++++++++++++++++++++++++++++++++-- 2 files changed, 176 insertions(+), 18 deletions(-) diff --git a/Resources/bin/open b/Resources/bin/open index f4104d90..9c81ea54 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -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 diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py index e602eca7..6119033a 100755 --- a/tests/test_open_wrapper.py +++ b/tests/test_open_wrapper.py @@ -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:")