diff --git a/bin/omarchy-git-url-check b/bin/omarchy-git-url-check new file mode 100755 index 00000000..53109949 --- /dev/null +++ b/bin/omarchy-git-url-check @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Check that a git URL names a repository, not a transport helper +# omarchy:args= +# omarchy:hidden=true + +set -euo pipefail + +# git picks a remote helper -- an executable it runs at clone time -- out of a URL +# in exactly two shapes, and no others: `::
`, and +# `://
` for any scheme git does not handle itself. A single +# colon is always scp-style ssh, and a bare path is always a path; neither can +# reach a helper. So constraining those two shapes covers the whole surface. +# +# The `::` shape is refused outright, because no helper reachable that way is one +# a theme or plugin URL has business naming, and `ext::` runs a shell command. +# The `://` shape cannot be refused the same way, since it is also how every +# legitimate URL arrives -- so it is allowlisted instead. The list is the +# transports git still connects itself, `git+ssh` and `ssh+git` included: those +# two are spelled like a helper but are read as plain ssh. `ext` and `fd` are +# left out deliberately -- git ships a helper for each, and `ext` runs whatever +# command the URL carries. +TRANSPORTS=(ssh git git+ssh ssh+git http https ftp ftps file) + +fail() { + echo "omarchy-git-url-check: $*" >&2 + exit 1 +} + +url="${1-}" + +if [[ -z $url ]]; then + fail "a git URL is required" +fi + +if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then + fail "'$url' names a git option or transport helper, not a repository." +fi + +if [[ $url =~ ^([A-Za-z0-9][A-Za-z0-9+.-]*):// ]]; then + scheme="${BASH_REMATCH[1]}" + + for transport in "${TRANSPORTS[@]}"; do + if [[ $scheme == "$transport" ]]; then + exit 0 + fi + done + + fail "'$url' names the '$scheme' transport, which Omarchy does not clone from." +fi diff --git a/bin/omarchy-plugin-add b/bin/omarchy-plugin-add index 9fa0e6c0..d77593e9 100755 --- a/bin/omarchy-plugin-add +++ b/bin/omarchy-plugin-add @@ -93,14 +93,12 @@ if [[ -z $url ]]; then [[ -n $url ]] || fail "a git URL is required" fi -# git reads a leading dash as an option, and `::
` as a remote -# helper to run at clone time. Reject both so an untrusted URL cannot smuggle a -# transport helper that executes before the plugin is validated or enabled. An -# scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git carries `::` too and -# must still clone. -if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then - fail "'$url' names a git option or transport helper, not a repository." -fi +# Refuse a URL that names a git option or a transport helper before cloning, so +# an untrusted URL cannot run a command before the plugin is validated or +# enabled. The check is shared with omarchy-theme-install and explains itself; a +# missing checker leaves this non-zero, which refuses the URL rather than +# cloning it. +omarchy-git-url-check "$url" || exit 1 if (( ! ASSUME_YES )); then cat >&2 <::
` as a remote -# helper to run. The helper name is a bare word at the very start, which is what -# this matches; an scp-style IPv6 host such as git@[2001:db8::1]:org/repo.git -# carries `::` too and must still clone. -if [[ $REPO_URL == -* || $REPO_URL =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then - echo "Error: '$REPO_URL' names a git option or transport helper, not a repository." - exit 1 -fi +# Refuse a URL that names a git option or a transport helper before cloning. The +# check is shared with omarchy-plugin-add and explains itself; a missing checker +# leaves this non-zero, which refuses the URL rather than cloning it. +omarchy-git-url-check "$REPO_URL" || exit 1 THEMES_DIR="$HOME/.config/omarchy/themes" diff --git a/test/shell.d/git-url-check-test.sh b/test/shell.d/git-url-check-test.sh new file mode 100755 index 00000000..405fc048 --- /dev/null +++ b/test/shell.d/git-url-check-test.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +set -euo pipefail + +# omarchy-git-url-check decides which URLs omarchy-theme-install and +# omarchy-plugin-add are willing to hand to `git clone`. git resolves a remote +# helper -- a program it runs at clone time -- from exactly two URL shapes, +# `::
` and `://
`, so those are the two shapes +# asserted here, alongside every legitimate form a user is likely to paste. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +check() { + "$ROOT/bin/omarchy-git-url-check" "$@" 2>&1 +} + +# `::
`, the shape that runs a program. `ext::` is the dangerous +# one: git runs the rest as a shell command once protocol.ext.allow permits it. +for url in "ext::sh -c id" "fd::0,1" "gcrypt::x" "a+b::x" "a.b::x" "a-b::x" "1::x"; do + output=$(check "$url") && + fail "omarchy-git-url-check refuses the transport helper '$url'" "$output" + grep -qF "names a git option or transport helper" <<<"$output" || + fail "omarchy-git-url-check names the helper rejection for '$url'" "$output" +done + +pass "a ::
URL is refused" + +# `://
`, the shape #8067 left open: git looks up +# git-remote- for any scheme it does not implement itself, so an +# allowlist is the only form of this check that holds. +for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x" "zzz://a" "ZZZ://a" "HTTPS://github.com/a/b"; do + output=$(check "$url") && + fail "omarchy-git-url-check refuses the '$url' transport" "$output" + grep -qF "which Omarchy does not clone from" <<<"$output" || + fail "omarchy-git-url-check names the transport rejection for '$url'" "$output" +done + +pass "a ://
URL outside git's own transports is refused" + +# A leading dash is an option to git, not a URL. +for url in "-x" "--upload-pack=touch /tmp/pwned" "-oProxyCommand=x"; do + output=$(check "$url") && + fail "omarchy-git-url-check refuses the option '$url'" "$output" +done + +pass "a URL shaped like a git option is refused" + +output=$(check "") && fail "omarchy-git-url-check refuses an empty URL" "$output" +output=$(check) && fail "omarchy-git-url-check refuses a missing URL" "$output" + +pass "an empty URL is refused" + +# Everything a user actually pastes. The scp-style forms carry a single colon, +# which git never reads as a helper, and the IPv6 host carries `::` inside +# brackets rather than at the start. +for url in \ + "https://github.com/acme/omarchy-weather.git" \ + "http://example.com/a/b.git" \ + "https://user:token@github.com/acme/repo.git" \ + "ssh://git@github.com/acme/repo.git" \ + "ssh://git@[2001:db8::1]:22/org/repo.git" \ + "git://example.com/repo.git" \ + "git+ssh://git@example.com/acme/repo.git" \ + "ssh+git://git@example.com/acme/repo.git" \ + "ftp://example.com/repo.git" \ + "ftps://example.com/repo.git" \ + "file:///home/me/repo" \ + "git@github.com:acme/repo.git" \ + "git@[2001:db8::1]:org/repo.git" \ + "host:-s/foo.git" \ + "/home/me/repo" \ + "./repo" \ + "../repo" \ + "repo"; do + output=$(check "$url") || + fail "omarchy-git-url-check accepts the legitimate URL '$url'" "$output" +done + +pass "the URL forms a user pastes are accepted" diff --git a/test/shell.d/plugin-add-test.sh b/test/shell.d/plugin-add-test.sh index 477d39a8..3e3ca57a 100644 --- a/test/shell.d/plugin-add-test.sh +++ b/test/shell.d/plugin-add-test.sh @@ -111,6 +111,20 @@ for bad in "ext::sh -c touch /tmp/omarchy-guard-test" "fd::17"; do done pass "plugin add rejects transport-helper URLs before cloning" +# The `://` spelling of the same thing: git resolves git-remote- for any +# scheme it does not implement itself, so `ext::` and `ext://` reach the same +# helper and both have to be refused. +for bad in "ext://sh -c id" "gcrypt://example.com/x"; do + rm -f "$clone_marker" + output=$(add_url "$bad") && + fail "plugin add rejects a transport-scheme URL: $bad" "$output" + grep -qF "which Omarchy does not clone from" <<<"$output" || + fail "plugin add names the transport-scheme rejection: $bad" "$output" + [[ ! -e $clone_marker ]] || + fail "plugin add reached git clone for a transport-scheme URL: $bad" +done +pass "plugin add rejects transport-scheme URLs before cloning" + # Option-shaped URLs on argv are refused before clone — by the option parser # (`-*` falls to "unknown add option"), not the guard. The guard's own # leading-dash arm is only reachable through the interactive gum prompt and is diff --git a/test/shell.d/theme-install-guards-test.sh b/test/shell.d/theme-install-guards-test.sh index be2d41f5..bc29338e 100755 --- a/test/shell.d/theme-install-guards-test.sh +++ b/test/shell.d/theme-install-guards-test.sh @@ -40,7 +40,7 @@ install_theme() { : >"$git_calls" : >"$theme_calls" - HOME="$test_tmp/home" PATH="$mock_bin:$PATH" \ + HOME="$test_tmp/home" PATH="${2-$mock_bin:$ROOT/bin:$PATH}" \ OMARCHY_TEST_GIT_CALLS="$git_calls" OMARCHY_TEST_THEME_CALLS="$theme_calls" \ bash "$ROOT/bin/omarchy-theme-install" "$1" >"$test_tmp/out" 2>&1 || return $? } @@ -58,6 +58,29 @@ done pass "a URL that names a git option or a transport helper never reaches git" +# git resolves git-remote- for any scheme it does not implement itself, +# so the `://` spelling of a helper has to be refused as well as the `::` one. +for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x"; do + if install_theme "$url"; then + fail "omarchy-theme-install refuses the URL '$url'" + fi + + [[ ! -s $git_calls ]] || fail "omarchy-theme-install refuses '$url' before running git" "$(cat "$git_calls")" +done + +pass "a URL naming a transport git does not implement never reaches git" + +# The checker is a separate command, so its absence has to refuse the URL rather +# than wave it through to git. +if install_theme "https://github.com/example/omarchy-cool-theme.git" "$mock_bin:$PATH"; then + fail "omarchy-theme-install refuses a URL it cannot check" +fi + +[[ ! -s $git_calls ]] || + fail "omarchy-theme-install refuses an unchecked URL before running git" "$(cat "$git_calls")" + +pass "a missing url checker refuses the URL instead of cloning it" + # A URL whose derived name would escape the themes directory. for url in "https://example.com/..git" "https://example.com/.git"; do if install_theme "$url"; then