promisor-remote: trust known remotes matching acceptFromServerUrl

A previous commit introduced the `promisor.acceptFromServerUrl` config variable along with the machinery to parse and validate the URL glob patterns and optional remote name prefixes it contains. However, these URL patterns are not yet tied into the client's acceptance logic. When a promisor remote is already configured locally, its fields (like authentication tokens) may occasionally need to be refreshed by the server. If `promisor.acceptFromServer` is set to the secure default ("None"), these updates are rejected, potentially causing future fetches to fail. To enable such targeted updates for trusted URLs, let's use the URL patterns from `promisor.acceptFromServerUrl` as an additional URL based allowlist. Concretely, let's check the advertised URLs against the URL glob patterns by introducing a new small helper function called url_matches_accept_list(), which iterates over the glob patterns and returns the first matching allowed_url entry (or NULL). The URL matching is done component by component: scheme and port are compared exactly, the host and path are matched with wildmatch(). Before matching, the advertised URL is passed through url_normalize() so that case variations in the scheme/host, percent-encoding tricks, and ".." path segments cannot bypass the allowlist. The username and password components of the URL are intentionally ignored during matching to allow servers to rotate them, though using the 'token' field of the capability is preferred over embedding credentials in the URL. Let's then use this helper in should_accept_remote() so that a known remote whose URL matches the allowlist is accepted. To prepare for this new logic, let's also: - Add an 'accept_urls' parameter to should_accept_remote(). - Replace the BUG() guard in the ACCEPT_KNOWN_URL case with an explicit 'if (accept == ACCEPT_KNOWN_URL) return' and a new BUG() guard in the ACCEPT_NONE case. - Call accept_from_server_url() from filter_promisor_remote() and relax its early return so that the function is entered when `accept_urls` has entries even if `accept == ACCEPT_NONE`. With this, many organizations may only need something like: git config set --global \ promisor.acceptFromServerUrl "https://my-org.com/*" to accept only their own remotes. And if they need to accept additional remotes in some specific repos, they can also set: git config set promisor.acceptFromServer knownUrl and configure the additional remote manually only in the repos where they are needed. Let's then properly document `promisor.acceptFromServerUrl` in "promisor.adoc" as an additive security allowlist for known remotes, including the URL normalization behavior and the component-wise matching, and let's mention it in "gitprotocol-v2.adoc". Also let's clarify in the documentation how `promisor.acceptFromServerUrl` interacts with `promisor.acceptFromServer`: - Precedence: when both options are set, `promisor.acceptFromServerUrl` is consulted first. If a matching pattern leads to acceptance, the remote is accepted regardless of `promisor.acceptFromServer`. Otherwise the decision is left to `promisor.acceptFromServer`. - URL-mismatch guard: even when the advertised URL matches the allowlist, an already-existing client-side remote whose configured URL differs from the advertised one is not accepted through `promisor.acceptFromServerUrl`. `promisor.acceptFromServer=all` and `=knownName` keep their pre-existing, looser semantics. The precedence paragraph is intentionally scoped here to known remotes only (field updates). A following commit that introduces auto-creation of unknown remotes will extend it to cover that case as well. Signed-off-by: Christian Couder <chriscool@tuxfamily.org> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Christian Couder committed May 27, 2026 at 16:08 UTC 5dd8043581ca331dcb59ab721aefb5881128e124
4 files changed +244 -14
Documentation/config/promisor.adoc
+76
@@ -51,6 +51,82 @@ promisor.acceptFromServer::
51 to "fetch" and "clone" requests from the client. Name and URL
52 comparisons are case sensitive. See linkgit:gitprotocol-v2[5].
53
54 +promisor.acceptFromServerUrl::
55 + A glob pattern to specify which server-advertised URLs a
56 + client is allowed to act on. When a URL matches, the client
57 + will accept the advertised remote as a promisor remote and may
58 + automatically accept field updates (such as authentication
59 + tokens) from the server, even if `promisor.acceptFromServer`
60 + is set to `none` (the default).
61 ++
62 +This option can appear multiple times in config files. An advertised
63 +URL will be accepted if it matches _ANY_ glob pattern specified by
64 +this option in _ANY_ config file read by Git.
65 ++
66 +When both `promisor.acceptFromServer` and `promisor.acceptFromServerUrl`
67 +are set, `promisor.acceptFromServerUrl` is consulted first and takes
68 +precedence: if a matching pattern leads to acceptance (by accepting
69 +field updates for a known remote whose URL matches both the local
70 +configuration and the allowlist), the advertised remote is accepted
71 +regardless of the `promisor.acceptFromServer` setting. If no pattern
72 +in `promisor.acceptFromServerUrl` triggers acceptance, the decision
73 +is left to `promisor.acceptFromServer`.
74 ++
75 +Note however that, even when an advertised URL matches a pattern in
76 +`promisor.acceptFromServerUrl`, an already-existing remote on the
77 +client whose name matches the advertised name but whose configured URL
78 +differs from the advertised one will _NOT_ be accepted through
79 +`promisor.acceptFromServerUrl`. This prevents a server from silently
80 +re-pointing an existing client-side remote at a different URL. (Such a
81 +remote may still be accepted through `promisor.acceptFromServer=all`
82 +or `=knownName`, which have their own, looser semantics; see the
83 +documentation of that option.)
84 ++
85 +Be _VERY_ careful with these patterns: `*` matches any sequence of
86 +characters within the 'host' and 'path' parts of a URL (but cannot
87 +cross part boundaries). An overly broad pattern is a major security
88 +risk, as a matching URL allows a server to update fields (such as
89 +authentication tokens) on known remotes without further confirmation.
90 +To minimize security risks, follow these guidelines:
91 ++
92 +--
93 +1. Start with a secure protocol scheme, like `https://` or `ssh://`.
94 ++
95 +2. Only allow domain names or paths where you control and trust _ALL_
96 + the content. Be especially careful with shared hosting platforms
97 + like `github.com` or `gitlab.com`. A broad pattern like
98 + `https://gitlab.com/*` is dangerous because it trusts every
99 + repository on the entire platform. Always restrict such patterns to
100 + your specific organization or namespace (e.g.,
101 + `https://gitlab.com/your-org/*`).
102 ++
103 +3. Never use globs at the end of domain names. For example,
104 + `https://cdn.your-org.com/*` might be safe, but
105 + `https://cdn.your-org.com*/*` is a major security risk because
106 + the latter matches `https://cdn.your-org.com.hacker.net/repo`.
107 ++
108 +4. Be careful using globs at the beginning of domain names. While the
109 + code ensures a `*` in the host cannot cross into the path, a
110 + pattern like `https://*.example.com/*` will still match any
111 + subdomain. This is extremely dangerous on shared hosting platforms
112 + (e.g., `https://*.github.io/*` trusts every user's site on the
113 + entire platform).
114 +--
115 ++
116 +Before matching, both the advertised URL and the pattern are
117 +normalized: the scheme and host are lowercased, percent-encoded
118 +characters are decoded where possible, and path segments like `..`
119 +are resolved. The port must also match exactly (e.g.,
120 +`https://example.com:8080/*` will not match a URL advertised on
121 +port 9999). The username and password components of the URL are
122 +ignored during matching. Note that embedding credentials in URLs is
123 +discouraged. Passing authentication tokens via the `token` field of
124 +the `promisor-remote` capability is strongly preferred.
125 ++
126 +For the security implications of accepting a promisor remote, see the
127 +documentation of `promisor.acceptFromServer`. For details on the
128 +protocol, see linkgit:gitprotocol-v2[5].
129 +
130 promisor.checkFields::
131 A comma or space separated list of additional remote related
132 field names. A client checks if the values of these fields
Documentation/gitprotocol-v2.adoc
+5 -4
@@ -866,10 +866,11 @@ the server advertised, the client shouldn't advertise the
866
867 On the server side, the "promisor.advertise" and "promisor.sendFields"
868 configuration options can be used to control what it advertises. On
869 -the client side, the "promisor.acceptFromServer" configuration option
870 -can be used to control what it accepts, and the "promisor.storeFields"
871 -option, to control what it stores. See the documentation of these
872 -configuration options in linkgit:git-config[1] for more information.
869 +the client side, the "promisor.acceptFromServer" and
870 +"promisor.acceptFromServerUrl" configuration options can be used to
871 +control what it accepts, and the "promisor.storeFields" option, to
872 +control what it stores. See the documentation of these configuration
873 +options in linkgit:git-config[1] for more information.
874
875 Note that in the future it would be nice if the "promisor-remote"
876 protocol capability could be used by the server, when responding to
promisor-remote.c
+92 -10
@@ -14,6 +14,7 @@
14 #include "url.h"
15 #include "urlmatch.h"
16 #include "version.h"
17 +#include "wildmatch.h"
18
19 struct promisor_remote_config {
20 struct promisor_remote *promisors;
@@ -742,8 +743,79 @@ static void load_accept_from_server_url(struct repository *repo,
743 }
744 }
745
746 +static bool match_pattern_url(const char *pat, size_t pat_len,
747 + const char *url, size_t url_len)
748 +{
749 + char *p_str = xstrndup(pat, pat_len);
750 + char *u_str = xstrndup(url, url_len);
751 + bool res = !wildmatch(p_str, u_str, 0);
752 +
753 + free(p_str);
754 + free(u_str);
755 +
756 + return res;
757 +}
758 +
759 +static bool match_one_url(const struct url_info *pi, const struct url_info *ui)
760 +{
761 + const char *pat = pi->url;
762 + const char *url = ui->url;
763 +
764 + /*
765 + * Schemes must match exactly. They are case-folded by
766 + * url_normalize(), so strncmp() suffices.
767 + */
768 + if (pi->scheme_len != ui->scheme_len || strncmp(pat, url, pi->scheme_len))
769 + return false;
770 +
771 + /*
772 + * Ports must match exactly. url_normalize() strips default
773 + * ports (like 443 for https), so length and content
774 + * comparisons are sufficient.
775 + */
776 + if (pi->port_len != ui->port_len ||
777 + strncmp(pat + pi->port_off, url + ui->port_off, pi->port_len))
778 + return false;
779 +
780 + /*
781 + * Match host and path separately to prevent a '*' in the host
782 + * portion of the pattern from matching across the '/'
783 + * boundary into the path.
784 + */
785 +
786 + return match_pattern_url(pat + pi->host_off, pi->host_len,
787 + url + ui->host_off, ui->host_len) &&
788 + match_pattern_url(pat + pi->path_off, pi->path_len,
789 + url + ui->path_off, ui->path_len);
790 +}
791 +
792 +static struct allowed_url *url_matches_accept_list(
793 + struct string_list *accept_urls, const char *url)
794 +{
795 + struct string_list_item *item;
796 + struct url_info url_info;
797 +
798 + url_info.url = url_normalize(url, &url_info);
799 +
800 + if (!url_info.url)
801 + return NULL;
802 +
803 + for_each_string_list_item(item, accept_urls) {
804 + struct allowed_url *allowed = item->util;
805 +
806 + if (match_one_url(&allowed->pattern_info, &url_info)) {
807 + free(url_info.url);
808 + return allowed;
809 + }
810 + }
811 +
812 + free(url_info.url);
813 + return NULL;
814 +}
815 +
816 static int should_accept_remote(enum accept_promisor accept,
817 struct promisor_info *advertised,
818 + struct string_list *accept_urls,
819 struct string_list *config_info)
820 {
821 struct promisor_info *p;
@@ -756,23 +828,27 @@ static int should_accept_remote(enum accept_promisor accept,
828 "this remote should have been rejected earlier",
829 remote_name);
830
759 - if (accept == ACCEPT_ALL)
760 - return all_fields_match(advertised, config_info, NULL);
761 -
831 /* Get config info for that promisor remote */
832 item = string_list_lookup(config_info, remote_name);
833
765 - if (!item)
834 + if (!item) {
835 /* We don't know about that remote */
836 + if (accept == ACCEPT_ALL)
837 + return all_fields_match(advertised, config_info, NULL);
838 return 0;
839 + }
840
841 p = item->util;
842
771 - if (accept == ACCEPT_KNOWN_NAME)
843 + /* Known remote in the allowlist? */
844 + if (!strcmp(p->url, remote_url) && url_matches_accept_list(accept_urls, remote_url))
845 return all_fields_match(advertised, config_info, p);
846
774 - if (accept != ACCEPT_KNOWN_URL)
775 - BUG("Unhandled 'enum accept_promisor' value '%d'", accept);
847 + if (accept == ACCEPT_ALL)
848 + return all_fields_match(advertised, config_info, NULL);
849 +
850 + if (accept == ACCEPT_KNOWN_NAME)
851 + return all_fields_match(advertised, config_info, p);
852
853 if (strcmp(p->url, remote_url)) {
854 warning(_("known remote named '%s' but with URL '%s' instead of '%s', "
@@ -781,7 +857,13 @@ static int should_accept_remote(enum accept_promisor accept,
857 return 0;
858 }
859
784 - return all_fields_match(advertised, config_info, p);
860 + if (accept == ACCEPT_KNOWN_URL)
861 + return all_fields_match(advertised, config_info, p);
862 +
863 + if (accept != ACCEPT_NONE)
864 + BUG("Unhandled 'enum accept_promisor' value '%d'", accept);
865 +
866 + return 0;
867 }
868
869 static int skip_field_name_prefix(const char *elem, const char *field_name, const char **value)
@@ -991,7 +1073,7 @@ static void filter_promisor_remote(struct repository *repo,
1073 /* Load and validate the acceptFromServerUrl config */
1074 load_accept_from_server_url(repo, &accept_urls);
1075
994 - if (accept == ACCEPT_NONE)
1076 + if (accept == ACCEPT_NONE && !accept_urls.nr)
1077 return;
1078
1079 /* Parse remote info received */
@@ -1011,7 +1093,7 @@ static void filter_promisor_remote(struct repository *repo,
1093 string_list_sort(&config_info);
1094 }
1095
1014 - if (should_accept_remote(accept, advertised, &config_info)) {
1096 + if (should_accept_remote(accept, advertised, &accept_urls, &config_info)) {
1097 if (!store_info)
1098 store_info = store_info_new(repo);
1099 if (promisor_store_advertised_fields(advertised, store_info))
t/t5710-promisor-remote-capability.sh
+71
@@ -387,6 +387,77 @@ test_expect_success "clone with 'KnownUrl' and empty url, so not advertised" '
387 check_missing_objects server 1 "$oid"
388 '
389
390 +test_expect_success "clone with 'None' but URL allowlisted" '
391 + git -C server config promisor.advertise true &&
392 + test_when_finished "rm -rf client" &&
393 +
394 + GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \
395 + -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \
396 + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \
397 + -c promisor.acceptfromserver=None \
398 + -c promisor.acceptFromServerUrl="$ENCODED_TRASH_DIRECTORY_URL/*" \
399 + --no-local --filter="blob:limit=5k" server client &&
400 +
401 + # Check that the largest object is still missing on the server
402 + check_missing_objects server 1 "$oid"
403 +'
404 +
405 +test_expect_success "clone with 'None' but URL not in allowlist" '
406 + git -C server config promisor.advertise true &&
407 + test_when_finished "rm -rf client" &&
408 +
409 + GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \
410 + -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \
411 + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \
412 + -c promisor.acceptfromserver=None \
413 + -c promisor.acceptFromServerUrl="https://example.com/*" \
414 + --no-local --filter="blob:limit=5k" server client &&
415 +
416 + # Check that the largest object is not missing on the server
417 + check_missing_objects server 0 "" &&
418 +
419 + # Reinitialize server so that the largest object is missing again
420 + initialize_server 1 "$oid"
421 +'
422 +
423 +test_expect_success "clone with 'None' but URL allowlisted in one pattern out of two" '
424 + git -C server config promisor.advertise true &&
425 + test_when_finished "rm -rf client" &&
426 +
427 + GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \
428 + -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \
429 + -c remote.lop.url="$TRASH_DIRECTORY_URL/lop" \
430 + -c promisor.acceptfromserver=None \
431 + -c promisor.acceptFromServerUrl="https://example.com/*" \
432 + -c promisor.acceptFromServerUrl="$ENCODED_TRASH_DIRECTORY_URL/*" \
433 + --no-local --filter="blob:limit=5k" server client &&
434 +
435 + # Check that the largest object is still missing on the server
436 + check_missing_objects server 1 "$oid"
437 +'
438 +
439 +test_expect_success "clone with 'None', URL allowlisted, but client has different URL" '
440 + git -C server config promisor.advertise true &&
441 + test_when_finished "rm -rf client" &&
442 +
443 + # The client configures "lop" with a different URL (serverTwo) than
444 + # what the server advertises (lop). Even though the advertised URL
445 + # matches the allowlist, the remote is rejected because the
446 + # configured URL does not match the advertised one.
447 + GIT_NO_LAZY_FETCH=0 git clone -c remote.lop.promisor=true \
448 + -c remote.lop.fetch="+refs/heads/*:refs/remotes/lop/*" \
449 + -c remote.lop.url="$TRASH_DIRECTORY_URL/serverTwo" \
450 + -c promisor.acceptfromserver=None \
451 + -c promisor.acceptFromServerUrl="$ENCODED_TRASH_DIRECTORY_URL/*" \
452 + --no-local --filter="blob:limit=5k" server client &&
453 +
454 + # Check that the largest object is not missing on the server
455 + check_missing_objects server 0 "" &&
456 +
457 + # Reinitialize server so that the largest object is missing again
458 + initialize_server 1 "$oid"
459 +'
460 +
461 test_expect_success "clone with invalid promisor.acceptFromServerUrl" '
462 git -C server config promisor.advertise true &&
463 test_when_finished "rm -rf client" &&