Share the git URL check, and refuse the transports Omarchy does not clone from (backport of #8174)

Backport of the shared URL check (PR #8174, merged to quattro as 68ab12f7) onto
the v4-0-1 release branch, on top of the #8067 backport it follows.

omarchy-theme-install and omarchy-plugin-add both clone a URL a stranger can
choose, and each carried its own copy of the rule that refuses a git option or a
transport helper before cloning. The rule now lives in omarchy-git-url-check and
both callers ask it: two copies of a security check drift, and the second copy
arrived four months after the first only because someone went looking for it.

That rule was also enforcing half of what it described. <helper>::<address> is
one of two shapes git resolves a remote helper from -- it also runs
git-remote-<scheme> for <scheme>://<address> whenever the scheme is not one it
connects itself, so ext::sh -c id and ext://sh -c id reach the same helper while
only the first was refused. Nothing exploitable follows on a stock system:
protocol.ext.allow defaults to never, and the :// spelling hands git-remote-ext
a command name it cannot exec. But a third-party helper installed on PATH is
reachable through the scheme form alone, and a guard is worth more when it
enforces the rule it states.

The :// shape cannot be refused the way :: is, because it is also how every
legitimate URL arrives, so the scheme is checked against the transports git
still connects itself: ssh git git+ssh ssh+git http https ftp ftps file.
git+ssh and ssh+git are on that list because they are spelled like a helper and
read as plain ssh; leaving them off would refuse a URL that clones today. ext
and fd are off it deliberately. A single colon is always scp-style ssh and a
bare path is always a path, so neither needs constraining.

The check fails closed: the callers read a non-zero status as a refusal, so a
missing omarchy-git-url-check refuses the URL rather than waving it through.

Clean cherry-pick on top of the #8067 backport: every file is byte-identical to
quattro, so merging v4-0-1 into quattro resolves without a conflict. test/shell
passes: 189 files, including the one this adds. test/cli passes, so the new
command's metadata is well-formed. Exercised the check here: https, scp-style,
ssh, git+ssh and scp-style IPv6 all pass, while ext:: ext:// fd:: fd:// and a
leading-dash form are refused, as is an uppercase EXT:// spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
This commit is contained in:
OmarchybotandClaude Opus 5 authored and David Heinemeier Hansson committed 2026-08-25 09:13:35 +02:00
1 parent c7af36d0aa
commit e713ff3166
6 files changed
+177 -17

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# omarchy:summary=Check that a git URL names a repository, not a transport helper
# omarchy:args=<git-url>
# 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: `<helper>::<address>`, and
# `<scheme>://<address>` 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
+6 -8
View File
@@ -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 `<helper>::<address>` 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 <<WARN
+4 -8
View File
@@ -16,14 +16,10 @@ if [[ -z $REPO_URL ]]; then
exit 1
fi
# git reads a leading dash as an option, and `<helper>::<address>` 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"
+79
View File
@@ -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,
# `<helper>::<address>` and `<scheme>://<address>`, 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
}
# `<helper>::<address>`, 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 <helper>::<address> URL is refused"
# `<scheme>://<address>`, the shape #8067 left open: git looks up
# git-remote-<scheme> 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 <scheme>://<address> 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"
+14
View File
@@ -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-<scheme> 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
+24 -1
View File
@@ -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-<scheme> 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