Recover from an ambient insteadOf rewrite onto a refused transport - #2
Merged
andrew merged 1 commit intoAug 6, 2026
Merged
Conversation
A url.<base>.insteadOf rule in the user's Git config is applied after ValidateURL has approved an https:// input, so it can move a request off the URL the caller validated. remoteEnv's GIT_ALLOW_PROTOCOL whitelist then refuses the transport it landed on, and the caller sees "fatal: transport 'ssh' not allowed" for a URL it never asked to have rewritten. Retry such a command once with the URL pinned to itself. Git resolves insteadOf by longest match, so a whole-URL self-map outranks any prefix rule and restores the validated URL, without discarding the proxy, CA bundle and credential settings that GIT_CONFIG_GLOBAL=os.DevNull would also take out. Reacting to Git's refusal rather than probing beforehand keeps working setups untouched: an https-to-https mirror rewrite and a transport the caller allowed through GIT_ALLOW_PROTOCOL are never refused, so they are never pinned. A refusal matches no transient marker, so the first attempt returns without backoff and the recovery costs one extra invocation on a command that had already failed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the
fatal: transport 'ssh' not allowedreported in alpha-omega-security/scrutineer#820.What is actually happening
This is not a transport bug — it is
remoteEnv's whitelist doing exactly what its comment says it does:The reporter's
~/.gitconfigis the documented case:The rewrite really did move the request off the URL that was validated, and refusing it is right. The gap is that failure is the only outcome offered, on a machine whose Git config is otherwise perfectly ordinary — forcing SSH for a forge is a common setup.
So I did not add
sshto the whitelist. That would let any ambient rule silently redirect a validated URL, including ontoext::, which executes a command. The loud failure is better than that.The fix
Retry once with the URL pinned to itself. Git resolves
insteadOfby longest match, so a whole-URL self-map outranks any prefix rule:Measured against the reporter's exact config, real
git, no stubs:This is deliberately narrower than
GIT_CONFIG_GLOBAL=os.DevNull, which would also discard the proxy, CA-bundle and credential settings a user legitimately keeps in the same file.Why it reacts to the refusal instead of probing first
My first cut probed every URL with
ls-remote --get-urlbefore running the real command. I dropped it for two reasons, the second of which is a bug I caught by testing rather than reasoning:Runner, so it shelled out to realgiteven under a stubbedRetry— the existing exact-argv tests would have started depending on the developer's own~/.gitconfig.insteadOftargets are very often Git's scp-like shorthand (git@github.com:o/r) which has no scheme at all. Git calls thatssh; a naiveurl.Parsedoes not.Reacting to Git's own refusal makes the shorthand, longest-match ordering and protocol naming stay Git's to interpret.
It also gives the change no regression surface, which I checked rather than assumed:
insteadOf = https://github.com/→https://gitlab.com/,--get-urlgiveshttps://gitlab.com/foo/barnormally andhttps://github.com/foo/baronce pinned. A blanket pin would silently bypass an operator's mirror. Reacting to a refusal cannot, because that config never fails.GIT_ALLOW_PROTOCOLis not refused either. This is what keeps this package's ownfile://-via-insteadOffixtures working — they passGIT_ALLOW_PROTOCOL=https:file, and the full suite is green.Cost: one extra invocation, only on a command that already failed. A refusal matches no transient marker, so
TransientFailuretreats it as permanent and the firstDoreturns after a single attempt with no backoff.Coverage
All four URL-bearing remote paths:
RemoteBranches,RemoteHead,Ensure's clone,fetchRef, andCache.EnsureCommit's unshallow.fetchRefgained aurlparameter for this — the fetch addresses the remote by name, but Git resolves origin's stored URL through the same rewrite rules, so fixing only the clone would leave the next scan broken.Tests, all offline (Git refuses before any network access):
Ensure: repository unreachable https://example.com/repo: fatal: transport 'ssh' not allowed.TestTransportRefusedMatchesGitsRefusalruns real git for both spellings —ssh://git@host/and the scp-likegit@host:— because the retry keys off Git's wording, and that is the fragile part of this design. Better to pin it against the binary than against a message I invented.TestPinnedURLOutranksAmbientPrefixRewriteproves the longest-match premise itself with real git via--get-url.go build,go vet,go test,go test -raceandgofmtall clean.Droppable, if you disagree
The
Cache.EnsureCommitandfetchRefcall sites are the ones I would cut first if you want this minimal — #820 only reports the add-repo path. I included them because a user who hits this hits it on every subsequent fetch too, but they are independent one-line changes.I also considered failing with an actionable message instead of recovering ("your gitconfig rewrites this URL to ssh://, which this tool does not permit") and can switch to that if you would rather the ambient config never be overridden silently. I went with recovery because
ValidateURLhas already established the caller's intent, but it is your call.