{"thread":{"id":"66164","subject":"[PATCH] bundle-uri: refuse advertised URIs by protocol","startedAt":"2026-08-12T15:55:59Z","lastAt":"2026-08-12T15:55:59Z","messageCount":1,"participants":["Johannes Schindelin via GitGitGadget"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"550416","messageId":"pull.2200.git.1786550157424.gitgitgadget@gmail.com","threadId":"66164","inReplyTo":null,"subject":"[PATCH] bundle-uri: refuse advertised URIs by protocol","fromName":"Johannes Schindelin via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-12T15:55:56Z","receivedAt":"2026-08-12T15:55:59Z","isPatch":true,"body":"From: Johannes Schindelin <johannes.schindelin@gmx.de>\n\nServers may advertise bundle URIs that are not HTTP(S);\ncopy_uri_to_file() then opens it as a local path. On Windows that can be\na UNC path like `//attacker/share/x`, i.e. a clone can be manipulated\ninto making an outbound SMB connection that leaks NTLM credentials\n(CVE-2026-62960).\n\nSubject advertised URIs to the usual protocol allow-list\n(`protocol.*.allow`), which drops \"file\" (and bare/UNC paths) by default\nbut keeps http/https/git/ssh. Do it in fetch_bundle_list(), the\nclone/fetch consume path, so ls-remote still lists everything; each\nskipped URI is reported. A user-supplied `--bundle-uri` is unaffected,\nand `protocol.file.allow=always` re-enables an advertised file URI.\n\nAssisted-by: Opus 4.7\nSigned-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>\n---\n    bundle-uri: refuse advertised URIs by protocol\n    \n    This is the security fix released with Git for Windows v2.55.0(4). Due\n    to the transparent NTLM authentication (\"SSPI\"), the vulnerability\n    affects only Windows. The patch has been sent to the git-security list\n    on June 26th, 2026, but only received reviews in the PR in\n    https://github.com/git-for-windows/git/security/advisories/GHSA-xrpg-8j9v-v282's\n    private fork (which had to be deleted so that the advisory could be\n    published).\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2200%2Fdscho%2Frespect-allowed-protocols-in-bundle-uris-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2200/dscho/respect-allowed-protocols-in-bundle-uris-v1\nPull-Request: https://github.com/gitgitgadget/git/pull/2200\n\n bundle-uri.c                 | 50 ++++++++++++++++++++++++++++++++\n t/lib-bundle-uri-protocol.sh | 56 ++++++++++++++++++++++++++++++++++++\n 2 files changed, 106 insertions(+)\n\ndiff --git a/bundle-uri.c b/bundle-uri.c\nindex 2bb2eb99e4..92a36ca0ab 100644\n--- a/bundle-uri.c\n+++ b/bundle-uri.c\n@@ -15,6 +15,8 @@\n #include \"remote.h\"\n #include \"trace2.h\"\n #include \"odb.h\"\n+#include \"transport.h\"\n+#include \"url.h\"\n \n static struct {\n \tenum bundle_list_heuristic heuristic;\n@@ -890,11 +892,59 @@ cleanup:\n \treturn result;\n }\n \n+/* protocol of 'uri', or \"file\" if it has none (bare/UNC/relative path) */\n+static void bundle_uri_protocol(const char *uri, struct strbuf *out)\n+{\n+\tconst char *p = uri;\n+\n+\twhile (is_urlschemechar(p == uri, *p))\n+\t\tp++;\n+\tstrbuf_reset(out);\n+\tif (p > uri && starts_with(p, \"://\"))\n+\t\tstrbuf_add(out, uri, p - uri);\n+\telse\n+\t\tstrbuf_addstr(out, \"file\");\n+}\n+\n+/* Drop advertised URIs whose protocol is not allowed (see protocol.*.allow). */\n+static void sanitize_bundle_list(struct bundle_list *list)\n+{\n+\tstruct remote_bundle_info **skipped;\n+\tsize_t nr = 0, i;\n+\tstruct remote_bundle_info *info;\n+\tstruct hashmap_iter iter;\n+\tstruct strbuf proto = STRBUF_INIT;\n+\n+\tALLOC_ARRAY(skipped, hashmap_get_size(&list->bundles));\n+\thashmap_for_each_entry(&list->bundles, &iter, info, ent) {\n+\t\tif (!info->uri)\n+\t\t\tcontinue;\n+\t\tbundle_uri_protocol(info->uri, &proto);\n+\t\t/* advertised URIs are not user-provided */\n+\t\tif (!is_transport_allowed(proto.buf, 0)) {\n+\t\t\twarning(_(\"skipping bundle URI '%s': protocol '%s' \"\n+\t\t\t\t  \"is not allowed\"), info->uri, proto.buf);\n+\t\t\tskipped[nr++] = info;\n+\t\t}\n+\t}\n+\tstrbuf_release(&proto);\n+\n+\tfor (i = 0; i < nr; i++) {\n+\t\thashmap_remove(&list->bundles, &skipped[i]->ent, NULL);\n+\t\tclear_remote_bundle_info(skipped[i], NULL);\n+\t\tfree(skipped[i]);\n+\t}\n+\n+\tfree(skipped);\n+}\n+\n int fetch_bundle_list(struct repository *r, struct bundle_list *list)\n {\n \tint result;\n \tstruct bundle_list global_list;\n \n+\tsanitize_bundle_list(list);\n+\n \t/*\n \t * If the creationToken heuristic is used, then the URIs\n \t * advertised by 'list' are not nested lists and instead\ndiff --git a/t/lib-bundle-uri-protocol.sh b/t/lib-bundle-uri-protocol.sh\nindex 794478ae19..889e673a44 100644\n--- a/t/lib-bundle-uri-protocol.sh\n+++ b/t/lib-bundle-uri-protocol.sh\n@@ -237,3 +237,59 @@ test_expect_success \"test bundle-uri with $BUNDLE_URI_PROTOCOL:// using protocol\n \t\t>actual &&\n \ttest_cmp_config_output expect actual\n '\n+\n+# Advertised bundle URIs are subject to protocol.*.allow; \"file\" (and bare or\n+# UNC paths) is denied by default, so such a URI must be skipped, not fetched.\n+advertise_uri () {\n+\ttest_config -C \"$BUNDLE_URI_PARENT\" bundle.version 1 &&\n+\ttest_config -C \"$BUNDLE_URI_PARENT\" bundle.mode all &&\n+\ttest_config -C \"$BUNDLE_URI_PARENT\" bundle.payload.uri \"$1\"\n+}\n+\n+ignores_advertised_uri () {\n+\trm -rf victim &&\n+\tadvertise_uri \"$1\" &&\n+\tgit -c transfer.bundleURI=true -c protocol.version=2 \\\n+\t\tclone \"$BUNDLE_URI_REPO_URI\" victim &&\n+\tgit -C victim for-each-ref refs/bundles/ >refs &&\n+\ttest_must_be_empty refs\n+}\n+\n+test_expect_success \"create bundle to advertise\" '\n+\tgit -C \"$BUNDLE_URI_PARENT\" bundle create \"$PWD/payload.bundle\" main\n+'\n+\n+test_expect_success \"ignore non-HTTP(S) bundle URI with $BUNDLE_URI_PROTOCOL://\" '\n+\tignores_advertised_uri \"$PWD/payload.bundle\" &&\n+\tignores_advertised_uri \"file://$PWD/payload.bundle\"\n+'\n+\n+test_expect_success \"protocol.file.allow=always honors file bundle URI with $BUNDLE_URI_PROTOCOL://\" '\n+\trm -rf victim &&\n+\tadvertise_uri \"$PWD/payload.bundle\" &&\n+\tgit -c transfer.bundleURI=true -c protocol.version=2 \\\n+\t\t-c protocol.file.allow=always \\\n+\t\tclone \"$BUNDLE_URI_REPO_URI\" victim &&\n+\tgit -C victim rev-parse --verify refs/bundles/heads/main\n+'\n+\n+# same path via a UNC administrative share (cf. t5580-unc-paths.sh)\n+if test_have_prereq CYGWIN\n+then\n+\tUNCPATH=\"$(cygpath -aw .)\"\n+elif test_have_prereq MINGW\n+then\n+\tUNCPATH=\"$(pwd)\"\n+fi\n+case \"$UNCPATH\" in\n+[A-Za-z]:*)\n+\tWITHOUTDRIVE=\"${UNCPATH#?:}\"\n+\tUNCPATH=\"//localhost/${UNCPATH%%:*}\\$$WITHOUTDRIVE\"\n+\ttest -d \"$UNCPATH\" && test_set_prereq ADMIN_UNC\n+\t;;\n+esac\n+\n+test_expect_success ADMIN_UNC \"ignore UNC bundle URI with $BUNDLE_URI_PROTOCOL://\" '\n+\tignores_advertised_uri \"$UNCPATH/payload.bundle\" &&\n+\tignores_advertised_uri \"file://$UNCPATH/payload.bundle\"\n+'\n\nbase-commit: 11c6700f10234578d10523faf35656ca491425c9\n-- \ngitgitgadget\n"}]}